From a726f18b4057686cef92ca91da6b6bc07d8d5afd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:13:13 +0900 Subject: [PATCH 001/229] test(core): require current origin before protocol dispatch --- ...rowser_context_origin_protocol_dispatch.rs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs new file mode 100644 index 000000000..ace88216e --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs @@ -0,0 +1,170 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, + DocumentEpoch, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +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 DispatchOutcome = Result<(u64, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse, DocumentEpoch) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|error| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + error.to_string(), + )) as Box + }) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch( + validated: ValidatedBrowserProtocolUse, + current_epoch: DocumentEpoch, +) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok((current_epoch.value(), validated.capability())) +} + +#[test] +fn exact_current_origin_and_protocol_metadata_gate_one_dispatch_call() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + registry.bind_context_origin(session, context, expected_origin.clone())?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!( + result, + Ok((1, BrowserProtocolCapability::SemanticObservation)) + ); + Ok(()) +} + +#[test] +fn origin_mismatch_or_unbound_origin_fails_before_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let other_origin = origin("https://other.example")?; + registry.bind_context_origin(session, context, expected_origin.clone())?; + + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextDispatchTarget::new(session, context), + &other_origin, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance + )) + ); + assert!(!dispatch_was_called()); + + registry.advance_document(context)?; + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::ContextOriginNotBound + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn protocol_mismatch_after_origin_revalidation_still_prevents_dispatch() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + registry.bind_context_origin(session, context, expected_origin.clone())?; + reset_dispatch_marker(); + + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} From 45feac28d4c6262aa825715586f41ac6452d041c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:16:59 +0900 Subject: [PATCH 002/229] test(core): format origin protocol dispatch contract --- .../tests/browser_context_origin_protocol_dispatch.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs index ace88216e..1be315ba1 100644 --- a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs @@ -66,7 +66,8 @@ fn successful_dispatch( } #[test] -fn exact_current_origin_and_protocol_metadata_gate_one_dispatch_call() -> Result<(), Box> { +fn exact_current_origin_and_protocol_metadata_gate_one_dispatch_call() -> Result<(), Box> +{ let descriptor = descriptor()?; let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("webdriver-session")?; @@ -167,4 +168,4 @@ fn protocol_mismatch_after_origin_revalidation_still_prevents_dispatch() ); assert!(!dispatch_was_called()); Ok(()) -} +} \ No newline at end of file From b294fccf684adf6988c739b2a58fa15a4d3bdb4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:18:32 +0900 Subject: [PATCH 003/229] test(core): preserve canonical test newline --- .../tests/browser_context_origin_protocol_dispatch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs index 1be315ba1..f409d7a6e 100644 --- a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs @@ -168,4 +168,4 @@ fn protocol_mismatch_after_origin_revalidation_still_prevents_dispatch() ); assert!(!dispatch_was_called()); Ok(()) -} \ No newline at end of file +} From 56e3142538bd2499a880616ba2980f256838cb45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:21:26 +0900 Subject: [PATCH 004/229] test(core): isolate missing origin dispatch boundary --- .../tests/browser_context_origin_protocol_dispatch.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs index f409d7a6e..9aaf67a0f 100644 --- a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs @@ -41,10 +41,10 @@ fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> } fn origin(value: &str) -> Result> { - Origin::parse(value).map_err(|error| { + Origin::parse(value).map_err(|_| { Box::new(io::Error::new( io::ErrorKind::InvalidInput, - error.to_string(), + "invalid controlled origin fixture", )) as Box }) } @@ -73,7 +73,7 @@ fn exact_current_origin_and_protocol_metadata_gate_one_dispatch_call() -> Result let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; let expected_origin = origin("https://app.example")?; - registry.bind_context_origin(session, context, expected_origin.clone())?; + registry.bind_context_origin(session, context, &expected_origin)?; reset_dispatch_marker(); let result = descriptor.dispatch_if_context_origin_current( @@ -102,7 +102,7 @@ fn origin_mismatch_or_unbound_origin_fails_before_dispatch() -> Result<(), Box Date: Wed, 12 Aug 2026 20:24:16 +0900 Subject: [PATCH 005/229] feat(core): revalidate current origin before protocol dispatch --- .../src/browser_protocol_dispatch.rs | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index 97c7777da..aa3ccfb75 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -3,7 +3,8 @@ use std::fmt; use crate::{ BrowserAuthorityRegistry, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolUseValidationError, BrowserRegistryError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, }; /// Current runtime metadata sampled from the browser-protocol adapter about to perform I/O. @@ -145,6 +146,48 @@ impl BrowserProtocolAdapterDescriptor { ) .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) } + + /// Revalidate exact browser session/context/origin authority and protocol metadata before I/O. + /// + /// The registry first proves that `expected_origin` is the canonical origin currently bound to + /// the supplied browser session and browsing context and returns that document's current epoch. + /// Only then are the exact protocol generation, runtime protocol family, adapter version, + /// upstream/browser revisions, and required capability validated. `dispatch` receives both the + /// non-cloneable protocol-use proof and the epoch sampled by that origin revalidation. + /// + /// This method does not derive the origin from Chromium, authenticate the adapter process, + /// authorize a destination/network/TLS/HTTP operation, grant Agent capability or approval, or + /// prove a post-condition. The caller must obtain `expected_origin` and `runtime_metadata` from + /// the trusted adapter about to perform I/O and prevent intervening registry mutation across its + /// larger execution transaction. + pub fn dispatch_if_context_origin_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextDispatchTarget, + expected_origin: &Origin, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let current_epoch = authority_registry + .require_context_origin( + target.browser_session(), + target.browsing_context(), + expected_origin, + ) + .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; + self.dispatch_if_runtime_matches( + required_originweave_protocol_version, + runtime_metadata, + required_capability, + |validated| dispatch(validated, current_epoch), + ) + .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) + } } /// Failure to compose current browser context ownership with protocol validation before dispatch. From 801dc76c766bf4dca03700d2b1e6216fd54402ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:35:57 +0900 Subject: [PATCH 006/229] fix(core): group origin dispatch authority target --- .../src/browser_protocol_dispatch.rs | 61 +++++++++++++++---- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index aa3ccfb75..58b4f2a18 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -79,6 +79,41 @@ impl BrowserContextDispatchTarget { } } +/// Exact browser context plus the canonical origin expected immediately before protocol dispatch. +/// +/// Grouping these values keeps one authority target explicit while avoiding a long positional +/// argument list at the dispatch boundary. Construction does not prove that the context is current +/// or that the origin is bound; [`BrowserProtocolAdapterDescriptor::dispatch_if_context_origin_current`] +/// performs those fail-closed checks immediately before protocol validation and callback execution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextOriginDispatchTarget<'a> { + context: BrowserContextDispatchTarget, + expected_origin: &'a Origin, +} + +impl<'a> BrowserContextOriginDispatchTarget<'a> { + /// Group one browser context target with its freshly sampled canonical origin. + #[must_use] + pub const fn new(context: BrowserContextDispatchTarget, expected_origin: &'a Origin) -> Self { + Self { + context, + expected_origin, + } + } + + /// Return the exact browser session/context pair requested for dispatch. + #[must_use] + pub const fn context(self) -> BrowserContextDispatchTarget { + self.context + } + + /// Return the canonical origin expected to remain current for the dispatch. + #[must_use] + pub const fn expected_origin(self) -> &'a Origin { + self.expected_origin + } +} + impl BrowserProtocolAdapterDescriptor { /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. /// @@ -149,22 +184,21 @@ impl BrowserProtocolAdapterDescriptor { /// Revalidate exact browser session/context/origin authority and protocol metadata before I/O. /// - /// The registry first proves that `expected_origin` is the canonical origin currently bound to - /// the supplied browser session and browsing context and returns that document's current epoch. - /// Only then are the exact protocol generation, runtime protocol family, adapter version, - /// upstream/browser revisions, and required capability validated. `dispatch` receives both the - /// non-cloneable protocol-use proof and the epoch sampled by that origin revalidation. + /// The registry first proves that `target.expected_origin()` is the canonical origin currently + /// bound to the supplied browser session and browsing context and returns that document's + /// current epoch. Only then are the exact protocol generation, runtime protocol family, adapter + /// version, upstream/browser revisions, and required capability validated. `dispatch` receives + /// both the non-cloneable protocol-use proof and the epoch sampled by that origin revalidation. /// /// This method does not derive the origin from Chromium, authenticate the adapter process, /// authorize a destination/network/TLS/HTTP operation, grant Agent capability or approval, or - /// prove a post-condition. The caller must obtain `expected_origin` and `runtime_metadata` from - /// the trusted adapter about to perform I/O and prevent intervening registry mutation across its - /// larger execution transaction. + /// prove a post-condition. The caller must construct `target` from the origin freshly sampled + /// from the trusted adapter about to perform I/O, sample `runtime_metadata` from that same + /// adapter, and prevent intervening registry mutation across its larger execution transaction. pub fn dispatch_if_context_origin_current( &self, authority_registry: &BrowserAuthorityRegistry, - target: BrowserContextDispatchTarget, - expected_origin: &Origin, + target: BrowserContextOriginDispatchTarget<'_>, required_originweave_protocol_version: OriginWeaveProtocolVersion, runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, required_capability: BrowserProtocolCapability, @@ -173,11 +207,12 @@ impl BrowserProtocolAdapterDescriptor { where F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, { + let context = target.context(); let current_epoch = authority_registry .require_context_origin( - target.browser_session(), - target.browsing_context(), - expected_origin, + context.browser_session(), + context.browsing_context(), + target.expected_origin(), ) .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; self.dispatch_if_runtime_matches( From 2575a42770a8c2fe90c51aeef71236d6a28f0251 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:36:27 +0900 Subject: [PATCH 007/229] fix(core): export origin dispatch target --- crates/originweave-core/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 19c2c59a9..3d83031ac 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -23,8 +23,8 @@ pub use browser_protocol::{ OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; pub use browser_protocol_dispatch::{ - BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, - BrowserProtocolRuntimeMetadata, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From 144895d5b86ca6b4cceeb0653638e41e9578f130 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:37:05 +0900 Subject: [PATCH 008/229] test(core): exercise grouped origin dispatch target --- ...rowser_context_origin_protocol_dispatch.rs | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs index 9aaf67a0f..986d4cdd4 100644 --- a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs @@ -1,10 +1,11 @@ use std::{cell::Cell, error::Error, io}; use originweave_core::{ - BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, - DocumentEpoch, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, + BrowserProtocolUseValidationError, BrowserRegistryError, DocumentEpoch, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -78,8 +79,10 @@ fn exact_current_origin_and_protocol_metadata_gate_one_dispatch_call() -> Result let result = descriptor.dispatch_if_context_origin_current( ®istry, - BrowserContextDispatchTarget::new(session, context), - &expected_origin, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ), ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata(ADAPTER_VERSION), BrowserProtocolCapability::SemanticObservation, @@ -108,8 +111,10 @@ fn origin_mismatch_or_unbound_origin_fails_before_dispatch() -> Result<(), Box Result<(), Box Date: Wed, 12 Aug 2026 23:42:18 +0900 Subject: [PATCH 009/229] test(core): require document epoch at protocol dispatch --- ..._context_origin_epoch_protocol_dispatch.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs diff --git a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs new file mode 100644 index 000000000..c0aeb87ca --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs @@ -0,0 +1,136 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, +}; + +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 DispatchOutcome = Result<(u64, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse, DocumentEpoch) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::TypedInput], + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch( + validated: ValidatedBrowserProtocolUse, + current_epoch: DocumentEpoch, +) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok((current_epoch.value(), validated.capability())) +} + +#[test] +fn exact_context_origin_epoch_and_protocol_metadata_gate_one_dispatch_call() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let expected_epoch = registry.bind_context_origin(session, context, &expected_origin)?; + let context_origin = BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ); + let target = BrowserContextOriginEpochDispatchTarget::new(context_origin, expected_epoch); + + assert_eq!(target.context_origin(), context_origin); + assert_eq!(target.expected_epoch(), expected_epoch); + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!(result, Ok((1, BrowserProtocolCapability::TypedInput))); + Ok(()) +} + +#[test] +fn same_origin_new_document_epoch_fails_before_protocol_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let observed_epoch = registry.bind_context_origin(session, context, &expected_origin)?; + let context_origin = BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ); + let target = BrowserContextOriginEpochDispatchTarget::new(context_origin, observed_epoch); + + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin(session, context, &expected_origin)?; + reset_dispatch_marker(); + + assert_eq!( + descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: observed_epoch, + current: current_epoch, + }) + ); + assert!(!dispatch_was_called()); + Ok(()) +} From d687e75c52c6da10202a7df90bb7eae4276c0ebe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:50:02 +0900 Subject: [PATCH 010/229] fix(core): reject stale document epochs before protocol dispatch --- .../src/browser_protocol_dispatch.rs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index 58b4f2a18..14bafb3d6 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -114,6 +114,44 @@ impl<'a> BrowserContextOriginDispatchTarget<'a> { } } +/// Exact browser context, canonical origin, and observed document epoch for one protocol dispatch. +/// +/// This target is intended for actions whose authority was derived from a prior structured browser +/// observation. Construction grants no authority. The dispatch boundary must revalidate the +/// session/context/origin and prove that the registry is still at `expected_epoch` immediately +/// before protocol metadata validation and callback execution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextOriginEpochDispatchTarget<'a> { + context_origin: BrowserContextOriginDispatchTarget<'a>, + expected_epoch: DocumentEpoch, +} + +impl<'a> BrowserContextOriginEpochDispatchTarget<'a> { + /// Bind one immediate-use context/origin target to the document epoch that was observed. + #[must_use] + pub const fn new( + context_origin: BrowserContextOriginDispatchTarget<'a>, + expected_epoch: DocumentEpoch, + ) -> Self { + Self { + context_origin, + expected_epoch, + } + } + + /// Return the exact browser context and canonical origin requested for dispatch. + #[must_use] + pub const fn context_origin(self) -> BrowserContextOriginDispatchTarget<'a> { + self.context_origin + } + + /// Return the exact document epoch whose observation authorized the requested action. + #[must_use] + pub const fn expected_epoch(self) -> DocumentEpoch { + self.expected_epoch + } +} + impl BrowserProtocolAdapterDescriptor { /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. /// @@ -223,6 +261,56 @@ impl BrowserProtocolAdapterDescriptor { ) .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) } + + /// Revalidate exact browser session/context/origin/document authority before protocol I/O. + /// + /// This stronger action boundary first proves the exact current session/context/origin through + /// the authority registry, then compares the registry's current document epoch with the epoch + /// that produced the caller's observation. A same-origin navigation therefore fails closed + /// before protocol validation or callback execution even when the canonical origin is rebound. + /// Exact protocol generation, family, adapter version, protocol/browser revisions and required + /// capability are validated only after the document remains current. + /// + /// The caller remains responsible for deriving the origin and observed epoch from the trusted + /// adapter/observation that produced the action, sampling runtime protocol metadata from the + /// adapter about to perform I/O, and preventing intervening mutation across the larger + /// transaction. This method does not authenticate Chromium, authorize destination/network + /// authority or policy approval, validate semantic node state, perform I/O, or prove success. + pub fn dispatch_if_context_origin_epoch_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let context_origin = target.context_origin(); + let context = context_origin.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err(BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }); + } + self.dispatch_if_runtime_matches( + required_originweave_protocol_version, + runtime_metadata, + required_capability, + |validated| dispatch(validated, current_epoch), + ) + .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) + } } /// Failure to compose current browser context ownership with protocol validation before dispatch. @@ -230,6 +318,13 @@ impl BrowserProtocolAdapterDescriptor { pub enum BrowserContextProtocolDispatchError { /// The supplied browser session/context pair is not current in the authority registry. BrowserAuthority(BrowserRegistryError), + /// The observed document epoch no longer matches the registry's current document. + DocumentEpochMismatch { + /// The document epoch that produced the action's observation. + expected: DocumentEpoch, + /// The document epoch currently active in the registry. + current: DocumentEpoch, + }, /// The current browser-protocol metadata or required capability failed validation. ProtocolValidation(BrowserProtocolUseValidationError), } @@ -243,6 +338,12 @@ impl fmt::Display for BrowserContextProtocolDispatchError { "browser context authority denied protocol dispatch: {error}" ) } + Self::DocumentEpochMismatch { expected, current } => write!( + formatter, + "browser document epoch {} no longer matches observed epoch {}", + current.value(), + expected.value() + ), Self::ProtocolValidation(error) => { write!( formatter, @@ -257,6 +358,7 @@ impl std::error::Error for BrowserContextProtocolDispatchError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::BrowserAuthority(error) => Some(error), + Self::DocumentEpochMismatch { .. } => None, Self::ProtocolValidation(error) => Some(error), } } From 6838d9f3d742978d5f3ab19e7d517f1381602598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:50:33 +0900 Subject: [PATCH 011/229] feat(core): expose epoch-bound protocol dispatch target --- crates/originweave-core/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 3d83031ac..f59ac5f8e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -24,7 +24,8 @@ pub use browser_protocol::{ }; pub use browser_protocol_dispatch::{ BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolRuntimeMetadata, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From 54e47cd28273d5872c4fe5fb2b68d8903bb25706 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:06:41 +0900 Subject: [PATCH 012/229] test(core): cover epoch dispatch denial evidence --- ..._context_origin_epoch_protocol_dispatch.rs | 98 +++++++++++++++++-- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs index c0aeb87ca..e64ab6077 100644 --- a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs @@ -117,20 +117,98 @@ fn same_origin_new_document_epoch_fails_before_protocol_dispatch() -> Result<(), registry.bind_context_origin(session, context, &expected_origin)?; reset_dispatch_marker(); + let result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + let error = match result { + Err(error) => error, + Ok(_) => { + return Err(Box::new(io::Error::other( + "stale document epoch unexpectedly dispatched", + ))) + } + }; + assert_eq!( - descriptor.dispatch_if_context_origin_epoch_current( - ®istry, - target, - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(), - BrowserProtocolCapability::TypedInput, - successful_dispatch as DispatchFn, - ), - Err(BrowserContextProtocolDispatchError::DocumentEpochMismatch { + error, + BrowserContextProtocolDispatchError::DocumentEpochMismatch { expected: observed_epoch, current: current_epoch, - }) + } + ); + assert_eq!( + error.to_string(), + "browser document epoch 2 no longer matches observed epoch 1" + ); + assert!(error.source().is_none()); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn epoch_dispatch_preserves_authority_and_protocol_failures_before_callback() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let current_origin = origin("https://app.example")?; + let other_origin = origin("https://other.example")?; + let expected_epoch = registry.bind_context_origin(session, context, ¤t_origin)?; + + let wrong_origin_target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &other_origin, + ), + expected_epoch, + ); + reset_dispatch_marker(); + let authority_result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + wrong_origin_target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + assert!(matches!( + authority_result, + Err(BrowserContextProtocolDispatchError::BrowserAuthority(_)) + )); + assert!(!dispatch_was_called()); + + let current_target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + ¤t_origin, + ), + expected_epoch, + ); + let drifted_runtime = BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + PROTOCOL_REVISION, + BROWSER_REVISION, + ); + reset_dispatch_marker(); + let protocol_result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + current_target, + ORIGINWEAVE_PROTOCOL_VERSION, + drifted_runtime, + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, ); + assert!(matches!( + protocol_result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) + )); assert!(!dispatch_was_called()); Ok(()) } From 3d00eb5c9bcb3258550bc8e75e6bedf9d0b9f01b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:15:53 +0900 Subject: [PATCH 013/229] style(core): apply canonical epoch dispatch formatting --- .../tests/browser_context_origin_epoch_protocol_dispatch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs index e64ab6077..da3bf51db 100644 --- a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs @@ -130,7 +130,7 @@ fn same_origin_new_document_epoch_fails_before_protocol_dispatch() -> Result<(), Ok(_) => { return Err(Box::new(io::Error::other( "stale document epoch unexpectedly dispatched", - ))) + ))); } }; From 4a7f46f7969d5d419b6d1d45600b87e65d625914 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:32:39 +0900 Subject: [PATCH 014/229] test(core): require typed browser operation capability binding --- ...owser_typed_operation_protocol_dispatch.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs new file mode 100644 index 000000000..0202f8ce4 --- /dev/null +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -0,0 +1,140 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolOperation, BrowserProtocolRuntimeMetadata, DocumentEpoch, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +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 descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn typed_input_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +#[test] +fn typed_operations_map_to_exact_transport_capabilities() { + assert_eq!( + BrowserProtocolOperation::Navigate.required_capability(), + BrowserProtocolCapability::Navigation + ); + assert_eq!( + BrowserProtocolOperation::ObserveSemantics.required_capability(), + BrowserProtocolCapability::SemanticObservation + ); + assert_eq!( + BrowserProtocolOperation::DispatchTypedInput.required_capability(), + BrowserProtocolCapability::TypedInput + ); + assert_eq!( + BrowserProtocolOperation::ObserveNetwork.required_capability(), + BrowserProtocolCapability::NetworkObservation + ); +} + +#[test] +fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::DispatchTypedInput, + |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { + ( + operation, + validated.capability(), + epoch.value(), + ) + }, + )?; + + assert_eq!( + result, + ( + BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolCapability::TypedInput, + 1, + ) + ); + Ok(()) +} + +#[test] +fn unsupported_typed_operation_fails_before_dispatch_callback() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + let dispatch_called = Cell::new(false); + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::DispatchTypedInput, + |_validated, _operation, _epoch| dispatch_called.set(true), + ); + + assert!(matches!( + result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) + )); + assert!(!dispatch_called.get()); + Ok(()) +} From c6d370e6d57b09bf8008f428583f5246cb44b57a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:52:39 +0900 Subject: [PATCH 015/229] style(core): format typed browser operation contract --- .../tests/browser_typed_operation_protocol_dispatch.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs index 0202f8ce4..6b555eba7 100644 --- a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -95,11 +95,7 @@ fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box< runtime_metadata(), BrowserProtocolOperation::DispatchTypedInput, |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { - ( - operation, - validated.capability(), - epoch.value(), - ) + (operation, validated.capability(), epoch.value()) }, )?; From 7a6f0e281a8f5877238475bab735d1a82c99962a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:58:16 +0900 Subject: [PATCH 016/229] feat(core): derive adapter capability from typed browser operation --- .../src/browser_protocol_operation.rs | 73 +++++++++++++++++++ crates/originweave-core/src/lib.rs | 2 + 2 files changed, 75 insertions(+) create mode 100644 crates/originweave-core/src/browser_protocol_operation.rs diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs new file mode 100644 index 000000000..5f7f62529 --- /dev/null +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -0,0 +1,73 @@ +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, DocumentEpoch, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +/// One typed high-level browser operation whose transport prerequisite is derived internally. +/// +/// This value carries operation semantics only. It grants no browser session, context, origin, +/// policy, approval, secret, network, or adapter authority and does not perform browser I/O. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolOperation { + /// Navigate one controlled browser context. + Navigate, + /// Produce one bounded semantic browser observation. + ObserveSemantics, + /// Dispatch policy-authorized typed browser input. + DispatchTypedInput, + /// Produce bounded browser-network evidence. + ObserveNetwork, +} + +impl BrowserProtocolOperation { + /// Return the exact adapter capability required for this typed operation. + #[must_use] + pub const fn required_capability(self) -> BrowserProtocolCapability { + match self { + Self::Navigate => BrowserProtocolCapability::Navigation, + Self::ObserveSemantics => BrowserProtocolCapability::SemanticObservation, + Self::DispatchTypedInput => BrowserProtocolCapability::TypedInput, + Self::ObserveNetwork => BrowserProtocolCapability::NetworkObservation, + } + } +} + +impl BrowserProtocolAdapterDescriptor { + /// Revalidate exact browser authority and derive adapter capability from one typed operation. + /// + /// The existing context/origin/document-epoch boundary runs first, followed by exact runtime + /// protocol metadata and the capability derived from `operation`. The callback can run only + /// after all prerequisites pass and receives the same typed operation together with the + /// non-cloneable protocol-use proof and freshly revalidated document epoch. + /// + /// This method does not authenticate Chromium or the adapter, grant policy approval, validate + /// semantic-node state, authorize destination/network activity, perform browser I/O, or prove + /// an action post-condition. The operation value itself grants no authority. + pub fn dispatch_operation_if_context_origin_epoch_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + operation: BrowserProtocolOperation, + dispatch: F, + ) -> Result + where + F: FnOnce( + ValidatedBrowserProtocolUse, + BrowserProtocolOperation, + DocumentEpoch, + ) -> R, + { + self.dispatch_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + operation.required_capability(), + |validated, epoch| dispatch(validated, operation, epoch), + ) + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index f59ac5f8e..3992b8717 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -10,6 +10,7 @@ mod browser_protocol; mod browser_protocol_dispatch; +mod browser_protocol_operation; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; @@ -27,6 +28,7 @@ pub use browser_protocol_dispatch::{ BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, }; +pub use browser_protocol_operation::BrowserProtocolOperation; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 4c4868fb890cf53bb4bb93c5e7cf584793442d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:00:29 +0900 Subject: [PATCH 017/229] style(core): format typed operation dispatch boundary --- crates/originweave-core/src/browser_protocol_operation.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 5f7f62529..6eaf32dd2 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -55,11 +55,7 @@ impl BrowserProtocolAdapterDescriptor { dispatch: F, ) -> Result where - F: FnOnce( - ValidatedBrowserProtocolUse, - BrowserProtocolOperation, - DocumentEpoch, - ) -> R, + F: FnOnce(ValidatedBrowserProtocolUse, BrowserProtocolOperation, DocumentEpoch) -> R, { self.dispatch_if_context_origin_epoch_current( authority_registry, From 9acbac713b4b3221c2a577e8b84df5ecff7d0a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:04:14 +0900 Subject: [PATCH 018/229] docs(changelog): record typed browser operation binding --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d0ef113..da31bfd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Runtime browser-adapter version binding at the atomic protocol-use boundary: the caller-supplied bounded adapter-version token must exactly match the reviewed descriptor version before runtime revision or capability checks can succeed, preventing adapter-build drift from silently reusing otherwise matching protocol/browser metadata without authenticating or attesting the adapter process. - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. +- Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. From 993529296d9e95e360571ed6cd7c11dea9b8a70f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:22:31 +0900 Subject: [PATCH 019/229] test: define buyer-visible browser operation vocabulary --- ...owser_typed_operation_protocol_dispatch.rs | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs index 6b555eba7..6223b067d 100644 --- a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -62,23 +62,37 @@ fn typed_input_target<'a>( } #[test] -fn typed_operations_map_to_exact_transport_capabilities() { - assert_eq!( - BrowserProtocolOperation::Navigate.required_capability(), - BrowserProtocolCapability::Navigation - ); - assert_eq!( - BrowserProtocolOperation::ObserveSemantics.required_capability(), - BrowserProtocolCapability::SemanticObservation - ); - assert_eq!( - BrowserProtocolOperation::DispatchTypedInput.required_capability(), - BrowserProtocolCapability::TypedInput - ); - assert_eq!( - BrowserProtocolOperation::ObserveNetwork.required_capability(), - BrowserProtocolCapability::NetworkObservation - ); +fn buyer_visible_operations_map_to_exact_transport_capabilities() { + let expected = [ + ( + BrowserProtocolOperation::Navigate, + BrowserProtocolCapability::Navigation, + ), + ( + BrowserProtocolOperation::QueryNodes, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ClickNode, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::TypeText, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::WaitForState, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ObserveNetwork, + BrowserProtocolCapability::NetworkObservation, + ), + ]; + + for (operation, capability) in expected { + assert_eq!(operation.required_capability(), capability); + } } #[test] @@ -93,7 +107,7 @@ fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box< target, ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata(), - BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolOperation::TypeText, |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { (operation, validated.capability(), epoch.value()) }, @@ -102,7 +116,7 @@ fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box< assert_eq!( result, ( - BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolOperation::TypeText, BrowserProtocolCapability::TypedInput, 1, ) @@ -123,7 +137,7 @@ fn unsupported_typed_operation_fails_before_dispatch_callback() -> Result<(), Bo target, ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata(), - BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolOperation::ClickNode, |_validated, _operation, _epoch| dispatch_called.set(true), ); From 06635ab949ebf0be9501ca738272d7fca1dbd0ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:04:38 +0900 Subject: [PATCH 020/229] feat(core): bind buyer-visible browser operation vocabulary --- .../src/browser_protocol_operation.rs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 6eaf32dd2..77edd06f1 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -5,30 +5,40 @@ use crate::{ OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; -/// One typed high-level browser operation whose transport prerequisite is derived internally. +/// One typed buyer-visible browser operation whose transport prerequisite is derived internally. /// /// This value carries operation semantics only. It grants no browser session, context, origin, -/// policy, approval, secret, network, or adapter authority and does not perform browser I/O. +/// semantic-node, policy, approval, secret, network, or adapter authority and does not perform +/// browser I/O. The vocabulary intentionally mirrors the bounded first Chromium vertical slice so +/// callers cannot hide materially different actions behind a coarse transport capability. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolOperation { /// Navigate one controlled browser context. Navigate, - /// Produce one bounded semantic browser observation. - ObserveSemantics, - /// Dispatch policy-authorized typed browser input. - DispatchTypedInput, + /// Query bounded semantic nodes from the current document. + QueryNodes, + /// Dispatch one policy-authorized click to a separately validated semantic node. + ClickNode, + /// Dispatch policy-authorized text input to a separately validated semantic node. + TypeText, + /// Observe bounded semantic state until a separately specified condition is satisfied. + WaitForState, /// Produce bounded browser-network evidence. ObserveNetwork, } impl BrowserProtocolOperation { /// Return the exact adapter capability required for this typed operation. + /// + /// This mapping is a transport prerequisite only. A matching capability does not authorize the + /// operation itself: node freshness, policy, approval, destination, and post-condition checks + /// remain independent authority boundaries. #[must_use] pub const fn required_capability(self) -> BrowserProtocolCapability { match self { Self::Navigate => BrowserProtocolCapability::Navigation, - Self::ObserveSemantics => BrowserProtocolCapability::SemanticObservation, - Self::DispatchTypedInput => BrowserProtocolCapability::TypedInput, + Self::QueryNodes | Self::WaitForState => BrowserProtocolCapability::SemanticObservation, + Self::ClickNode | Self::TypeText => BrowserProtocolCapability::TypedInput, Self::ObserveNetwork => BrowserProtocolCapability::NetworkObservation, } } From d1d5c736a1e50154f59d1ba718108d8610be8165 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:10:29 +0900 Subject: [PATCH 021/229] test(core): require bounded BiDi accessibility query --- .../webdriver_bidi_accessibility_query.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs new file mode 100644 index 000000000..c3e632116 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -0,0 +1,111 @@ +use std::error::Error; + +use originweave_core::{ + MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, +}; + +#[test] +fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new( + Some("textbox"), + Some("Task text"), + 32, + )?; + + assert_eq!(query.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + assert_eq!(query.method(), "browsingContext.locateNodes"); + assert_eq!(query.locator_type(), "accessibility"); + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("Task text")); + assert_eq!(query.max_node_count(), 32); + Ok(()) +} + +#[test] +fn role_only_and_name_only_queries_are_valid() -> Result<(), Box> { + let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + assert_eq!(role_only.role(), Some("button")); + assert_eq!(role_only.name(), None); + + let name_only = WebDriverBiDiAccessibilityQuery::new(None, Some("Submit task"), 1)?; + assert_eq!(name_only.role(), None); + assert_eq!(name_only.name(), Some("Submit task")); + Ok(()) +} + +#[test] +fn missing_or_empty_accessibility_locator_fields_fail_closed() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, None, 1), + Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(""), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(""), 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyName) + ); +} + +#[test] +fn accessibility_locator_text_is_bounded_by_utf8_bytes() -> Result<(), Box> { + let maximum_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES); + let maximum_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES); + let query = WebDriverBiDiAccessibilityQuery::new( + Some(&maximum_role), + Some(&maximum_name), + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + )?; + assert_eq!(query.role(), Some(maximum_role.as_str())); + assert_eq!(query.name(), Some(maximum_name.as_str())); + + let overlong_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&overlong_role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong) + ); + + let overlong_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&overlong_name), 1), + Err(WebDriverBiDiAccessibilityQueryError::NameTooLong) + ); + Ok(()) +} + +#[test] +fn accessibility_query_node_count_is_finite_and_nonzero() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 0), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new( + Some("button"), + None, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT + 1, + ), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); +} + +#[test] +fn accessibility_query_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiAccessibilityQueryError::MissingLocatorValue, + WebDriverBiDiAccessibilityQueryError::EmptyRole, + WebDriverBiDiAccessibilityQueryError::RoleTooLong, + WebDriverBiDiAccessibilityQueryError::EmptyName, + WebDriverBiDiAccessibilityQueryError::NameTooLong, + WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} From 341ccdcb0980240fcdb0e8630fd5b4475abd4960 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:12:49 +0900 Subject: [PATCH 022/229] style(core): apply canonical BiDi query test formatting --- .../tests/webdriver_bidi_accessibility_query.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index c3e632116..34412314b 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -7,12 +7,9 @@ use originweave_core::{ }; #[test] -fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> { - let query = WebDriverBiDiAccessibilityQuery::new( - Some("textbox"), - Some("Task text"), - 32, - )?; +fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> +{ + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 32)?; assert_eq!(query.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); assert_eq!(query.method(), "browsingContext.locateNodes"); From ad3b5a217f98c966f9c502936b308968fd7e76c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:14:57 +0900 Subject: [PATCH 023/229] feat(core): bound WebDriver BiDi accessibility query --- .../src/browser_protocol_operation.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 77edd06f1..f7a614988 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -1,3 +1,6 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, @@ -5,6 +8,133 @@ use crate::{ OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; +/// Exact WebDriver BiDi method used by the bounded accessibility-query contract. +pub const WEBDRIVER_BIDI_LOCATE_NODES_METHOD: &str = "browsingContext.locateNodes"; +/// Maximum UTF-8 bytes accepted for one accessibility-role query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES: usize = 64; +/// Maximum UTF-8 bytes accepted for one accessibility-name query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES: usize = 512; +/// Maximum number of nodes one bounded accessibility query may request. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT: u16 = 128; + +/// Fail-closed validation errors for one bounded WebDriver BiDi accessibility query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiAccessibilityQueryError { + /// Neither an accessibility role nor an accessible name was supplied. + MissingLocatorValue, + /// An explicitly supplied accessibility role was empty. + EmptyRole, + /// The accessibility role exceeded the local UTF-8 byte budget. + RoleTooLong, + /// An explicitly supplied accessible name was empty. + EmptyName, + /// The accessible name exceeded the local UTF-8 byte budget. + NameTooLong, + /// The requested node count was zero or exceeded the local result budget. + InvalidNodeCount, +} + +impl Display for WebDriverBiDiAccessibilityQueryError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::MissingLocatorValue => "accessibility query requires a role or accessible name", + Self::EmptyRole => "accessibility query role must not be empty", + Self::RoleTooLong => "accessibility query role exceeds the local byte budget", + Self::EmptyName => "accessibility query name must not be empty", + Self::NameTooLong => "accessibility query name exceeds the local byte budget", + Self::InvalidNodeCount => "accessibility query node count is outside the local budget", + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiAccessibilityQueryError {} + +/// Bounded transport parameters for WebDriver BiDi accessibility-node lookup. +/// +/// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator +/// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact +/// accessible name, or both, together with a finite result count. Text budgets are OriginWeave +/// resource limits rather than claims about upstream protocol maxima. +/// +/// Construction grants no browser session, context, origin, semantic-node, policy, capability, or +/// network authority and performs no browser I/O. A trusted adapter must still bind the query to an +/// exact current browsing context through the separately reviewed authority and protocol boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiAccessibilityQuery { + role: Option, + name: Option, + max_node_count: u16, +} + +impl WebDriverBiDiAccessibilityQuery { + /// Validate one bounded accessibility lookup request. + /// + /// Explicit empty values fail closed rather than being treated as absent. Role and name limits + /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget + /// through multi-byte text. At least one selector value and one result slot are required. + pub fn new( + role: Option<&str>, + name: Option<&str>, + max_node_count: u16, + ) -> Result { + if role.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); + } + if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong); + } + if name.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); + } + if name.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::NameTooLong); + } + if role.is_none() && name.is_none() { + return Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue); + } + if max_node_count == 0 || max_node_count > MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount); + } + + Ok(Self { + role: role.map(str::to_owned), + name: name.map(str::to_owned), + max_node_count, + }) + } + + /// Return the exact upstream method associated with this query contract. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_LOCATE_NODES_METHOD + } + + /// Return the exact WebDriver BiDi locator type represented by this value. + #[must_use] + pub const fn locator_type(&self) -> &'static str { + "accessibility" + } + + /// Return the exact optional accessibility role requested by the caller. + #[must_use] + pub fn role(&self) -> Option<&str> { + self.role.as_deref() + } + + /// Return the exact optional accessible name requested by the caller. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Return the finite maximum number of nodes requested from the adapter. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.max_node_count + } +} + /// One typed buyer-visible browser operation whose transport prerequisite is derived internally. /// /// This value carries operation semantics only. It grants no browser session, context, origin, From 750348a499f87c4a057acf0f3cd69ad6537a0148 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:15:22 +0900 Subject: [PATCH 024/229] feat(core): export bounded BiDi accessibility query --- crates/originweave-core/src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 3992b8717..4b809e315 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -28,7 +28,12 @@ pub use browser_protocol_dispatch::{ BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, }; -pub use browser_protocol_operation::BrowserProtocolOperation; +pub use browser_protocol_operation::{ + BrowserProtocolOperation, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, +}; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 7fc113d26f864ac14e6793409d79328bbe954a2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:16:46 +0900 Subject: [PATCH 025/229] style(core): apply canonical BiDi query exports --- crates/originweave-core/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 4b809e315..4268f6d27 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -29,10 +29,10 @@ pub use browser_protocol_dispatch::{ BrowserProtocolRuntimeMetadata, }; pub use browser_protocol_operation::{ - BrowserProtocolOperation, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, + BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, - WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From fd66444e8d0b9275d109ffa581ebdcfed0d4e301 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:22:26 +0900 Subject: [PATCH 026/229] test(core): require minimal BiDi node serialization --- .../tests/webdriver_bidi_accessibility_query.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 34412314b..26f1d23e0 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -3,7 +3,9 @@ use std::error::Error; use originweave_core::{ MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, }; #[test] @@ -20,6 +22,19 @@ fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Resul Ok(()) } +#[test] +fn accessibility_query_fixes_minimal_serialization_options() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), None, 8)?; + + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, "none"); + assert_eq!(query.serialization_max_dom_depth(), 0); + assert_eq!(query.serialization_max_object_depth(), 0); + assert_eq!(query.serialization_include_shadow_tree(), "none"); + Ok(()) +} + #[test] fn role_only_and_name_only_queries_are_valid() -> Result<(), Box> { let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; From 75356e0fc10ed2486a463381c48b6e37fc50ff92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:23:49 +0900 Subject: [PATCH 027/229] feat(core): minimize BiDi node serialization surface --- .../src/browser_protocol_operation.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index f7a614988..e7e1662d4 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -16,6 +16,12 @@ pub const MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES: usize = 64; pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES: usize = 512; /// Maximum number of nodes one bounded accessibility query may request. pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT: u16 = 128; +/// Fixed DOM serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH: u16 = 0; +/// Fixed object serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH: u16 = 0; +/// Fixed shadow-tree serialization mode for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE: &str = "none"; /// Fail-closed validation errors for one bounded WebDriver BiDi accessibility query. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,6 +63,11 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// accessible name, or both, together with a finite result count. Text budgets are OriginWeave /// resource limits rather than claims about upstream protocol maxima. /// +/// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, +/// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a +/// future transport adapter may request; they do not themselves parse, validate, or authorize any +/// returned node. +/// /// Construction grants no browser session, context, origin, semantic-node, policy, capability, or /// network authority and performs no browser I/O. A trusted adapter must still bind the query to an /// exact current browsing context through the separately reviewed authority and protocol boundary. @@ -116,6 +127,24 @@ impl WebDriverBiDiAccessibilityQuery { "accessibility" } + /// Return the fixed maximum DOM serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_dom_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH + } + + /// Return the fixed maximum object serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_object_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH + } + + /// Return the fixed shadow-tree serialization mode for returned remote nodes. + #[must_use] + pub const fn serialization_include_shadow_tree(&self) -> &'static str { + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE + } + /// Return the exact optional accessibility role requested by the caller. #[must_use] pub fn role(&self) -> Option<&str> { From 8a18184935e3ae9219b5de7e049a87776b8ef0f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:24:05 +0900 Subject: [PATCH 028/229] feat(core): export minimal BiDi serialization limits --- crates/originweave-core/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 4268f6d27..e43cb26bf 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -31,8 +31,9 @@ pub use browser_protocol_dispatch::{ pub use browser_protocol_operation::{ BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, - WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From b649787d6ea151ab622c98329b408765ff6a808f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:12:46 +0900 Subject: [PATCH 029/229] test(bidi): reject over-budget locateNodes results --- .../tests/webdriver_bidi_accessibility_query.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 26f1d23e0..720c3596f 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -105,6 +105,19 @@ fn accessibility_query_node_count_is_finite_and_nonzero() { ); } +#[test] +fn accessibility_query_revalidates_returned_node_count() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!(query.validate_result_count(0), Ok(())); + assert_eq!(query.validate_result_count(2), Ok(())); + assert_eq!( + query.validate_result_count(3), + Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded) + ); + Ok(()) +} + #[test] fn accessibility_query_error_contract_is_source_free() { let errors = [ @@ -114,6 +127,7 @@ fn accessibility_query_error_contract_is_source_free() { WebDriverBiDiAccessibilityQueryError::EmptyName, WebDriverBiDiAccessibilityQueryError::NameTooLong, WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, ]; for error in errors { From 7faa2c6bb3760b7cd67cdae45e5f29ff749aea7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:14:08 +0900 Subject: [PATCH 030/229] fix(bidi): revalidate locateNodes result budget --- .../src/browser_protocol_operation.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index e7e1662d4..d921d2d6b 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -38,6 +38,8 @@ pub enum WebDriverBiDiAccessibilityQueryError { NameTooLong, /// The requested node count was zero or exceeded the local result budget. InvalidNodeCount, + /// The untrusted adapter returned more nodes than the reviewed request budget allowed. + ResultNodeCountExceeded, } impl Display for WebDriverBiDiAccessibilityQueryError { @@ -49,6 +51,9 @@ impl Display for WebDriverBiDiAccessibilityQueryError { Self::EmptyName => "accessibility query name must not be empty", Self::NameTooLong => "accessibility query name exceeds the local byte budget", Self::InvalidNodeCount => "accessibility query node count is outside the local budget", + Self::ResultNodeCountExceeded => { + "accessibility query result exceeds the requested node budget" + } }; formatter.write_str(message) } @@ -65,8 +70,8 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// /// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, /// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a -/// future transport adapter may request; they do not themselves parse, validate, or authorize any -/// returned node. +/// future transport adapter may request. The adapter must additionally revalidate the returned +/// node count against this exact query before it retains or normalizes any returned node data. /// /// Construction grants no browser session, context, origin, semantic-node, policy, capability, or /// network authority and performs no browser I/O. A trusted adapter must still bind the query to an @@ -162,6 +167,21 @@ impl WebDriverBiDiAccessibilityQuery { pub const fn max_node_count(&self) -> u16 { self.max_node_count } + + /// Revalidate an untrusted `locateNodes` result count against this exact request budget. + /// + /// A conforming browser is expected to honor `maxNodeCount`, but an adapter boundary must not + /// treat that expectation as resource authority. Zero through the requested maximum are valid; + /// any larger returned array fails closed before later node normalization or retention. + pub fn validate_result_count( + &self, + returned_node_count: usize, + ) -> Result<(), WebDriverBiDiAccessibilityQueryError> { + if returned_node_count > usize::from(self.max_node_count) { + return Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded); + } + Ok(()) + } } /// One typed buyer-visible browser operation whose transport prerequisite is derived internally. From d99181bb0b7c893c2eba6eaf7c13723dc196ac53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:14:47 +0900 Subject: [PATCH 031/229] docs(changelog): record BiDi result budget revalidation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da31bfd48..861c04eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. +- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. From b45c40b3919d923cea1bdfadd7453ea17617dc11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:08:01 +0900 Subject: [PATCH 032/229] test(bidi): require bounded remote node references --- .../webdriver_bidi_remote_node_reference.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs new file mode 100644 index 000000000..cb0fd2d47 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -0,0 +1,79 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, +}; + +#[test] +fn remote_node_reference_requires_exact_node_type_and_shared_id() -> Result<(), Box> { + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + + assert_eq!(reference.remote_type(), WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE); + assert_eq!(reference.remote_type(), "node"); + assert_eq!(reference.shared_id(), "shared-node-42"); + Ok(()) +} + +#[test] +fn remote_node_reference_rejects_non_node_remote_values() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("object", Some("shared-node-42")), + Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType) + ); +} + +#[test] +fn remote_node_reference_requires_a_usable_shared_id() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", None), + Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + +#[test] +fn remote_node_reference_reuses_the_registry_identifier_budget() -> Result<(), Box> { + let maximum = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&maximum))?; + assert_eq!(reference.shared_id(), maximum); + + let overlong = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_bounds_multibyte_shared_ids_by_utf8_bytes() -> Result<(), Box> { + let exact = "한".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES / "한".len()); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&exact))?; + assert_eq!(reference.shared_id(), exact); + + let overlong = format!("{exact}한"); + assert!(overlong.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType, + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} From 64d763004dc096f3bf8a3aea4cff47e07fbe60f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:12:54 +0000 Subject: [PATCH 033/229] feat(core): admit bounded BiDi remote node references Close the locateNodes result-item gap by requiring the exact node remote type and a usable sharedId within the registry identifier budget before an untrusted adapter value can be retained as a later handle. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/browser_protocol_operation.rs | 89 ++++++++++++++++++- crates/originweave-core/src/lib.rs | 8 +- .../webdriver_bidi_remote_node_reference.rs | 5 +- docs/doctoring.md | 2 + docs/doctoring/browser-agent-protocols.md | 2 +- 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861c04eef..4ca0efeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. +- 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, so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index d921d2d6b..b2f61e8f6 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -5,7 +5,7 @@ use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, DocumentEpoch, - OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; /// Exact WebDriver BiDi method used by the bounded accessibility-query contract. @@ -184,6 +184,93 @@ impl WebDriverBiDiAccessibilityQuery { } } +/// Exact WebDriver BiDi remote-value type admitted as a later node handle. +pub const WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE: &str = "node"; + +/// Fail-closed validation errors for one untrusted WebDriver BiDi node remote value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiRemoteNodeReferenceError { + /// The remote value type was not the exact `node` type. + UnexpectedRemoteType, + /// The remote value omitted `sharedId`. + MissingSharedId, + /// The shared identifier was empty or exceeded the local UTF-8 byte budget. + InvalidSharedId, +} + +impl Display for WebDriverBiDiRemoteNodeReferenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::UnexpectedRemoteType => { + "remote node reference type must be the exact node remote value" + } + Self::MissingSharedId => "remote node reference requires a shared id", + Self::InvalidSharedId => { + "remote node reference shared id is empty or exceeds the local byte budget" + } + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiRemoteNodeReferenceError {} + +/// Bounded admission of one untrusted WebDriver BiDi `script.NodeRemoteValue`. +/// +/// The 1 June 2026 WebDriver BiDi Working Draft returns `script.NodeRemoteValue` items from +/// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional +/// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a +/// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context +/// identifiers. +/// +/// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a +/// later typed-input adapter cannot refer to the same node across realms without that shared +/// identity. Construction grants no session, context, origin, document-epoch, semantic-node, +/// policy, or network authority and performs no browser I/O. The admitted value remains an +/// untrusted transport handle until a separately reviewed authority boundary binds it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiRemoteNodeReference { + shared_id: String, +} + +impl WebDriverBiDiRemoteNodeReference { + /// Admit one untrusted locateNodes remote value as a later node handle. + /// + /// The remote type is checked first so a non-node value cannot be retained even when it carries + /// a well-formed shared identifier. A missing shared identifier is distinct from an empty or + /// over-budget identifier so callers can distinguish protocol omission from local + /// resource-budget rejection. + pub fn new( + remote_type: &str, + shared_id: Option<&str>, + ) -> Result { + if remote_type != WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE { + return Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType); + } + let Some(shared_id) = shared_id else { + return Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId); + }; + if shared_id.is_empty() || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); + } + Ok(Self { + shared_id: shared_id.to_owned(), + }) + } + + /// Return the exact admitted WebDriver BiDi remote-value type. + #[must_use] + pub const fn remote_type(&self) -> &'static str { + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + } + + /// Return the exact shared node identifier admitted from the remote value. + #[must_use] + pub fn shared_id(&self) -> &str { + &self.shared_id + } +} + /// One typed buyer-visible browser operation whose transport prerequisite is derived internally. /// /// This value carries operation semantics only. It grants no browser session, context, origin, diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e43cb26bf..49a8ca0ff 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -31,9 +31,11 @@ pub use browser_protocol_dispatch::{ pub use browser_protocol_operation::{ BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, - WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, - WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index cb0fd2d47..2aa8a377a 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -9,7 +9,10 @@ use originweave_core::{ fn remote_node_reference_requires_exact_node_type_and_shared_id() -> Result<(), Box> { let reference = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; - assert_eq!(reference.remote_type(), WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE); + assert_eq!( + reference.remote_type(), + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + ); assert_eq!(reference.remote_type(), "node"); assert_eq!(reference.shared_id(), "shared-node-42"); Ok(()) diff --git a/docs/doctoring.md b/docs/doctoring.md index 75c107ef0..28faa6d81 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,6 +8,8 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers. Requiring `sharedId` is a local fail-closed policy, not a claim that the Working Draft makes the field mandatory. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 5173a32e6..d31b6ba2f 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -46,7 +46,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences -1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. +1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable `sharedId` is present; do not treat a realm-local `handle` or a missing shared identifier as OriginWeave node authority. 2. Pin exact Chromium/CDP compatibility evidence at release time. 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. From 601628bf26e20e0926e4474ff230503aeb4864f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:27:30 +0000 Subject: [PATCH 034/229] test(core): reject control-bearing BiDi locator text Require fail-closed rejection of whitespace and control injection in accessibility roles, accessible names, BiDi sharedIds, and registry external identifiers before production support exists. Co-authored-by: Seongho Bae --- .../tests/browser_authority_registry.rs | 23 +++++++++- .../webdriver_bidi_accessibility_query.rs | 42 +++++++++++++++++++ .../webdriver_bidi_remote_node_reference.rs | 16 +++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 98f74ee26..d2c41a5fa 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -46,7 +46,7 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), let cases = [ ( BrowserRegistryError::InvalidExternalIdentifier, - "external browser identifier must contain 1 to 512 UTF-8 bytes".to_owned(), + "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters".to_owned(), ), ( BrowserRegistryError::UnknownBrowserSession, @@ -214,6 +214,27 @@ fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result let unicode = registry.register_session("세션-opaque-✓")?; assert!(unicode.value() > 0); + + assert_eq!( + registry.register_session(" "), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\n"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{0000}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin(); + assert_eq!( + registry.bind_node(session, context, &origin, "backend-node-17\n"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); Ok(()) } diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 720c3596f..347c88c47 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -63,6 +63,46 @@ fn missing_or_empty_accessibility_locator_fields_fail_closed() { ); } +#[test] +fn accessibility_role_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("text box"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\n"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{0000}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); +} + +#[test] +fn accessibility_name_rejects_control_injection_and_whitespace_only_values() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\ntask"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{0000}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(" "), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); +} + +#[test] +fn accessibility_name_keeps_ordinary_spaces_and_multibyte_text() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("작업 텍스트"), 1)?; + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("작업 텍스트")); + Ok(()) +} + #[test] fn accessibility_locator_text_is_bounded_by_utf8_bytes() -> Result<(), Box> { let maximum_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES); @@ -125,6 +165,8 @@ fn accessibility_query_error_contract_is_source_free() { WebDriverBiDiAccessibilityQueryError::EmptyRole, WebDriverBiDiAccessibilityQueryError::RoleTooLong, WebDriverBiDiAccessibilityQueryError::EmptyName, + WebDriverBiDiAccessibilityQueryError::InvalidRole, + WebDriverBiDiAccessibilityQueryError::InvalidName, WebDriverBiDiAccessibilityQueryError::NameTooLong, WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index 2aa8a377a..70d76073d 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -38,6 +38,22 @@ fn remote_node_reference_requires_a_usable_shared_id() { ); } +#[test] +fn remote_node_reference_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(" ")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\n")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{0000}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + #[test] fn remote_node_reference_reuses_the_registry_identifier_budget() -> Result<(), Box> { let maximum = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); From 496f55698fa196c0878562719157549f25d3a03c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:28:28 +0000 Subject: [PATCH 035/229] feat(core): reject control-bearing BiDi locator text Fail closed on whitespace or control characters in exact accessibility roles, accessible-name locators, BiDi sharedIds, and registry external identifiers so untrusted protocol text cannot become a later handle. Co-authored-by: Seongho Bae --- CHANGELOG.md | 4 +- .../src/browser_protocol_operation.rs | 46 ++++++++++++++++--- .../originweave-core/src/browser_registry.rs | 15 ++++-- docs/doctoring.md | 8 +++- docs/doctoring/browser-agent-protocols.md | 6 ++- 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca0efeed..4f70191fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. -- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. -- 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, so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. +- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. +- 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. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index b2f61e8f6..33ed6f0f4 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -34,6 +34,10 @@ pub enum WebDriverBiDiAccessibilityQueryError { RoleTooLong, /// An explicitly supplied accessible name was empty. EmptyName, + /// The accessibility role contained whitespace or a control character. + InvalidRole, + /// The accessible name contained a control character or only whitespace. + InvalidName, /// The accessible name exceeded the local UTF-8 byte budget. NameTooLong, /// The requested node count was zero or exceeded the local result budget. @@ -49,6 +53,12 @@ impl Display for WebDriverBiDiAccessibilityQueryError { Self::EmptyRole => "accessibility query role must not be empty", Self::RoleTooLong => "accessibility query role exceeds the local byte budget", Self::EmptyName => "accessibility query name must not be empty", + Self::InvalidRole => { + "accessibility query role must not contain whitespace or control characters" + } + Self::InvalidName => { + "accessibility query name must not contain control characters or only whitespace" + } Self::NameTooLong => "accessibility query name exceeds the local byte budget", Self::InvalidNodeCount => "accessibility query node count is outside the local budget", Self::ResultNodeCountExceeded => { @@ -65,8 +75,10 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// /// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator /// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact -/// accessible name, or both, together with a finite result count. Text budgets are OriginWeave -/// resource limits rather than claims about upstream protocol maxima. +/// accessible name, or both, together with a finite result count. Roles are exact tokens and +/// therefore reject whitespace and controls. Accessible names may contain ordinary spaces but +/// reject controls and whitespace-only values. Text budgets are OriginWeave resource limits rather +/// than claims about upstream protocol maxima. /// /// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, /// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a @@ -88,7 +100,10 @@ impl WebDriverBiDiAccessibilityQuery { /// /// Explicit empty values fail closed rather than being treated as absent. Role and name limits /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget - /// through multi-byte text. At least one selector value and one result slot are required. + /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace and control + /// characters fail closed instead of becoming fallback-role lists. Accessible names may contain + /// ordinary spaces but not controls or whitespace-only values. At least one selector value and + /// one result slot are required. pub fn new( role: Option<&str>, name: Option<&str>, @@ -97,12 +112,24 @@ impl WebDriverBiDiAccessibilityQuery { if role.is_some_and(str::is_empty) { return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); } + if role.is_some_and(|value| { + value + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + }) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidRole); + } if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { return Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong); } if name.is_some_and(str::is_empty) { return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); } + if name.is_some_and(|value| { + value.chars().any(char::is_control) || value.chars().all(char::is_whitespace) + }) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidName); + } if name.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES) { return Err(WebDriverBiDiAccessibilityQueryError::NameTooLong); } @@ -194,7 +221,7 @@ pub enum WebDriverBiDiRemoteNodeReferenceError { UnexpectedRemoteType, /// The remote value omitted `sharedId`. MissingSharedId, - /// The shared identifier was empty or exceeded the local UTF-8 byte budget. + /// The shared identifier was empty, contained control or whitespace, or exceeded the local budget. InvalidSharedId, } @@ -206,7 +233,7 @@ impl Display for WebDriverBiDiRemoteNodeReferenceError { } Self::MissingSharedId => "remote node reference requires a shared id", Self::InvalidSharedId => { - "remote node reference shared id is empty or exceeds the local byte budget" + "remote node reference shared id is empty, contains control or whitespace, or exceeds the local byte budget" } }; formatter.write_str(message) @@ -221,7 +248,7 @@ impl Error for WebDriverBiDiRemoteNodeReferenceError {} /// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional /// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a /// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context -/// identifiers. +/// identifiers and contains no control or whitespace characters. /// /// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a /// later typed-input adapter cannot refer to the same node across realms without that shared @@ -250,7 +277,12 @@ impl WebDriverBiDiRemoteNodeReference { let Some(shared_id) = shared_id else { return Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId); }; - if shared_id.is_empty() || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + if shared_id.is_empty() + || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || shared_id + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); } Ok(Self { diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 477117e4e..87f801b23 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -338,7 +338,7 @@ impl Default for BrowserAuthorityRegistry { /// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. + /// An external identifier was empty, contained control or whitespace, or exceeded the reviewed byte bound. InvalidExternalIdentifier, /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, @@ -366,9 +366,9 @@ pub enum BrowserRegistryError { impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => { - formatter.write_str("external browser identifier must contain 1 to 512 UTF-8 bytes") - } + Self::InvalidExternalIdentifier => formatter.write_str( + "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters", + ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") } @@ -402,7 +402,12 @@ impl fmt::Display for BrowserRegistryError { impl std::error::Error for BrowserRegistryError {} fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { - if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + if identifier.is_empty() + || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || identifier + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) diff --git a/docs/doctoring.md b/docs/doctoring.md index 28faa6d81..2e129337f 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,7 +8,9 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers. Requiring `sharedId` is a local fail-closed policy, not a claim that the Working Draft makes the field mandatory. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. + +WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. ### Browser origin equivalence @@ -156,8 +158,12 @@ Web Hypertext Application Technology Working Group. (2026). *URL standard*. http World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index d31b6ba2f..8da0ed3ea 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -46,7 +46,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences -1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable `sharedId` is present; do not treat a realm-local `handle` or a missing shared identifier as OriginWeave node authority. +1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable control-free `sharedId` is present; do not treat a realm-local `handle`, a missing shared identifier, or control/whitespace-bearing protocol text as OriginWeave node authority. Treat an accessibility-query role as one exact WAI-ARIA token, not a whitespace-separated fallback list. 2. Pin exact Chromium/CDP compatibility evidence at release time. 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. @@ -74,6 +74,10 @@ Model Context Protocol. (2026). *Model Context Protocol specification (2026-07-2 World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html From 9f389402c9e3b5548fe5d7c149d1f3679b3d0c63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:16 +0000 Subject: [PATCH 036/229] test(bidi): bind locateNodes results to current authority Require a fail-closed composition that revalidates the exact current session/context/origin/document epoch, rejects over-budget or non-node items, and translates admitted sharedIds into ObservedNodeHandle values before production support exists. Co-authored-by: Seongho Bae --- .../webdriver_bidi_locate_nodes_admission.rs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs new file mode 100644 index 000000000..5bdb6282d --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -0,0 +1,164 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserRegistryError, DocumentEpoch, Origin, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +fn controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +#[test] +fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = query.bind_current_nodes( + &mut registry, + target, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn over_budget_locate_nodes_result_fails_before_node_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded + )) + ); + Ok(()) +} + +#[test] +fn stale_document_epoch_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + query.bind_current_nodes( + &mut registry, + stale_target, + &[("node", Some("shared-submit"))], + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + ) + ); + Ok(()) +} + +#[test] +fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes(&mut registry, target, &[("object", Some("shared-submit"))],) + Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType + )) + ); + Ok(()) +} + +#[test] +fn locate_nodes_admission_error_contract_is_source_aware() { + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ), + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { expected, current }, + WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession, + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + } + assert!(errors[0].source().is_some()); + assert!(errors[1].source().is_some()); + assert!(errors[2].source().is_none()); + assert!(errors[3].source().is_some()); +} From ce0af01836628f46fde34490c3f71ecd42570138 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:24 +0000 Subject: [PATCH 037/229] test(bidi): fix locateNodes admission test syntax Correct a missing comma so the intended missing-production compile failure is not hidden by a test-harness syntax error. Co-authored-by: Seongho Bae --- .../tests/webdriver_bidi_locate_nodes_admission.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs index 5bdb6282d..af591dc21 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -129,7 +129,7 @@ fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box Date: Sun, 16 Aug 2026 15:31:25 +0000 Subject: [PATCH 038/229] feat(core): bind locateNodes results to current authority Revalidate the exact current session, context, origin, and document epoch, admit every untrusted result item first, then translate shared node identities through the registry into ObservedNodeHandle values. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/browser_protocol_operation.rs | 126 +++++++++++++++++- crates/originweave-core/src/lib.rs | 4 +- .../webdriver_bidi_locate_nodes_admission.rs | 72 +++++++++- docs/doctoring.md | 2 +- 5 files changed, 195 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f70191fd..639384522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - 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. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 33ed6f0f4..3d47cafc8 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -4,8 +4,9 @@ use std::fmt::{Display, Formatter}; use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, BrowserRegistryError, DocumentEpoch, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, }; /// Exact WebDriver BiDi method used by the bounded accessibility-query contract. @@ -209,6 +210,127 @@ impl WebDriverBiDiAccessibilityQuery { } Ok(()) } + + /// Admit one untrusted `locateNodes` result against the exact current document authority. + /// + /// The registry first proves that `target` still names the current session, browsing context, + /// canonical origin, and document epoch. Only then is the returned item count checked against + /// this query's budget. Each item must be an exact `node` remote value with a usable shared + /// identifier; those identifiers are translated through the registry into + /// [`ObservedNodeHandle`] values bound to that same current authority. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn bind_current_nodes( + &self, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + let context_origin = target.context_origin(); + let context = context_origin.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }, + ); + } + self.validate_result_count(items.len()) + .map_err(WebDriverBiDiLocateNodesAdmissionError::Query)?; + + let mut references = Vec::new(); + for (remote_type, shared_id) in items { + references.push( + WebDriverBiDiRemoteNodeReference::new(remote_type, *shared_id) + .map_err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode)?, + ); + } + + let mut handles = Vec::new(); + for reference in references { + handles.push( + authority_registry + .bind_node( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + reference.shared_id(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?, + ); + } + Ok(handles) + } +} + +/// Fail-closed errors for admitting an untrusted `locateNodes` result into current node authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesAdmissionError { + /// The untrusted result violated this query's reviewed locator or result-count contract. + Query(WebDriverBiDiAccessibilityQueryError), + /// One result item was not an admissible node remote value. + RemoteNode(WebDriverBiDiRemoteNodeReferenceError), + /// The observed document epoch no longer matches the registry's current document. + DocumentEpochMismatch { + /// The document epoch that produced the observation being bound. + expected: DocumentEpoch, + /// The document epoch currently active in the registry. + current: DocumentEpoch, + }, + /// The supplied browser session, context, or origin is not current in the registry. + BrowserAuthority(BrowserRegistryError), +} + +impl Display for WebDriverBiDiLocateNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query(error) => { + write!( + formatter, + "accessibility query rejected locateNodes admission: {error}" + ) + } + Self::RemoteNode(error) => { + write!( + formatter, + "remote node reference rejected locateNodes admission: {error}" + ) + } + Self::DocumentEpochMismatch { expected, current } => write!( + formatter, + "browser document epoch {} no longer matches observed epoch {}", + current.value(), + expected.value() + ), + Self::BrowserAuthority(error) => { + write!( + formatter, + "browser authority denied locateNodes admission: {error}" + ) + } + } + } +} + +impl Error for WebDriverBiDiLocateNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Query(error) => Some(error), + Self::RemoteNode(error) => Some(error), + Self::DocumentEpochMismatch { .. } => None, + Self::BrowserAuthority(error) => Some(error), + } + } } /// Exact WebDriver BiDi remote-value type admitted as a later node handle. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 49a8ca0ff..59785fc8e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -34,8 +34,8 @@ pub use browser_protocol_operation::{ WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, WebDriverBiDiRemoteNodeReference, - WebDriverBiDiRemoteNodeReferenceError, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs index af591dc21..f5ddd4789 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -4,9 +4,10 @@ use std::error::Error; use originweave_core::{ BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextOriginEpochDispatchTarget, BrowserRegistryError, DocumentEpoch, Origin, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, - WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, + BrowserContextOriginEpochDispatchTarget, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, }; fn controlled_origin() -> Origin { @@ -30,8 +31,8 @@ fn current_target<'a>( } #[test] -fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() --> Result<(), Box> { +fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() -> Result<(), Box> +{ let mut registry = BrowserAuthorityRegistry::new(); let expected_origin = controlled_origin(); let target = current_target(&mut registry, &expected_origin)?; @@ -121,6 +122,67 @@ fn stale_document_epoch_fails_before_locate_nodes_binding() -> Result<(), Box Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!( + query.bind_current_nodes( + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted + )) + ); + Ok(()) +} + +#[test] +fn empty_locate_nodes_result_is_valid_when_the_document_is_current() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + let handles = query.bind_current_nodes(&mut registry, target, &[])?; + assert!(handles.is_empty()); + Ok(()) +} + +#[test] +fn unknown_browser_session_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes(&mut registry, target, &[("node", Some("shared-submit"))]), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + Ok(()) +} + #[test] fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); diff --git a/docs/doctoring.md b/docs/doctoring.md index 2e129337f..643f443a8 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,7 +8,7 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. A later same-call admission boundary may translate that handle through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. From 2533394e618cc55f4a40c417e660354201f503f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:36:54 +0000 Subject: [PATCH 039/229] test(bidi): require QueryNodes capability before node admission Add the failing contract that an untrusted locateNodes result cannot become ObservedNodeHandle values unless the adapter proves SemanticObservation on the exact current session, context, origin, and document epoch. Co-authored-by: Seongho Bae --- .../webdriver_bidi_query_nodes_admission.rs | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs new file mode 100644 index 000000000..d6906b0eb --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs @@ -0,0 +1,310 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, BrowserProtocolOperation, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +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 descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +fn admit_query_nodes<'a>( + descriptor: &BrowserProtocolAdapterDescriptor, + registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'a>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], +) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + descriptor.admit_query_nodes( + registry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + query, + items, + ) +} + +#[test] +fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles( +) -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn navigation_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::Navigation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +#[test] +fn typed_input_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_rejects_control_bearing_and_omitted_shared_ids() -> Result<(), Box> +{ + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit\n"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId + ) + )) + ); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", None)], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId + ) + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_stale_document_epoch() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + stale_target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_unknown_session() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert!(matches!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::BrowserAuthority(_) + )) + )); + Ok(()) +} + +#[test] +fn query_nodes_maps_to_semantic_observation_and_error_contract_is_source_aware() { + assert_eq!( + BrowserProtocolOperation::QueryNodes.required_capability(), + BrowserProtocolCapability::SemanticObservation + ); + + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { expected, current }, + ), + WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); + } +} From 4186437b12aa051f6c868fb60838a0d235542e27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:38:22 +0000 Subject: [PATCH 040/229] feat(core): admit locateNodes only after QueryNodes proof Consume a non-cloneable SemanticObservation protocol-use proof before bind_current_nodes can translate untrusted sharedId values into current ObservedNodeHandle values. Navigation-only and TypedInput-only adapters fail closed. Co-authored-by: Seongho Bae --- .../src/browser_protocol_operation.rs | 72 +++++++++++++++++++ crates/originweave-core/src/lib.rs | 3 +- .../webdriver_bidi_query_nodes_admission.rs | 12 ++-- 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 3d47cafc8..ca5faf14d 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -273,6 +273,43 @@ impl WebDriverBiDiAccessibilityQuery { } } +/// Fail-closed errors for QueryNodes admission that requires SemanticObservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiQueryNodesAdmissionError { + /// Protocol metadata or the QueryNodes capability failed before node admission. + ProtocolDispatch(BrowserContextProtocolDispatchError), + /// The untrusted `locateNodes` result failed current-authority admission. + LocateNodes(WebDriverBiDiLocateNodesAdmissionError), +} + +impl Display for WebDriverBiDiQueryNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ProtocolDispatch(error) => { + write!( + formatter, + "QueryNodes protocol dispatch rejected locateNodes admission: {error}" + ) + } + Self::LocateNodes(error) => { + write!( + formatter, + "QueryNodes current-authority admission rejected locateNodes result: {error}" + ) + } + } + } +} + +impl Error for WebDriverBiDiQueryNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ProtocolDispatch(error) => Some(error), + Self::LocateNodes(error) => Some(error), + } + } +} + /// Fail-closed errors for admitting an untrusted `locateNodes` result into current node authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebDriverBiDiLocateNodesAdmissionError { @@ -496,4 +533,39 @@ impl BrowserProtocolAdapterDescriptor { |validated, epoch| dispatch(validated, operation, epoch), ) } + + /// Admit one untrusted `locateNodes` result only after QueryNodes protocol proof. + /// + /// The same-call boundary first consumes a non-cloneable protocol-use proof for + /// [`BrowserProtocolOperation::QueryNodes`], which derives + /// [`BrowserProtocolCapability::SemanticObservation`]. Only then may + /// [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`] translate admitted `sharedId` + /// values into [`ObservedNodeHandle`] values on the exact current session, browsing + /// context, canonical origin, and document epoch. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn admit_query_nodes( + &self, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + self.dispatch_operation_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + BrowserProtocolOperation::QueryNodes, + |_validated, _operation, _epoch| (), + ) + .map_err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch)?; + query + .bind_current_nodes(authority_registry, target, items) + .map_err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes) + } } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 59785fc8e..81c5dd60d 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -35,7 +35,8 @@ pub use browser_protocol_operation::{ WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, + WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs index d6906b0eb..204df6baf 100644 --- a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs @@ -8,7 +8,7 @@ use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, BrowserProtocolOperation, BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, }; @@ -67,7 +67,7 @@ fn admit_query_nodes<'a>( target: BrowserContextOriginEpochDispatchTarget<'a>, query: &WebDriverBiDiAccessibilityQuery, items: &[(&str, Option<&str>)], -) -> Result, WebDriverBiDiQueryNodesAdmissionError> { +) -> Result, WebDriverBiDiQueryNodesAdmissionError> { descriptor.admit_query_nodes( registry, target, @@ -79,8 +79,8 @@ fn admit_query_nodes<'a>( } #[test] -fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles( -) -> Result<(), Box> { +fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles() +-> Result<(), Box> { let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; let mut registry = BrowserAuthorityRegistry::new(); let expected_origin = controlled_origin(); @@ -178,8 +178,8 @@ fn typed_input_only_adapter_cannot_admit_query_nodes() -> Result<(), Box Result<(), Box> -{ +fn query_nodes_admission_rejects_control_bearing_and_omitted_shared_ids() +-> Result<(), Box> { let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; let mut registry = BrowserAuthorityRegistry::new(); let expected_origin = controlled_origin(); From 87e1e715a0310785b0ff024835a61cf99f2ed262 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:38:22 +0000 Subject: [PATCH 041/229] docs: record QueryNodes SemanticObservation admission boundary Keep CHANGELOG, doctoring, API, architecture, ADR 0010, and the roadmap aligned with the same-call proof that observation handles require QueryNodes capability and current document authority. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + docs/API_CONTRACT.md | 2 ++ docs/adr/0010-session-context-bound-node-authority.md | 1 + docs/doctoring.md | 2 +- docs/product-roadmap.md | 1 + 6 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9b23ef9f0..4213966b9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,7 +166,7 @@ Observation should prefer the most structured trustworthy source available: 4. accessibility tree combined with DOM and layout; 5. screenshot or vision fallback for canvas and inaccessible custom interfaces. -Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. +Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. An untrusted WebDriver BiDi `locateNodes` result becomes an `ObservedNodeHandle` only after the adapter proves `QueryNodes` / `SemanticObservation` and the exact current session, browsing context, canonical origin, and document epoch still match. That control-plane composition does not perform browser I/O or authorize typed input. ## 8. Action lifecycle diff --git a/CHANGELOG.md b/CHANGELOG.md index 639384522..4b59ba6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - 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 consumes a non-cloneable SemanticObservation protocol-use proof before `bind_current_nodes` can translate an untrusted `locateNodes` result into current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only adapters cannot mint observation handles. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f750922fd..93b53b36f 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -227,6 +227,8 @@ Queries the OriginWeave semantic observation contract; callers do not need to sy Query predicates may include role, accessible name, state, structured field, source channel and scoped layout attributes. Results contain opaque semantic-node handles bound to the current session/context/origin/document epoch. +The first Rust control-plane slice admits an untrusted WebDriver BiDi `locateNodes` result only after the adapter proves `QueryNodes` / `SemanticObservation` on the exact current session, browsing context, canonical origin, and document epoch. That composition still performs no browser I/O and does not authorize typed input. + ## 14. Action operations ### `browser.act` diff --git a/docs/adr/0010-session-context-bound-node-authority.md b/docs/adr/0010-session-context-bound-node-authority.md index 9c085bdd3..1b8933ab9 100644 --- a/docs/adr/0010-session-context-bound-node-authority.md +++ b/docs/adr/0010-session-context-bound-node-authority.md @@ -30,6 +30,7 @@ The numeric identifiers are internal opaque registry identities. They are not ra - The Rust core stays independent of Chromium, WebDriver, selectors, script execution, network access, storage, credentials, and model providers. - Future WebDriver BiDi and CDP adapters must own external-to-internal identity translation, registry lifecycle, epoch rotation, and immediate pre-action validation. - A valid handle proves only observation authority. It does not grant a browser capability, origin permission, resolved-destination authority, transport authority, approval, or successful post-condition. +- QueryNodes admission consumes a non-cloneable SemanticObservation protocol-use proof before translating an admitted `locateNodes` `sharedId` into an `ObservedNodeHandle`. Navigation-only or TypedInput-only adapters cannot mint observation handles. ## References diff --git a/docs/doctoring.md b/docs/doctoring.md index 643f443a8..cb33f6d94 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,7 +8,7 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. A later same-call admission boundary may translate that handle through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first consumes a non-cloneable SemanticObservation protocol-use proof, then translates each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md index c61dfee63..0905e0054 100644 --- a/docs/product-roadmap.md +++ b/docs/product-roadmap.md @@ -70,6 +70,7 @@ Delivered document-node authority foundation: - a nonzero `BrowsingContextId` for one independently navigable browser context inside that session; - a nonzero `DocumentEpoch` identity for one observed document lifetime inside that context; - an `ObservedNodeHandle` bound to the exact browser session, browsing context, canonical origin, document epoch, and nonzero adapter-local node identifier; +- same-call QueryNodes admission that consumes a SemanticObservation protocol-use proof before translating admitted `locateNodes` `sharedId` values into those handles; - deterministic rejection of cross-session, cross-context, cross-origin, or stale-document node reuse before a future browser adapter performs an action; - reusable core contracts without Chromium, WebDriver, selector, script-execution, network, storage, or secret dependencies. From 685d517f1280e492db42048aa89e025dd05fdd94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:39:43 +0000 Subject: [PATCH 042/229] test(core): reject Unicode format locator and identifier text Add the failing contract that zero-width and bidi-override format characters cannot be admitted in accessibility roles, accessible names, BiDi sharedId values, or registry external identifiers. Co-authored-by: Seongho Bae --- .../tests/browser_authority_registry.rs | 8 ++++++++ .../webdriver_bidi_accessibility_query.rs | 20 +++++++++++++++++++ .../webdriver_bidi_remote_node_reference.rs | 12 +++++++++++ 3 files changed, 40 insertions(+) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index d2c41a5fa..627b90ac2 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -227,6 +227,14 @@ fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result registry.register_session("webdriver-session\u{0000}"), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + assert_eq!( + registry.register_session("webdriver-session\u{200B}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{202E}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 347c88c47..e3a1a9f2d 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -79,6 +79,26 @@ fn accessibility_role_rejects_whitespace_and_control_injection() { ); } +#[test] +fn accessibility_locator_text_rejects_unicode_format_and_bidi_overrides() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{200B}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{202E}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{200B}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{202E}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); +} + #[test] fn accessibility_name_rejects_control_injection_and_whitespace_only_values() { assert_eq!( diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index 70d76073d..5ce4ab05c 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -38,6 +38,18 @@ fn remote_node_reference_requires_a_usable_shared_id() { ); } +#[test] +fn remote_node_reference_rejects_unicode_format_and_bidi_overrides() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{200B}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{202E}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + #[test] fn remote_node_reference_rejects_whitespace_and_control_injection() { assert_eq!( From 816fa1f14bb2da948c6c67b7e9667a81571c30a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:41:21 +0000 Subject: [PATCH 043/229] feat(core): reject Unicode format protocol text Fail closed on reviewed Default_Ignorable and bidirectional format characters in accessibility locators, BiDi sharedId values, and registry external identifiers. Ordinary spaces in accessible names remain valid. Co-authored-by: Seongho Bae --- .../src/browser_protocol_operation.rs | 40 +++++++++---------- .../originweave-core/src/browser_registry.rs | 37 ++++++++++++++--- crates/originweave-core/src/lib.rs | 2 + .../tests/browser_authority_registry.rs | 2 +- .../webdriver_bidi_accessibility_query.rs | 36 ++++++++--------- .../webdriver_bidi_remote_node_reference.rs | 20 +++++----- 6 files changed, 79 insertions(+), 58 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index ca5faf14d..c768617bd 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -35,9 +35,9 @@ pub enum WebDriverBiDiAccessibilityQueryError { RoleTooLong, /// An explicitly supplied accessible name was empty. EmptyName, - /// The accessibility role contained whitespace or a control character. + /// The accessibility role contained whitespace, a control, or a Unicode format character. InvalidRole, - /// The accessible name contained a control character or only whitespace. + /// The accessible name contained a control, Unicode format character, or only whitespace. InvalidName, /// The accessible name exceeded the local UTF-8 byte budget. NameTooLong, @@ -55,10 +55,10 @@ impl Display for WebDriverBiDiAccessibilityQueryError { Self::RoleTooLong => "accessibility query role exceeds the local byte budget", Self::EmptyName => "accessibility query name must not be empty", Self::InvalidRole => { - "accessibility query role must not contain whitespace or control characters" + "accessibility query role must not contain whitespace, control, or Unicode format characters" } Self::InvalidName => { - "accessibility query name must not contain control characters or only whitespace" + "accessibility query name must not contain control or Unicode format characters or only whitespace" } Self::NameTooLong => "accessibility query name exceeds the local byte budget", Self::InvalidNodeCount => "accessibility query node count is outside the local budget", @@ -77,8 +77,9 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator /// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact /// accessible name, or both, together with a finite result count. Roles are exact tokens and -/// therefore reject whitespace and controls. Accessible names may contain ordinary spaces but -/// reject controls and whitespace-only values. Text budgets are OriginWeave resource limits rather +/// therefore reject whitespace, controls, and Unicode format characters. Accessible names may +/// contain ordinary spaces but reject controls, format characters, and whitespace-only values. +/// Text budgets are OriginWeave resource limits rather /// than claims about upstream protocol maxima. /// /// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, @@ -101,10 +102,10 @@ impl WebDriverBiDiAccessibilityQuery { /// /// Explicit empty values fail closed rather than being treated as absent. Role and name limits /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget - /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace and control - /// characters fail closed instead of becoming fallback-role lists. Accessible names may contain - /// ordinary spaces but not controls or whitespace-only values. At least one selector value and - /// one result slot are required. + /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace, control, and + /// Unicode format characters fail closed instead of becoming fallback-role lists. Accessible + /// names may contain ordinary spaces but not controls, Unicode format characters, or + /// whitespace-only values. At least one selector value and one result slot are required. pub fn new( role: Option<&str>, name: Option<&str>, @@ -113,11 +114,7 @@ impl WebDriverBiDiAccessibilityQuery { if role.is_some_and(str::is_empty) { return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); } - if role.is_some_and(|value| { - value - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - }) { + if role.is_some_and(|value| crate::contains_disallowed_protocol_text(value, false)) { return Err(WebDriverBiDiAccessibilityQueryError::InvalidRole); } if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { @@ -127,7 +124,8 @@ impl WebDriverBiDiAccessibilityQuery { return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); } if name.is_some_and(|value| { - value.chars().any(char::is_control) || value.chars().all(char::is_whitespace) + crate::contains_disallowed_protocol_text(value, true) + || value.chars().all(char::is_whitespace) }) { return Err(WebDriverBiDiAccessibilityQueryError::InvalidName); } @@ -380,7 +378,7 @@ pub enum WebDriverBiDiRemoteNodeReferenceError { UnexpectedRemoteType, /// The remote value omitted `sharedId`. MissingSharedId, - /// The shared identifier was empty, contained control or whitespace, or exceeded the local budget. + /// The shared identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the local budget. InvalidSharedId, } @@ -392,7 +390,7 @@ impl Display for WebDriverBiDiRemoteNodeReferenceError { } Self::MissingSharedId => "remote node reference requires a shared id", Self::InvalidSharedId => { - "remote node reference shared id is empty, contains control or whitespace, or exceeds the local byte budget" + "remote node reference shared id is empty, contains control, whitespace, or Unicode format characters, or exceeds the local byte budget" } }; formatter.write_str(message) @@ -407,7 +405,7 @@ impl Error for WebDriverBiDiRemoteNodeReferenceError {} /// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional /// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a /// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context -/// identifiers and contains no control or whitespace characters. +/// identifiers and contains no control, whitespace, or Unicode format characters. /// /// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a /// later typed-input adapter cannot refer to the same node across realms without that shared @@ -438,9 +436,7 @@ impl WebDriverBiDiRemoteNodeReference { }; if shared_id.is_empty() || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES - || shared_id - .chars() - .any(|character| character.is_control() || character.is_whitespace()) + || crate::contains_disallowed_protocol_text(shared_id, false) { return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); } diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 87f801b23..10de5ab69 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -6,6 +6,35 @@ use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHand /// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; +/// Invisible and bidirectional Unicode format characters rejected in protocol text. +/// +/// These code points are Default_Ignorable or bidirectional format controls. They can hide or +/// reorder locator and identifier text without being `char::is_control` or `char::is_whitespace`. +/// The reviewed set is a local fail-closed policy for OriginWeave protocol admission, not a claim +/// that every Unicode format character is forbidden by WebDriver BiDi or WAI-ARIA. +pub const UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS: &[char] = &[ + '\u{00AD}', '\u{061C}', '\u{180E}', '\u{200B}', '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', + '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2060}', '\u{2061}', '\u{2062}', + '\u{2063}', '\u{2064}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{206A}', '\u{206B}', + '\u{206C}', '\u{206D}', '\u{206E}', '\u{206F}', '\u{FEFF}', +]; + +/// Return whether protocol text contains a control, whitespace, or reviewed format character. +/// +/// When `allow_ordinary_space` is true, U+0020 may appear so accessible names can keep ordinary +/// spaces. Every other whitespace character, every control, and every reviewed format character +/// still fail closed. +pub(crate) fn contains_disallowed_protocol_text(value: &str, allow_ordinary_space: bool) -> bool { + value.chars().any(|character| { + if allow_ordinary_space && character == ' ' { + return false; + } + character.is_control() + || character.is_whitespace() + || UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS.contains(&character) + }) +} + /// Default maximum number of authority identifiers allocated per registry namespace. const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; @@ -338,7 +367,7 @@ impl Default for BrowserAuthorityRegistry { /// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty, contained control or whitespace, or exceeded the reviewed byte bound. + /// An external identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the reviewed byte bound. InvalidExternalIdentifier, /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, @@ -367,7 +396,7 @@ impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidExternalIdentifier => formatter.write_str( - "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters", + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters", ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") @@ -404,9 +433,7 @@ impl std::error::Error for BrowserRegistryError {} fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES - || identifier - .chars() - .any(|character| character.is_control() || character.is_whitespace()) + || contains_disallowed_protocol_text(identifier, false) { return Err(BrowserRegistryError::InvalidExternalIdentifier); } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 81c5dd60d..095e6512c 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -38,7 +38,9 @@ pub use browser_protocol_operation::{ WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, }; +pub(crate) use browser_registry::contains_disallowed_protocol_text; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::*; diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 627b90ac2..6cef3e353 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -46,7 +46,7 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), let cases = [ ( BrowserRegistryError::InvalidExternalIdentifier, - "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters".to_owned(), + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters".to_owned(), ), ( BrowserRegistryError::UnknownBrowserSession, diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index e3a1a9f2d..360e8d4f1 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -2,10 +2,10 @@ use std::error::Error; use originweave_core::{ MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, - MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, - WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, - WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, + MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, }; #[test] @@ -81,22 +81,18 @@ fn accessibility_role_rejects_whitespace_and_control_injection() { #[test] fn accessibility_locator_text_rejects_unicode_format_and_bidi_overrides() { - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(Some("button\u{200B}"), None, 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) - ); - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(Some("button\u{202E}"), None, 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) - ); - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{200B}task"), 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidName) - ); - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{202E}task"), 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidName) - ); + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let role = format!("button{character}"); + let name = format!("Submit{character}task"); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&name), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + } } #[test] diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index 5ce4ab05c..d5f25bfbc 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -1,8 +1,9 @@ use std::error::Error; use originweave_core::{ - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, - WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, }; #[test] @@ -40,14 +41,13 @@ fn remote_node_reference_requires_a_usable_shared_id() { #[test] fn remote_node_reference_rejects_unicode_format_and_bidi_overrides() { - assert_eq!( - WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{200B}")), - Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) - ); - assert_eq!( - WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{202E}")), - Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) - ); + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let shared_id = format!("shared-node-42{character}"); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&shared_id)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + } } #[test] From ef4a3b7bc5e4ee509aaf857b880945eff4bba5d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:41:21 +0000 Subject: [PATCH 044/229] docs: cite UTS 39 and UAX 9 for format-character rejection Record the local fail-closed format-character policy and cite the current Unicode security-mechanisms standard, the bidirectional algorithm, and the superseded UTR 36 report. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring.md | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b59ba6f8..c0235be4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,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 consumes a non-cloneable SemanticObservation protocol-use proof before `bind_current_nodes` can translate an untrusted `locateNodes` result into current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only adapters cannot mint observation handles. +- 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/docs/doctoring.md b/docs/doctoring.md index cb33f6d94..fcc61d618 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,9 +8,11 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first consumes a non-cloneable SemanticObservation protocol-use proof, then translates each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control, whitespace, or reviewed Unicode format characters. Requiring `sharedId` and rejecting control, whitespace, and format characters is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first consumes a non-cloneable SemanticObservation protocol-use proof, then translates each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. -WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. +WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace, a control character, or a Unicode format character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control and reviewed format characters that would become protocol-text injection or bidirectional spoofing, and rejects whitespace-only names as non-selectors. + +UTS #39 Revision 32 is the current Unicode security-mechanisms standard and marks Default_Ignorable and bidirectional format characters as restricted in identifier profiles. UAX #9 defines the bidirectional format controls that can reorder displayed protocol text. UTR #36 Revision 15 remains a stabilized historical security-considerations report; its identifier recommendations are superseded by UTS #39 rather than cited as current normative profile rules. OriginWeave therefore rejects the reviewed format-character set in roles, shared identifiers, and registry external identifiers, and rejects those same characters inside accessible names while still allowing ordinary U+0020 spaces. ### Browser origin equivalence @@ -154,6 +156,12 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html +Unicode Consortium. (2014, September 19). *Unicode security considerations* (Unicode Technical Report #36, Revision 15). https://www.unicode.org/reports/tr36/tr36-15.html + +Unicode Consortium. (2025a, September 4). *Unicode bidirectional algorithm* (Unicode Standard Annex #9, Version 17.0.0). https://www.unicode.org/reports/tr9/ + +Unicode Consortium. (2025b, September 4). *Unicode security mechanisms* (Unicode Technical Standard #39, Revision 32). https://www.unicode.org/reports/tr39/tr39-32.html + Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ From c0e3d8ef36980eebee52d2f9c053f1bd386314d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:51:58 +0000 Subject: [PATCH 045/229] test(core): require SemanticObservation proof to bind locateNodes RED: bind_current_nodes must consume a non-cloneable protocol-use proof and reject TypedInput and Navigation proofs before minting observation handles. Navigation-only and TypedInput-only adapters can still call the public no-proof API on the predecessor head. Co-authored-by: Seongho Bae --- .../webdriver_bidi_query_nodes_admission.rs | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs index 204df6baf..69f407aa2 100644 --- a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs @@ -9,8 +9,9 @@ use originweave_core::{ BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, BrowserProtocolOperation, BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiQueryNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -177,6 +178,60 @@ fn typed_input_only_adapter_cannot_admit_query_nodes() -> Result<(), Box Result> { + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +#[test] +fn bind_current_nodes_rejects_typed_input_and_navigation_protocol_proofs() +-> Result<(), Box> { + let typed_input = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let navigation = descriptor(&[BrowserProtocolCapability::Navigation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let items = [("node", Some("shared-submit"))]; + + assert_eq!( + query.bind_current_nodes( + protocol_use_proof(&typed_input, BrowserProtocolCapability::TypedInput)?, + &mut registry, + target, + &items, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput + ) + ) + ); + assert_eq!( + query.bind_current_nodes( + protocol_use_proof(&navigation, BrowserProtocolCapability::Navigation)?, + &mut registry, + target, + &items, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::Navigation + ) + ) + ); + Ok(()) +} + #[test] fn query_nodes_admission_rejects_control_bearing_and_omitted_shared_ids() -> Result<(), Box> { From d6a7998dab6c77d46deb50d3ffc0db63d60b7f92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:53:57 +0000 Subject: [PATCH 046/229] feat(core): consume SemanticObservation proof in bind_current_nodes Transfer the non-cloneable QueryNodes protocol-use proof by ownership into locateNodes admission and reject Navigation, TypedInput, and NetworkObservation proofs before minting ObservedNodeHandle values. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 +- .../src/browser_protocol_operation.rs | 63 +++++++++++---- .../webdriver_bidi_locate_nodes_admission.rs | 76 +++++++++++++++++-- docs/API_CONTRACT.md | 2 +- ...10-session-context-bound-node-authority.md | 2 +- docs/doctoring.md | 2 +- docs/product-roadmap.md | 2 +- 8 files changed, 124 insertions(+), 27 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4213966b9..be1bd6096 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,7 +166,7 @@ Observation should prefer the most structured trustworthy source available: 4. accessibility tree combined with DOM and layout; 5. screenshot or vision fallback for canvas and inaccessible custom interfaces. -Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. An untrusted WebDriver BiDi `locateNodes` result becomes an `ObservedNodeHandle` only after the adapter proves `QueryNodes` / `SemanticObservation` and the exact current session, browsing context, canonical origin, and document epoch still match. That control-plane composition does not perform browser I/O or authorize typed input. +Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. An untrusted WebDriver BiDi `locateNodes` result becomes an `ObservedNodeHandle` only after the adapter transfers a non-cloneable `QueryNodes` / `SemanticObservation` protocol-use proof into `bind_current_nodes` and the exact current session, browsing context, canonical origin, and document epoch still match. Navigation or TypedInput proofs fail closed. That control-plane composition does not perform browser I/O or authorize typed input. ## 8. Action lifecycle diff --git a/CHANGELOG.md b/CHANGELOG.md index c0235be4d..b33951d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - 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 consumes a non-cloneable SemanticObservation protocol-use proof before `bind_current_nodes` can translate an untrusted `locateNodes` result into current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only adapters cannot mint observation handles. +- 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. - 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_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index c768617bd..69327a56b 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -211,7 +211,12 @@ impl WebDriverBiDiAccessibilityQuery { /// Admit one untrusted `locateNodes` result against the exact current document authority. /// - /// The registry first proves that `target` still names the current session, browsing context, + /// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose capability is + /// exactly [`BrowserProtocolCapability::SemanticObservation`]. Navigation and TypedInput proofs + /// fail closed before the registry is consulted, so those adapters cannot mint observation + /// handles. The proof is consumed by ownership and cannot be reused for a later bind. + /// + /// The registry then proves that `target` still names the current session, browsing context, /// canonical origin, and document epoch. Only then is the returned item count checked against /// this query's budget. Each item must be an exact `node` remote value with a usable shared /// identifier; those identifiers are translated through the registry into @@ -222,10 +227,19 @@ impl WebDriverBiDiAccessibilityQuery { /// returned handles immediately before use. pub fn bind_current_nodes( &self, + validated: ValidatedBrowserProtocolUse, authority_registry: &mut BrowserAuthorityRegistry, target: BrowserContextOriginEpochDispatchTarget<'_>, items: &[(&str, Option<&str>)], ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_query_nodes_proof = validated; let context_origin = target.context_origin(); let context = context_origin.context(); let current_epoch = authority_registry @@ -324,6 +338,8 @@ pub enum WebDriverBiDiLocateNodesAdmissionError { }, /// The supplied browser session, context, or origin is not current in the registry. BrowserAuthority(BrowserRegistryError), + /// The consumed protocol-use proof was not SemanticObservation. + UnsupportedCapability(BrowserProtocolCapability), } impl Display for WebDriverBiDiLocateNodesAdmissionError { @@ -353,6 +369,18 @@ impl Display for WebDriverBiDiLocateNodesAdmissionError { "browser authority denied locateNodes admission: {error}" ) } + Self::UnsupportedCapability(capability) => { + let name = match capability { + BrowserProtocolCapability::Navigation => "Navigation", + BrowserProtocolCapability::SemanticObservation => "SemanticObservation", + BrowserProtocolCapability::TypedInput => "TypedInput", + BrowserProtocolCapability::NetworkObservation => "NetworkObservation", + }; + write!( + formatter, + "locateNodes admission requires a SemanticObservation protocol-use proof, not {name}" + ) + } } } } @@ -364,6 +392,7 @@ impl Error for WebDriverBiDiLocateNodesAdmissionError { Self::RemoteNode(error) => Some(error), Self::DocumentEpochMismatch { .. } => None, Self::BrowserAuthority(error) => Some(error), + Self::UnsupportedCapability(_) => None, } } } @@ -532,12 +561,13 @@ impl BrowserProtocolAdapterDescriptor { /// Admit one untrusted `locateNodes` result only after QueryNodes protocol proof. /// - /// The same-call boundary first consumes a non-cloneable protocol-use proof for + /// The same-call boundary first obtains a non-cloneable protocol-use proof for /// [`BrowserProtocolOperation::QueryNodes`], which derives - /// [`BrowserProtocolCapability::SemanticObservation`]. Only then may - /// [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`] translate admitted `sharedId` - /// values into [`ObservedNodeHandle`] values on the exact current session, browsing - /// context, canonical origin, and document epoch. + /// [`BrowserProtocolCapability::SemanticObservation`]. That proof is transferred by + /// ownership into [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which refuses + /// any other capability before translating admitted `sharedId` values into + /// [`ObservedNodeHandle`] values on the exact current session, browsing context, canonical + /// origin, and document epoch. /// /// This method performs no browser I/O, does not authenticate Chromium, and does not grant /// policy, destination, or typed-input authority. A later action must still revalidate the @@ -551,17 +581,18 @@ impl BrowserProtocolAdapterDescriptor { query: &WebDriverBiDiAccessibilityQuery, items: &[(&str, Option<&str>)], ) -> Result, WebDriverBiDiQueryNodesAdmissionError> { - self.dispatch_operation_if_context_origin_epoch_current( - authority_registry, - target, - required_originweave_protocol_version, - runtime_metadata, - BrowserProtocolOperation::QueryNodes, - |_validated, _operation, _epoch| (), - ) - .map_err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch)?; + let validated = self + .dispatch_operation_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + BrowserProtocolOperation::QueryNodes, + |validated, _operation, _epoch| validated, + ) + .map_err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch)?; query - .bind_current_nodes(authority_registry, target, items) + .bind_current_nodes(validated, authority_registry, target, items) .map_err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes) } } diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs index f5ddd4789..34be7b9b4 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -4,12 +4,20 @@ use std::error::Error; use originweave_core::{ BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextOriginEpochDispatchTarget, BrowserRegistryError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, Origin, WebDriverBiDiAccessibilityQuery, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, }; +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 controlled_origin() -> Origin { Origin::parse("https://app.example").expect("valid controlled fixture origin") } @@ -30,6 +38,25 @@ fn current_target<'a>( )) } +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, + )?) +} + #[test] fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() -> Result<(), Box> { @@ -39,6 +66,7 @@ fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() -> Resul let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; let handles = query.bind_current_nodes( + semantic_observation_proof()?, &mut registry, target, &[ @@ -77,6 +105,7 @@ fn over_budget_locate_nodes_result_fails_before_node_binding() -> Result<(), Box assert_eq!( query.bind_current_nodes( + semantic_observation_proof()?, &mut registry, target, &[ @@ -108,6 +137,7 @@ fn stale_document_epoch_fails_before_locate_nodes_binding() -> Result<(), Box Resul assert_eq!( query.bind_current_nodes( + semantic_observation_proof()?, &mut registry, target, &[ @@ -153,7 +184,7 @@ fn empty_locate_nodes_result_is_valid_when_the_document_is_current() -> Result<( let target = current_target(&mut registry, &expected_origin)?; let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; - let handles = query.bind_current_nodes(&mut registry, target, &[])?; + let handles = query.bind_current_nodes(semantic_observation_proof()?, &mut registry, target, &[])?; assert!(handles.is_empty()); Ok(()) } @@ -175,7 +206,12 @@ fn unknown_browser_session_fails_before_locate_nodes_binding() -> Result<(), Box let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; assert_eq!( - query.bind_current_nodes(&mut registry, target, &[("node", Some("shared-submit"))]), + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-submit"))], + ), Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( BrowserRegistryError::UnknownBrowserSession )) @@ -191,7 +227,12 @@ fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box Date: Sun, 16 Aug 2026 15:54:24 +0000 Subject: [PATCH 047/229] style(core): rustfmt locateNodes proof-admission tests Co-authored-by: Seongho Bae --- .../webdriver_bidi_locate_nodes_admission.rs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs index 34be7b9b4..80add05b6 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -184,7 +184,8 @@ fn empty_locate_nodes_result_is_valid_when_the_document_is_current() -> Result<( let target = current_target(&mut registry, &expected_origin)?; let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; - let handles = query.bind_current_nodes(semantic_observation_proof()?, &mut registry, target, &[])?; + let handles = + query.bind_current_nodes(semantic_observation_proof()?, &mut registry, target, &[])?; assert!(handles.is_empty()); Ok(()) } @@ -277,16 +278,12 @@ fn locate_nodes_admission_error_contract_is_source_aware() { assert!(errors[2].source().is_none()); assert!(errors[3].source().is_some()); assert!(errors[4].source().is_none()); - assert!(errors[4] - .to_string() - .contains("SemanticObservation protocol-use proof, not TypedInput")); - assert!(errors[5] - .to_string() - .contains("not Navigation")); - assert!(errors[6] - .to_string() - .contains("not SemanticObservation")); - assert!(errors[7] - .to_string() - .contains("not NetworkObservation")); + assert!( + errors[4] + .to_string() + .contains("SemanticObservation protocol-use proof, not TypedInput") + ); + assert!(errors[5].to_string().contains("not Navigation")); + assert!(errors[6].to_string().contains("not SemanticObservation")); + assert!(errors[7].to_string().contains("not NetworkObservation")); } From 2cd4ad5bc55fca5f98a2b498db3a9c91f5666891 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:42:09 +0900 Subject: [PATCH 048/229] test(core): forbid public raw node minting --- crates/originweave-core/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 095e6512c..b90a3053b 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -4,6 +4,21 @@ //! browser protocol/identifier boundaries in focused modules so browser //! adapters can evolve without turning raw CDP or WebDriver metadata into //! OriginWeave authority. +//! +//! Raw adapter-local node identifiers must not be mintable through the public +//! registry API. Public callers must enter through the reviewed semantic-node +//! admission path instead: +//! +//! ```compile_fail +//! use originweave_core::{BrowserAuthorityRegistry, Origin}; +//! +//! let mut registry = BrowserAuthorityRegistry::new(); +//! let session = registry.register_session("webdriver-session")?; +//! let context = registry.register_context(session, "top-level-context")?; +//! let origin = Origin::parse("http://127.0.0.1:43127")?; +//! let _handle = registry.bind_node(session, context, &origin, "backend-node-17")?; +//! # Ok::<(), Box>(()) +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] From 35cc131e25a7fba4b22f8d43b93fd29d6d2c9fe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:45:02 +0900 Subject: [PATCH 049/229] fix(core): encapsulate raw browser node minting --- .../src/browser_authority_registry.rs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 crates/originweave-core/src/browser_authority_registry.rs diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs new file mode 100644 index 000000000..39bdcb46c --- /dev/null +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -0,0 +1,146 @@ +use crate::browser_registry::BrowserAuthorityRegistry as RawBrowserAuthorityRegistry; +use crate::{ + BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, + Origin, +}; + +/// 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 an untrusted +/// browser-protocol node identifier into an [`ObservedNodeHandle`] 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 and revalidates the exact current document before minting handles. +pub struct BrowserAuthorityRegistry { + inner: RawBrowserAuthorityRegistry, +} + +impl BrowserAuthorityRegistry { + /// Create an empty registry with the reviewed default per-namespace identifier capacity. + #[must_use] + pub fn new() -> Self { + Self { + inner: RawBrowserAuthorityRegistry::new(), + } + } + + /// Create an empty registry with a caller-selected per-namespace identifier capacity. + /// + /// Session, browsing-context, and node identifiers retain independent monotonic namespaces. + /// The node namespace is still reachable only through crate-owned semantic admission. + #[must_use] + pub fn with_identifier_limit(maximum_identifier: u64) -> Self { + Self { + inner: RawBrowserAuthorityRegistry::with_identifier_limit(maximum_identifier), + } + } + + /// Register one opaque external browser-session identifier. + pub fn register_session( + &mut self, + external_identifier: &str, + ) -> Result { + self.inner.register_session(external_identifier) + } + + /// Register one opaque external browsing-context identifier inside a known browser session. + pub fn register_context( + &mut self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result { + self.inner + .register_context(browser_session, external_identifier) + } + + /// Retire one browsing context and all registry-local authority derived from it. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + self.inner.remove_context(browsing_context) + } + + /// Retire one browser session and every registered context and node binding beneath it. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + self.inner.remove_session(browser_session) + } + + /// Return the currently active document epoch for a known browsing context. + pub fn current_epoch( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner.current_epoch(browsing_context) + } + + /// Return the current document epoch only when the supplied session owns the context. + pub fn current_context_epoch( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner + .current_context_epoch(browser_session, browsing_context) + } + + /// Bind the canonical origin observed for the exact current browser document. + pub fn bind_context_origin( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + self.inner + .bind_context_origin(browser_session, browsing_context, origin) + } + + /// Revalidate the canonical origin bound to the exact current browser document. + pub fn require_context_origin( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + self.inner + .require_context_origin(browser_session, browsing_context, origin) + } + + /// Advance a browsing context to the next document epoch and invalidate old node bindings. + pub fn advance_document( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner.advance_document(browsing_context) + } + + /// Bind one admitted adapter-local node identifier to exact current browser authority. + /// + /// This operation is intentionally crate-private. Production callers outside this crate cannot + /// invoke it without first passing through a public admission path that owns the appropriate + /// protocol-use proof and untrusted-result validation. + pub(crate) fn bind_node( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, + ) -> Result { + self.inner.bind_node( + browser_session, + browsing_context, + origin, + external_identifier, + ) + } +} + +impl Default for BrowserAuthorityRegistry { + fn default() -> Self { + Self::new() + } +} From a7e7bd78b97f54f90e37bbb228f5046462bdda69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:45:27 +0900 Subject: [PATCH 050/229] fix(core): expose guarded browser authority registry --- crates/originweave-core/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b90a3053b..32eb4db99 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -23,6 +23,7 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod browser_authority_registry; mod browser_protocol; mod browser_protocol_dispatch; mod browser_protocol_operation; @@ -31,6 +32,7 @@ mod browser_registry; mod browser_registry_coverage; mod contracts; +pub use browser_authority_registry::BrowserAuthorityRegistry; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, @@ -55,7 +57,7 @@ pub use browser_protocol_operation::{ }; pub(crate) use browser_registry::contains_disallowed_protocol_text; pub use browser_registry::{ - BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::*; From 7b6ecbc796189c9b8f58dc0b6de340b16fd53d2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:46:48 +0900 Subject: [PATCH 051/229] test(core): route public node tests through semantic admission --- .../tests/browser_authority_registry.rs | 142 ++++++++++++++---- 1 file changed, 113 insertions(+), 29 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 6cef3e353..f686289db 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -3,14 +3,79 @@ use std::error::Error; use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, NodeHandleError, ObservedNodeHandle, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; +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 loopback_origin() -> Origin { Origin::parse("http://127.0.0.1:43127").expect("valid loopback fixture origin") } +fn semantic_observation_proof() -> ValidatedBrowserProtocolUse { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + ) + .expect("valid semantic-observation descriptor"); + descriptor + .validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + ) + .expect("valid semantic-observation proof") +} + +fn bind_observed_node( + registry: &mut BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, +) -> Result { + let epoch = registry + .bind_context_origin(browser_session, browsing_context, origin) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("generic"), None, 1) + .expect("valid bounded semantic-node query"); + query + .bind_current_nodes( + semantic_observation_proof(), + registry, + target, + &[("node", Some(external_identifier))], + )? + .into_iter() + .next() + .ok_or(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + )) +} + #[test] fn external_protocol_identifiers_are_scoped_and_never_become_authority() -> Result<(), Box> { @@ -94,8 +159,8 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< let context = registry.register_context(session, "top-level-context")?; let origin = loopback_origin(); - let first = registry.bind_node(session, context, &origin, "backend-node-17")?; - let same = registry.bind_node(session, context, &origin, "backend-node-17")?; + let first = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + let same = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; assert_eq!(first.node_id(), same.node_id()); let next_epoch = registry.advance_document(context)?; @@ -108,7 +173,7 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< }) ); - let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; + let rebound = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; assert_eq!(rebound.document_epoch(), next_epoch); assert_ne!(first.node_id(), rebound.node_id()); Ok(()) @@ -120,7 +185,7 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let origin = loopback_origin(); assert_eq!( - registry.bind_node(attacker, context, &origin, "node"), - Err(BrowserRegistryError::ContextSessionMismatch { - expected: owner, - actual: attacker, - }) + bind_observed_node(&mut registry, attacker, context, &origin, "node"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + } + )) ); Ok(()) } @@ -190,10 +264,12 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Result let context = registry.register_context(session, "top-level-context")?; let origin = loopback_origin(); assert_eq!( - registry.bind_node(session, context, &origin, "backend-node-17\n"), - Err(BrowserRegistryError::InvalidExternalIdentifier) + bind_observed_node( + &mut registry, + session, + context, + &origin, + "backend-node-17\n", + ), + Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId + )) ); Ok(()) } @@ -262,14 +346,12 @@ fn authority_identifier_capacity_is_bounded_and_testable() -> Result<(), Box Result<(), Bo let context = registry.register_context(known, "known-context")?; let origin = loopback_origin(); assert_eq!( - registry.bind_node(unknown, context, &origin, "node"), - Err(BrowserRegistryError::UnknownBrowserSession) + bind_observed_node(&mut registry, unknown, context, &origin, "node"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) ); Ok(()) } From 00f6268a8fa933cac210dc04b419d3540b495b7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:09:34 +0900 Subject: [PATCH 052/229] test(core): route origin binding node discovery through admission --- .../tests/browser_context_origin_binding.rs | 71 ++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs index 9d404ff3c..c00d04159 100644 --- a/crates/originweave-core/tests/browser_context_origin_binding.rs +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -2,10 +2,20 @@ use std::error::Error; use std::io; use originweave_core::{ - BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, - DocumentEpoch, Origin, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, }; +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 first_origin() -> Result> { Origin::parse("http://127.0.0.1:43127") .map_err(|_error| io::Error::other("controlled first origin must be valid").into()) @@ -16,6 +26,61 @@ fn second_origin() -> Result> { .map_err(|_error| io::Error::other("controlled second origin must be valid").into()) } +fn semantic_observation_proof() -> ValidatedBrowserProtocolUse { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + ) + .expect("valid semantic-observation descriptor"); + descriptor + .validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + ) + .expect("valid semantic-observation proof") +} + +fn bind_observed_node( + registry: &mut BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, +) -> Result { + let epoch = registry + .require_context_origin(browser_session, browsing_context, origin) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("generic"), None, 1) + .expect("valid bounded semantic-node query"); + query + .bind_current_nodes( + semantic_observation_proof(), + registry, + target, + &[("node", Some(external_identifier))], + )? + .into_iter() + .next() + .ok_or(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + )) +} + #[test] fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); @@ -30,7 +95,7 @@ fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box Date: Mon, 17 Aug 2026 05:11:09 +0900 Subject: [PATCH 053/229] style(core): apply canonical rustfmt to browser authority tests --- .../tests/browser_authority_registry.rs | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index f686289db..339fde349 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -6,13 +6,14 @@ use originweave_core::{ BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, NodeHandleError, ObservedNodeHandle, Origin, - OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + BrowsingContextId, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, + ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, }; -const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = OriginWeaveProtocolVersion::new(0, 1); +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"; @@ -185,7 +186,8 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box Date: Mon, 17 Aug 2026 05:13:33 +0900 Subject: [PATCH 054/229] test(core): permit fixture expect calls in admission regression --- crates/originweave-core/tests/browser_context_origin_binding.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs index c00d04159..9f79cb652 100644 --- a/crates/originweave-core/tests/browser_context_origin_binding.rs +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use std::error::Error; use std::io; From 04560b8f450dc90a204a86ffd7c1982564b8b81a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:58:38 +0900 Subject: [PATCH 055/229] test(core): require atomic locateNodes batch admission --- .../webdriver_bidi_locate_nodes_atomicity.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs new file mode 100644 index 000000000..16577fda7 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs @@ -0,0 +1,83 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, +}; + +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, + )?) +} + +#[test] +fn exhausted_locate_nodes_batch_does_not_consume_partial_node_authority( +) -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let origin = Origin::parse("https://app.example")?; + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + let batch_query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 2)?; + + assert_eq!( + batch_query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted, + )) + ); + + let recovery_query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Recovery action"), 1)?; + let handles = recovery_query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-recovery"))], + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].node_id(), 1); + Ok(()) +} From b972931b2de5046c1d1a1a4910b27bcd1cf37fea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:00:21 +0900 Subject: [PATCH 056/229] test(core): apply canonical rustfmt to atomic admission regression --- .../tests/webdriver_bidi_locate_nodes_atomicity.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs index 16577fda7..9b681e39a 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs @@ -36,8 +36,8 @@ fn semantic_observation_proof() -> Result Result<(), Box> { +fn exhausted_locate_nodes_batch_does_not_consume_partial_node_authority() +-> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); let origin = Origin::parse("https://app.example")?; let session = registry.register_session("webdriver-session")?; @@ -50,8 +50,7 @@ fn exhausted_locate_nodes_batch_does_not_consume_partial_node_authority( ), epoch, ); - let batch_query = - WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 2)?; + let batch_query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 2)?; assert_eq!( batch_query.bind_current_nodes( From 8916235c7c5673647174a34f045665ea21514ae8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:01:48 +0900 Subject: [PATCH 057/229] test(core): reach atomic locateNodes admission boundary --- .../tests/webdriver_bidi_locate_nodes_atomicity.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs index 9b681e39a..974ea4d3f 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs @@ -1,6 +1,7 @@ #![allow(clippy::expect_used)] use std::error::Error; +use std::io; use originweave_core::{ BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, @@ -39,7 +40,12 @@ fn semantic_observation_proof() -> Result Result<(), Box> { let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); - let origin = Origin::parse("https://app.example")?; + let origin = Origin::parse("https://app.example").map_err(|_error| { + io::Error::new( + io::ErrorKind::InvalidData, + "controlled fixture origin must remain valid", + ) + })?; let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; let epoch = registry.bind_context_origin(session, context, &origin)?; From deee9e07f21c5f17dd4703bd7c3f4812d09536c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:10:26 +0900 Subject: [PATCH 058/229] fix(core): make locateNodes authority binding atomic --- .../src/browser_authority_registry.rs | 31 ++++--- .../src/browser_protocol_operation.rs | 31 ++++--- .../originweave-core/src/browser_registry.rs | 89 +++++++++++++++++++ 3 files changed, 121 insertions(+), 30 deletions(-) diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index 39bdcb46c..b97daae6e 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -7,11 +7,12 @@ use crate::{ /// 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 an untrusted -/// browser-protocol node identifier into an [`ObservedNodeHandle`] 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 and revalidates the exact current document before minting handles. +/// public because trusted adapters need them to maintain current authority. Converting untrusted +/// browser-protocol node identifiers into [`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. pub struct BrowserAuthorityRegistry { inner: RawBrowserAuthorityRegistry, } @@ -118,23 +119,25 @@ impl BrowserAuthorityRegistry { self.inner.advance_document(browsing_context) } - /// Bind one admitted adapter-local node identifier to exact current browser authority. + /// Bind one admitted batch of adapter-local node identifiers to current browser authority. /// - /// This operation is intentionally crate-private. Production callers outside this crate cannot - /// invoke it without first passing through a public admission path that owns the appropriate - /// protocol-use proof and untrusted-result validation. - pub(crate) fn bind_node( + /// 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. + pub(crate) fn bind_nodes( &mut self, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: &Origin, - external_identifier: &str, - ) -> Result { - self.inner.bind_node( + external_identifiers: &[&str], + ) -> Result, BrowserRegistryError> { + self.inner.bind_nodes( browser_session, browsing_context, origin, - external_identifier, + external_identifiers, ) } } diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 69327a56b..0c7942716 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -219,8 +219,9 @@ impl WebDriverBiDiAccessibilityQuery { /// The registry then proves that `target` still names the current session, browsing context, /// canonical origin, and document epoch. Only then is the returned item count checked against /// this query's budget. Each item must be an exact `node` remote value with a usable shared - /// identifier; those identifiers are translated through the registry into - /// [`ObservedNodeHandle`] values bound to that same current authority. + /// identifier. The complete admitted batch is then translated atomically into + /// [`ObservedNodeHandle`] values bound to that same current authority: if any registry binding + /// fails, no partial node authority from this call is retained. /// /// This method performs no browser I/O, does not authenticate Chromium, and does not grant /// policy, destination, or typed-input authority. A later action must still revalidate the @@ -268,20 +269,18 @@ impl WebDriverBiDiAccessibilityQuery { ); } - let mut handles = Vec::new(); - for reference in references { - handles.push( - authority_registry - .bind_node( - context.browser_session(), - context.browsing_context(), - context_origin.expected_origin(), - reference.shared_id(), - ) - .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?, - ); - } - Ok(handles) + let shared_ids = references + .iter() + .map(WebDriverBiDiRemoteNodeReference::shared_id) + .collect::>(); + authority_registry + .bind_nodes( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + &shared_ids, + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority) } } diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 10de5ab69..ac8b9f584 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -356,6 +356,46 @@ impl BrowserAuthorityRegistry { observed_node_handle(browser_session, browsing_context, origin, epoch, node_id) }) } + + /// Bind a batch of node identifiers transactionally to the exact current browser authority. + /// + /// Successful bindings are retained only when every identifier in the batch succeeds. If a + /// later identifier fails validation, authority checks, identifier allocation, or handle + /// construction, node mappings allocated by this batch are removed, the next node identifier + /// is restored, and an origin first established by this batch is removed before the error is + /// returned. Handles created earlier in the failed batch never escape this method, so restoring + /// the local allocation cursor cannot revive externally observable stale authority. + pub(crate) fn bind_nodes( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifiers: &[&str], + ) -> Result, BrowserRegistryError> { + let starting_next_node_id = self.next_node_id; + let had_origin = self.context_origin.contains_key(&browsing_context); + let mut handles = Vec::with_capacity(external_identifiers.len()); + for external_identifier in external_identifiers { + match self.bind_node( + browser_session, + browsing_context, + origin, + external_identifier, + ) { + Ok(handle) => handles.push(handle), + Err(error) => { + self.node_by_external + .retain(|_key, node_id| *node_id < starting_next_node_id); + self.next_node_id = starting_next_node_id; + if !had_origin { + self.context_origin.remove(&browsing_context); + } + return Err(error); + } + } + } + Ok(handles) + } } impl Default for BrowserAuthorityRegistry { @@ -611,6 +651,55 @@ mod tests { ); } + #[test] + fn batched_node_binding_rolls_back_partial_authority() { + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let existing = values(registry.bind_node(session, context, origin, "existing")); + assert_eq!(existing.len(), 1); + assert_eq!(existing[0].node_id(), 1); + assert_eq!( + registry.bind_nodes(session, context, origin, &["existing", "fresh", "overflow"]), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert_eq!(registry.node_by_external.len(), 1); + assert_eq!(registry.next_node_id, 2); + assert!(registry.context_origin.contains_key(&context)); + let recovery = values(registry.bind_node(session, context, origin, "recovery")); + assert_eq!(recovery.len(), 1); + assert_eq!(recovery[0].node_id(), 2); + + let mut unbound_registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let sessions = values(unbound_registry.register_session("unbound-session")); + assert_eq!(sessions.len(), 1); + let unbound_session = sessions[0]; + let contexts = values(unbound_registry.register_context(unbound_session, "unbound-context")); + assert_eq!(contexts.len(), 1); + let unbound_context = contexts[0]; + assert!(!unbound_registry.context_origin.contains_key(&unbound_context)); + assert_eq!( + unbound_registry.bind_nodes( + unbound_session, + unbound_context, + origin, + &["first", "overflow"], + ), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert!(!unbound_registry.context_origin.contains_key(&unbound_context)); + assert!(unbound_registry.node_by_external.is_empty()); + assert_eq!(unbound_registry.next_node_id, 1); + } + #[test] fn origin_rotation_and_node_cleanup_are_explicit() { let mut registry = BrowserAuthorityRegistry::new(); From c18bd19be95b2deea11f18e8a89fd4e106c5fa7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:13:38 +0900 Subject: [PATCH 059/229] test(core): exercise atomic browser binding coverage path --- crates/originweave-core/src/browser_registry_coverage.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 1860bc7be..37cedd741 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -19,8 +19,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { assert_eq!(origins.len(), 1); let origin = &origins[0]; - let first = values(registry.bind_node(session, context, origin, "unit-node")); - let repeated = values(registry.bind_node(session, context, origin, "unit-node")); + let first = values(registry.bind_nodes(session, context, origin, &["unit-node"])); + let repeated = values(registry.bind_nodes(session, context, origin, &["unit-node"])); assert_eq!(first.len(), 1); assert_eq!(repeated.len(), 1); assert_eq!(first[0], repeated[0]); @@ -51,7 +51,7 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() { let context = contexts[0]; assert_eq!( - registry.bind_node(attacker, context, &origins[0], "unit-node"), + registry.bind_nodes(attacker, context, &origins[0], &["unit-node"]), Err(BrowserRegistryError::ContextSessionMismatch { expected: owner, actual: attacker, From cca7e568f7a03677282d3def23cfc2ee412b92c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:19:36 +0900 Subject: [PATCH 060/229] style(core): apply canonical rustfmt to atomic binding tests --- crates/originweave-core/src/browser_registry.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index ac8b9f584..1e9cc731b 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -682,10 +682,15 @@ mod tests { let sessions = values(unbound_registry.register_session("unbound-session")); assert_eq!(sessions.len(), 1); let unbound_session = sessions[0]; - let contexts = values(unbound_registry.register_context(unbound_session, "unbound-context")); + let contexts = + values(unbound_registry.register_context(unbound_session, "unbound-context")); assert_eq!(contexts.len(), 1); let unbound_context = contexts[0]; - assert!(!unbound_registry.context_origin.contains_key(&unbound_context)); + assert!( + !unbound_registry + .context_origin + .contains_key(&unbound_context) + ); assert_eq!( unbound_registry.bind_nodes( unbound_session, @@ -695,7 +700,11 @@ mod tests { ), Err(BrowserRegistryError::IdentifierSpaceExhausted) ); - assert!(!unbound_registry.context_origin.contains_key(&unbound_context)); + assert!( + !unbound_registry + .context_origin + .contains_key(&unbound_context) + ); assert!(unbound_registry.node_by_external.is_empty()); assert_eq!(unbound_registry.next_node_id, 1); } From eec935e42346bcc22045c734ac63a2eedaf8596b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:03:56 +0900 Subject: [PATCH 061/229] test(core): reject CDP proof at BiDi node admission --- .../webdriver_bidi_protocol_kind_admission.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs new file mode 100644 index 000000000..2f0851bb7 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs @@ -0,0 +1,67 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; +const CDP_PROTOCOL_REVISION: &str = "cdp-pdl-2026-08-17"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn cdp_semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + ORIGINWEAVE_PROTOCOL_VERSION, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn webdriver_bidi_locate_nodes_rejects_cdp_semantic_observation_proof( +) -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example").expect("valid controlled fixture origin"); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 1)?; + + assert_eq!( + query.bind_current_nodes( + cdp_semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-task-text"))], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol, + )) + ); + Ok(()) +} From dcb77be069161e2804ab9a70d60b5f344bc2b94a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:05:27 +0900 Subject: [PATCH 062/229] test(core): apply canonical BiDi protocol-kind regression formatting --- .../tests/webdriver_bidi_protocol_kind_admission.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs index 2f0851bb7..2d5eb6862 100644 --- a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs @@ -36,8 +36,8 @@ fn cdp_semantic_observation_proof() -> Result Result<(), Box> { +fn webdriver_bidi_locate_nodes_rejects_cdp_semantic_observation_proof() -> Result<(), Box> +{ let mut registry = BrowserAuthorityRegistry::new(); let origin = Origin::parse("https://app.example").expect("valid controlled fixture origin"); let session = registry.register_session("webdriver-session")?; @@ -59,9 +59,11 @@ fn webdriver_bidi_locate_nodes_rejects_cdp_semantic_observation_proof( target, &[("node", Some("shared-task-text"))], ), - Err(WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( - BrowserProtocolKind::ChromeDevToolsProtocol, - )) + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol, + ) + ) ); Ok(()) } From 1671042743ff2a0069659096b086803858f0cdee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:07:31 +0900 Subject: [PATCH 063/229] test(core): cover protocol-confusion error contract --- .../webdriver_bidi_protocol_kind_admission.rs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs index 2d5eb6862..8c764060d 100644 --- a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs @@ -52,18 +52,24 @@ fn webdriver_bidi_locate_nodes_rejects_cdp_semantic_observation_proof() -> Resul ); let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 1)?; - assert_eq!( - query.bind_current_nodes( + let error = query + .bind_current_nodes( cdp_semantic_observation_proof()?, &mut registry, target, &[("node", Some("shared-task-text"))], - ), - Err( - WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( - BrowserProtocolKind::ChromeDevToolsProtocol, - ) ) + .expect_err("CDP proof must not authorize WebDriver BiDi locateNodes admission"); + assert_eq!( + error, + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol, + ) + ); + assert_eq!( + error.to_string(), + "locateNodes admission requires a WebDriverBiDi protocol-use proof, not ChromeDevToolsProtocol" ); + assert!(error.source().is_none()); Ok(()) } From 65e849c6c0e8ac8036052653dab64aab050355cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:08:42 +0900 Subject: [PATCH 064/229] fix(core): bind BiDi node admission to protocol family --- .../src/browser_protocol_operation.rs | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 0c7942716..8198f23a9 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -4,9 +4,9 @@ use std::fmt::{Display, Formatter}; use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, BrowserRegistryError, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, OriginWeaveProtocolVersion, - ValidatedBrowserProtocolUse, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, + BrowserRegistryError, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + ObservedNodeHandle, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; /// Exact WebDriver BiDi method used by the bounded accessibility-query contract. @@ -211,10 +211,12 @@ impl WebDriverBiDiAccessibilityQuery { /// Admit one untrusted `locateNodes` result against the exact current document authority. /// - /// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose capability is - /// exactly [`BrowserProtocolCapability::SemanticObservation`]. Navigation and TypedInput proofs - /// fail closed before the registry is consulted, so those adapters cannot mint observation - /// handles. The proof is consumed by ownership and cannot be reused for a later bind. + /// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose protocol + /// family is exactly [`BrowserProtocolKind::WebDriverBiDi`] and whose capability is exactly + /// [`BrowserProtocolCapability::SemanticObservation`]. A CDP proof or a Navigation/TypedInput + /// proof fails closed before the registry is consulted, so another protocol surface cannot + /// mint WebDriver BiDi observation handles. The proof is consumed by ownership and cannot be + /// reused for a later bind. /// /// The registry then proves that `target` still names the current session, browsing context, /// canonical origin, and document epoch. Only then is the returned item count checked against @@ -233,6 +235,11 @@ impl WebDriverBiDiAccessibilityQuery { target: BrowserContextOriginEpochDispatchTarget<'_>, items: &[(&str, Option<&str>)], ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), + ); + } if validated.capability() != BrowserProtocolCapability::SemanticObservation { return Err( WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( @@ -337,6 +344,8 @@ pub enum WebDriverBiDiLocateNodesAdmissionError { }, /// The supplied browser session, context, or origin is not current in the registry. BrowserAuthority(BrowserRegistryError), + /// The consumed protocol-use proof came from a different browser protocol family. + UnsupportedProtocolKind(BrowserProtocolKind), /// The consumed protocol-use proof was not SemanticObservation. UnsupportedCapability(BrowserProtocolCapability), } @@ -368,6 +377,10 @@ impl Display for WebDriverBiDiLocateNodesAdmissionError { "browser authority denied locateNodes admission: {error}" ) } + Self::UnsupportedProtocolKind(kind) => write!( + formatter, + "locateNodes admission requires a WebDriverBiDi protocol-use proof, not {kind:?}" + ), Self::UnsupportedCapability(capability) => { let name = match capability { BrowserProtocolCapability::Navigation => "Navigation", @@ -391,7 +404,7 @@ impl Error for WebDriverBiDiLocateNodesAdmissionError { Self::RemoteNode(error) => Some(error), Self::DocumentEpochMismatch { .. } => None, Self::BrowserAuthority(error) => Some(error), - Self::UnsupportedCapability(_) => None, + Self::UnsupportedProtocolKind(_) | Self::UnsupportedCapability(_) => None, } } } @@ -563,10 +576,10 @@ impl BrowserProtocolAdapterDescriptor { /// The same-call boundary first obtains a non-cloneable protocol-use proof for /// [`BrowserProtocolOperation::QueryNodes`], which derives /// [`BrowserProtocolCapability::SemanticObservation`]. That proof is transferred by - /// ownership into [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which refuses - /// any other capability before translating admitted `sharedId` values into - /// [`ObservedNodeHandle`] values on the exact current session, browsing context, canonical - /// origin, and document epoch. + /// ownership into [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which requires + /// the exact [`BrowserProtocolKind::WebDriverBiDi`] family and refuses any other capability + /// before translating admitted `sharedId` values into [`ObservedNodeHandle`] values on the + /// exact current session, browsing context, canonical origin, and document epoch. /// /// This method performs no browser I/O, does not authenticate Chromium, and does not grant /// policy, destination, or typed-input authority. A later action must still revalidate the From ec6293eb86666a5de335d0a71674215a8953db33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:11:30 +0900 Subject: [PATCH 065/229] fix(core): apply canonical protocol-kind gate formatting --- crates/originweave-core/src/browser_protocol_operation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 8198f23a9..280be51c1 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -5,8 +5,8 @@ use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, - BrowserRegistryError, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, - ObservedNodeHandle, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserRegistryError, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; /// Exact WebDriver BiDi method used by the bounded accessibility-query contract. From 0bd1878bdc1fc5592aa86422c277b8fca2136c57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:09:40 +0900 Subject: [PATCH 066/229] test(core): define locateNodes command serialization contract --- .../webdriver_bidi_locate_nodes_command.rs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs new file mode 100644 index 000000000..3d9974531 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs @@ -0,0 +1,125 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_WEBDRIVER_BIDI_COMMAND_ID, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesCommandError, +}; + +#[test] +fn locate_nodes_command_serializes_exact_bidi_envelope() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new( + Some("textbox"), + Some(r#"Task "quoted" \ review 작업"#), + 32, + )?; + let command = WebDriverBiDiLocateNodesCommand::new( + 42, + r#"context-"quoted"\path"#, + &query, + )?; + + assert_eq!(command.command_id(), 42); + assert_eq!(command.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + assert_eq!(command.browsing_context(), r#"context-"quoted"\path"#); + assert_eq!( + command.as_json(), + r#"{"id":42,"method":"browsingContext.locateNodes","params":{"context":"context-\"quoted\"\\path","locator":{"type":"accessibility","value":{"role":"textbox","name":"Task \"quoted\" \\ review 작업"}},"maxNodeCount":32,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_serializes_role_only_and_name_only_locators() +-> Result<(), Box> { + let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let role_command = WebDriverBiDiLocateNodesCommand::new(0, "context-a", &role_only)?; + assert_eq!( + role_command.as_json(), + r#"{"id":0,"method":"browsingContext.locateNodes","params":{"context":"context-a","locator":{"type":"accessibility","value":{"role":"button"}},"maxNodeCount":1,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + + let name_only = WebDriverBiDiAccessibilityQuery::new(None, Some("Submit task"), 2)?; + let name_command = WebDriverBiDiLocateNodesCommand::new( + MAX_WEBDRIVER_BIDI_COMMAND_ID, + "context-b", + &name_only, + )?; + assert_eq!(name_command.command_id(), MAX_WEBDRIVER_BIDI_COMMAND_ID); + assert_eq!( + name_command.as_json(), + r#"{"id":9007199254740991,"method":"browsingContext.locateNodes","params":{"context":"context-b","locator":{"type":"accessibility","value":{"name":"Submit task"}},"maxNodeCount":2,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_rejects_out_of_range_command_id() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + WebDriverBiDiLocateNodesCommand::new( + MAX_WEBDRIVER_BIDI_COMMAND_ID + 1, + "context-a", + &query, + ), + Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId) + ); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(u64::MAX, "context-a", &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId) + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_rejects_invalid_browsing_context_text() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + for invalid_context in ["", "context with space", "context\nline"] { + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, invalid_context, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + } + + let overlong = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, &overlong, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let context = format!("context{character}"); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, &context, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + } + Ok(()) +} + +#[test] +fn locate_nodes_command_accepts_maximum_bounded_context() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let context = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let command = WebDriverBiDiLocateNodesCommand::new(1, &context, &query)?; + + assert_eq!(command.browsing_context(), context); + assert!(command.as_json().contains(&context)); + Ok(()) +} + +#[test] +fn locate_nodes_command_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiLocateNodesCommandError::InvalidCommandId, + WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} From 09de2a19511ae68c053eb8d214d7b9f5936e7501 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:13:53 +0900 Subject: [PATCH 067/229] style(core): apply canonical locateNodes test formatting --- .../tests/webdriver_bidi_locate_nodes_command.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs index 3d9974531..d0195649b 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs @@ -14,11 +14,7 @@ fn locate_nodes_command_serializes_exact_bidi_envelope() -> Result<(), Box Result<(), Box Result<(), Box> { +fn locate_nodes_command_serializes_role_only_and_name_only_locators() -> Result<(), Box> +{ let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; let role_command = WebDriverBiDiLocateNodesCommand::new(0, "context-a", &role_only)?; assert_eq!( From 542d1808a0196347acf0baa31de540fb769facfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:16:53 +0900 Subject: [PATCH 068/229] feat(core): serialize bounded locateNodes command envelopes --- .../src/webdriver_bidi_command.rs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_command.rs diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs new file mode 100644 index 000000000..516af3116 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -0,0 +1,144 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + contains_disallowed_protocol_text, WebDriverBiDiAccessibilityQuery, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, +}; + +/// Maximum WebDriver BiDi command identifier representable by the protocol `js-uint` type. +pub const MAX_WEBDRIVER_BIDI_COMMAND_ID: u64 = 9_007_199_254_740_991; + +/// Fail-closed validation errors for one serialized WebDriver BiDi `locateNodes` command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesCommandError { + /// The command identifier exceeds WebDriver BiDi's unsigned safe-integer range. + InvalidCommandId, + /// The browsing-context identifier is empty, over budget, or contains disallowed text. + InvalidBrowsingContext, +} + +impl Display for WebDriverBiDiLocateNodesCommandError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidCommandId => "WebDriver BiDi command id is outside the js-uint range", + Self::InvalidBrowsingContext => { + "WebDriver BiDi browsing context is empty, over budget, or contains disallowed text" + } + }) + } +} + +impl Error for WebDriverBiDiLocateNodesCommandError {} + +/// Deterministic serialized command envelope for one bounded WebDriver BiDi accessibility query. +/// +/// Construction accepts only a WebDriver BiDi `js-uint` command identifier, a bounded opaque +/// browsing-context identifier, and an already validated [`WebDriverBiDiAccessibilityQuery`]. The +/// serialized envelope fixes the exact `browsingContext.locateNodes` method, accessibility locator, +/// finite node budget, and minimal serialization options carried by the query. String values are +/// JSON-escaped without interpreting their content. +/// +/// This is an inert transport value. It performs no browser I/O, authenticates no browser or +/// adapter, grants no session/context/origin authority, and cannot authorize policy or typed input. +/// A trusted transport adapter must still bind the command to the exact authenticated browser +/// session and later admit any response through the reviewed current-authority boundary. +#[derive(Debug, PartialEq, Eq)] +pub struct WebDriverBiDiLocateNodesCommand { + command_id: u64, + browsing_context: String, + json: String, +} + +impl WebDriverBiDiLocateNodesCommand { + /// Validate and serialize one bounded `browsingContext.locateNodes` command envelope. + pub fn new( + command_id: u64, + browsing_context: &str, + query: &WebDriverBiDiAccessibilityQuery, + ) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId); + } + if browsing_context.is_empty() + || browsing_context.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || contains_disallowed_protocol_text(browsing_context, false) + { + return Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext); + } + + let mut json = String::from("{\"id\":"); + json.push_str(&command_id.to_string()); + json.push_str(",\"method\":\""); + json.push_str(WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + json.push_str("\",\"params\":{\"context\":"); + push_json_string(&mut json, browsing_context); + json.push_str(",\"locator\":{\"type\":\""); + json.push_str(query.locator_type()); + json.push_str("\",\"value\":{"); + + if let Some(role) = query.role() { + json.push_str("\"role\":"); + push_json_string(&mut json, role); + } + if let Some(name) = query.name() { + if query.role().is_some() { + json.push(','); + } + json.push_str("\"name\":"); + push_json_string(&mut json, name); + } + + json.push_str("}},\"maxNodeCount\":"); + json.push_str(&query.max_node_count().to_string()); + json.push_str(",\"serializationOptions\":{\"maxDomDepth\":"); + json.push_str(&query.serialization_max_dom_depth().to_string()); + json.push_str(",\"maxObjectDepth\":"); + json.push_str(&query.serialization_max_object_depth().to_string()); + json.push_str(",\"includeShadowTree\":"); + push_json_string(&mut json, query.serialization_include_shadow_tree()); + json.push_str("}}}"); + + Ok(Self { + command_id, + browsing_context: browsing_context.to_owned(), + json, + }) + } + + /// Return the validated WebDriver BiDi command identifier. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the exact WebDriver BiDi method serialized by this command. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_LOCATE_NODES_METHOD + } + + /// Return the exact validated browsing-context identifier. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } + + /// Return the deterministic JSON command envelope. + #[must_use] + pub fn as_json(&self) -> &str { + &self.json + } +} + +fn push_json_string(output: &mut String, value: &str) { + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + character => output.push(character), + } + } + output.push('"'); +} From 75d0ef12a2ef1701fce7085ea6df7bd519ca2f0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:18:03 +0900 Subject: [PATCH 069/229] feat(core): export bounded locateNodes command contract --- crates/originweave-core/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 32eb4db99..18b387de3 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -31,6 +31,7 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +mod webdriver_bidi_command; pub use browser_authority_registry::BrowserAuthorityRegistry; pub use browser_protocol::{ @@ -61,3 +62,7 @@ pub use browser_registry::{ UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::*; +pub use webdriver_bidi_command::{ + MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesCommandError, +}; From 82bdd73242a5c072b4ca4089f18246a2c4f36c46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:19:49 +0900 Subject: [PATCH 070/229] style(core): apply canonical locateNodes command formatting --- crates/originweave-core/src/webdriver_bidi_command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index 516af3116..cdd212da5 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -2,8 +2,8 @@ use std::error::Error; use std::fmt::{Display, Formatter}; use crate::{ - contains_disallowed_protocol_text, WebDriverBiDiAccessibilityQuery, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, contains_disallowed_protocol_text, }; /// Maximum WebDriver BiDi command identifier representable by the protocol `js-uint` type. From 1844676c991a34a7f3e3326d0aaa08061c819164 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:02:33 +0900 Subject: [PATCH 071/229] docs(changelog): record BiDi locateNodes serialization --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b33951d21..eabbe9164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. +- Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. - 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. From d28836ae8c0f2096f214d681f001d60e9fcb780b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:03:14 +0900 Subject: [PATCH 072/229] docs(doctoring): ground BiDi command serialization --- docs/doctoring/browser-agent-protocols.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 8da0ed3ea..4a7005844 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,6 +1,6 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-10 +- **Reviewed:** 2026-08-18 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) @@ -8,9 +8,11 @@ This addendum complements the main doctoring record. The main record already car ## WebDriver BiDi -The W3C publication reviewed for this baseline is the 1 June 2026 **Working Draft**, not a Recommendation. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. +The latest published W3C technical-report baseline reviewed here remains the 1 June 2026 **Working Draft**, not a Recommendation. The current Editor’s Draft reviewed on 18 August 2026 identifies itself as the 20 July 2026 draft. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. -Primary source: World Wide Web Consortium, *WebDriver BiDi*. +For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. + +Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). ## Chrome Manifest V3 @@ -47,12 +49,13 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences 1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable control-free `sharedId` is present; do not treat a realm-local `handle`, a missing shared identifier, or control/whitespace-bearing protocol text as OriginWeave node authority. Treat an accessibility-query role as one exact WAI-ARIA token, not a whitespace-separated fallback list. -2. Pin exact Chromium/CDP compatibility evidence at release time. -3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. -4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. -5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. -7. Treat WARC/PROV as provenance representations, not policy or truth escalation. +2. Serialize reviewed BiDi commands from already validated bounded values only; a protocol-shaped JSON envelope never substitutes for authenticated browser transport, current session/context/origin/document authority, policy authorization, or post-condition evidence. +3. Pin exact Chromium/CDP compatibility evidence at release time. +4. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. +5. Keep WebMCP experimental/optional and propagate untrusted-content semantics. +6. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. +7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. +8. Treat WARC/PROV as provenance representations, not policy or truth escalation. ## References — APA 7th @@ -78,6 +81,8 @@ World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Application World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, July 20). *WebDriver BiDi* (Editor’s Draft). https://w3c.github.io/webdriver-bidi/ + World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html From 15f5988815d17759f1d940e82fc7a561f73f333c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:07:34 +0900 Subject: [PATCH 073/229] test(core): require BiDi locateNodes response correlation --- ..._bidi_locate_nodes_response_correlation.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs new file mode 100644 index 000000000..4adb655d6 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs @@ -0,0 +1,67 @@ +use std::error::Error; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, +}; + +fn locate_nodes_command( + command_id: u64, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn locate_nodes_response_requires_exact_command_id() -> Result<(), Box> { + let correlated = locate_nodes_command(42)?.correlate_response_id(42)?; + + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn locate_nodes_response_rejects_mismatched_command_id() -> Result<(), Box> { + let error = locate_nodes_command(42)?.correlate_response_id(41); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + }) + ); + Ok(()) +} + +#[test] +fn locate_nodes_response_rejects_out_of_range_id_before_correlation() -> Result<(), Box> { + let error = locate_nodes_command(1)?.correlate_response_id(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId) + ); + Ok(()) +} + +#[test] +fn response_correlation_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId, + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 2, + actual: 1, + }, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} From 39a73bb026ecb88109f7326c18fbe55f9cfb31e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:09:12 +0900 Subject: [PATCH 074/229] test(core): format BiDi response correlation RED --- ...driver_bidi_locate_nodes_response_correlation.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs index 4adb655d6..a8147feb4 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs @@ -31,16 +31,19 @@ fn locate_nodes_response_rejects_mismatched_command_id() -> Result<(), Box Result<(), Box> { +fn locate_nodes_response_rejects_out_of_range_id_before_correlation() -> Result<(), Box> +{ let error = locate_nodes_command(1)?.correlate_response_id(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1); assert_eq!( From 5abc70f5d62515692100a79233ea6993cdd9b3f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:11:57 +0900 Subject: [PATCH 075/229] feat(core): correlate BiDi locateNodes response ids --- crates/originweave-core/src/lib.rs | 5 +- .../src/webdriver_bidi_command.rs | 89 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 18b387de3..7f372bae3 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -63,6 +63,7 @@ pub use browser_registry::{ }; pub use contracts::*; pub use webdriver_bidi_command::{ - MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiLocateNodesCommand, - WebDriverBiDiLocateNodesCommandError, + MAX_WEBDRIVER_BIDI_COMMAND_ID, ValidatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, + WebDriverBiDiLocateNodesResponseCorrelationError, }; diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index cdd212da5..a80e59f50 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -31,6 +31,64 @@ impl Display for WebDriverBiDiLocateNodesCommandError { impl Error for WebDriverBiDiLocateNodesCommandError {} +/// Fail-closed errors while correlating one WebDriver BiDi response with its exact command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseCorrelationError { + /// The returned response identifier exceeds WebDriver BiDi's `js-uint` range. + InvalidResponseId, + /// The returned response identifier belongs to a different in-flight command. + ResponseIdMismatch { + /// Exact command identifier that this response must carry. + expected: u64, + /// Untrusted response identifier returned by the adapter. + actual: u64, + }, +} + +impl Display for WebDriverBiDiLocateNodesResponseCorrelationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidResponseId => { + formatter.write_str("WebDriver BiDi response id is outside the js-uint range") + } + Self::ResponseIdMismatch { expected, actual } => write!( + formatter, + "WebDriver BiDi response id {actual} does not match command id {expected}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseCorrelationError {} + +/// Non-cloneable evidence that one `locateNodes` response matched the exact command id. +/// +/// Only [`WebDriverBiDiLocateNodesCommand::correlate_response_id`] can construct this value. It +/// retains the exact command identifier and bounded browsing-context identifier so a later trusted +/// transport boundary can carry correlation evidence forward without reconstructing it from +/// ambient metadata. It does not authenticate a browser or adapter, prove current OriginWeave +/// session/context/origin authority, validate response payload shape, admit nodes, or authorize an +/// Agent action. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedWebDriverBiDiLocateNodesResponse { + command_id: u64, + browsing_context: String, +} + +impl ValidatedWebDriverBiDiLocateNodesResponse { + /// Return the exact command identifier proven to match the response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the bounded browsing-context identifier serialized by the matched command. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } +} + /// Deterministic serialized command envelope for one bounded WebDriver BiDi accessibility query. /// /// Construction accepts only a WebDriver BiDi `js-uint` command identifier, a bounded opaque @@ -129,6 +187,37 @@ impl WebDriverBiDiLocateNodesCommand { pub fn as_json(&self) -> &str { &self.json } + + /// Consume this command and correlate one untrusted response identifier with it. + /// + /// The response identifier is validated against WebDriver BiDi's `js-uint` range before exact + /// equality is checked. Success consumes the command and returns non-cloneable correlation + /// evidence, preventing this command value from being reused to validate another response. + /// This does not parse a response, authenticate the transport, or grant browser/Agent authority. + pub fn correlate_response_id( + self, + response_id: u64, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseCorrelationError, + > { + if response_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId); + } + if response_id != self.command_id { + return Err( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: self.command_id, + actual: response_id, + }, + ); + } + + Ok(ValidatedWebDriverBiDiLocateNodesResponse { + command_id: self.command_id, + browsing_context: self.browsing_context, + }) + } } fn push_json_string(output: &mut String, value: &str) { From 311863527ef81978350603e3d5c8a2eeef82d359 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:15:20 +0900 Subject: [PATCH 076/229] docs(changelog): record BiDi response correlation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eabbe9164..a41b6db9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. +- Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. - 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. From 547d9c9eba5c9ed07f5f35eb06878c6a55a8b125 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:16:16 +0900 Subject: [PATCH 077/229] docs(doctoring): ground BiDi response correlation --- docs/doctoring/browser-agent-protocols.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 4a7005844..9d35587d3 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -12,6 +12,8 @@ The latest published W3C technical-report baseline reviewed here remains the 1 J For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. +WebDriver BiDi commands may execute concurrently and finish out of order. The Editor’s Draft defines the command id as the local end’s correlation identifier and sets a successful `CommandResponse.id` to that exact command id; an `ErrorResponse.id` may be `null` when no valid command id can be recovered. OriginWeave therefore fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. + Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). ## Chrome Manifest V3 @@ -49,7 +51,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences 1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable control-free `sharedId` is present; do not treat a realm-local `handle`, a missing shared identifier, or control/whitespace-bearing protocol text as OriginWeave node authority. Treat an accessibility-query role as one exact WAI-ARIA token, not a whitespace-separated fallback list. -2. Serialize reviewed BiDi commands from already validated bounded values only; a protocol-shaped JSON envelope never substitutes for authenticated browser transport, current session/context/origin/document authority, policy authorization, or post-condition evidence. +2. Serialize reviewed BiDi commands from already validated bounded values only, then correlate each non-null response id to the exact consumed command before payload admission; a protocol-shaped JSON envelope or matching id never substitutes for authenticated browser transport, current session/context/origin/document authority, policy authorization, or post-condition evidence. 3. Pin exact Chromium/CDP compatibility evidence at release time. 4. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 5. Keep WebMCP experimental/optional and propagate untrusted-content semantics. From 2ba56afe218f76dcbb5d39a8c312300e8df38c79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:30:45 +0900 Subject: [PATCH 078/229] test(core): require BiDi response envelope semantics --- ...ver_bidi_locate_nodes_response_envelope.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs new file mode 100644 index 000000000..a8ba8aa90 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs @@ -0,0 +1,109 @@ +use std::error::Error; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiCommandResponseKind, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +fn locate_nodes_command( + command_id: u64, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn success_envelope_requires_and_retains_exact_response_id() -> Result<(), Box> { + let correlated = locate_nodes_command(42)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Success, + Some(42), + )?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn error_envelope_with_id_is_correlated_but_remains_error_kind() -> Result<(), Box> { + let correlated = locate_nodes_command(42)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Error, + Some(42), + )?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Error); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn success_envelope_rejects_missing_id() -> Result<(), Box> { + let error = locate_nodes_command(42)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Success, + None, + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId) + ); + Ok(()) +} + +#[test] +fn null_error_id_is_explicitly_uncorrelatable() -> Result<(), Box> { + let error = locate_nodes_command(42)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Error, + None, + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse) + ); + Ok(()) +} + +#[test] +fn envelope_preserves_exact_correlation_failures() -> Result<(), Box> { + let error = locate_nodes_command(42)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Success, + Some(41), + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + } + )) + ); + Ok(()) +} + +#[test] +fn envelope_error_sources_distinguish_protocol_shape_from_correlation() { + let direct_errors = [ + WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId, + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + ]; + for error in direct_errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } + + let correlation = WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId, + ); + assert!(correlation.source().is_some()); + assert!(!correlation.to_string().is_empty()); +} From 4bf783e50a07cc35c5299ff1f7ed935fdabafdde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:33:35 +0900 Subject: [PATCH 079/229] style(core): canonicalize BiDi envelope regression --- ...ver_bidi_locate_nodes_response_envelope.rs | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs index a8ba8aa90..db9479529 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs @@ -19,10 +19,8 @@ fn locate_nodes_command( #[test] fn success_envelope_requires_and_retains_exact_response_id() -> Result<(), Box> { - let correlated = locate_nodes_command(42)?.correlate_response_envelope( - WebDriverBiDiCommandResponseKind::Success, - Some(42), - )?; + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))?; assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Success); assert_eq!(correlated.command_id(), 42); @@ -32,10 +30,8 @@ fn success_envelope_requires_and_retains_exact_response_id() -> Result<(), Box Result<(), Box> { - let correlated = locate_nodes_command(42)?.correlate_response_envelope( - WebDriverBiDiCommandResponseKind::Error, - Some(42), - )?; + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, Some(42))?; assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Error); assert_eq!(correlated.command_id(), 42); @@ -45,10 +41,8 @@ fn error_envelope_with_id_is_correlated_but_remains_error_kind() -> Result<(), B #[test] fn success_envelope_rejects_missing_id() -> Result<(), Box> { - let error = locate_nodes_command(42)?.correlate_response_envelope( - WebDriverBiDiCommandResponseKind::Success, - None, - ); + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, None); assert_eq!( error, @@ -59,10 +53,8 @@ fn success_envelope_rejects_missing_id() -> Result<(), Box> { #[test] fn null_error_id_is_explicitly_uncorrelatable() -> Result<(), Box> { - let error = locate_nodes_command(42)?.correlate_response_envelope( - WebDriverBiDiCommandResponseKind::Error, - None, - ); + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, None); assert_eq!( error, @@ -73,10 +65,8 @@ fn null_error_id_is_explicitly_uncorrelatable() -> Result<(), Box> { #[test] fn envelope_preserves_exact_correlation_failures() -> Result<(), Box> { - let error = locate_nodes_command(42)?.correlate_response_envelope( - WebDriverBiDiCommandResponseKind::Success, - Some(41), - ); + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(41)); assert_eq!( error, From 45ed6b084f1d531889fcc3534637df94eab78965 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:35:43 +0900 Subject: [PATCH 080/229] feat(core): retain WebDriver BiDi response envelope kind --- .../src/webdriver_bidi_command.rs | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index a80e59f50..bc7e01e12 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -61,6 +61,55 @@ impl Display for WebDriverBiDiLocateNodesResponseCorrelationError { impl Error for WebDriverBiDiLocateNodesResponseCorrelationError {} +/// Structured WebDriver BiDi command-response envelope kind retained through correlation. +/// +/// A later trusted parser must derive this classification from the exact wire envelope. This value +/// does not validate raw JSON or grant browser, node, policy, or Agent authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiCommandResponseKind { + /// A WebDriver BiDi command success response. + Success, + /// A WebDriver BiDi command error response. + Error, +} + +/// Fail-closed errors while admitting a structured WebDriver BiDi response envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseEnvelopeError { + /// A success envelope did not carry the required command response identifier. + MissingResponseId, + /// An error envelope carried no recoverable command identifier and cannot be correlated. + UncorrelatableErrorResponse, + /// The present response identifier failed exact command correlation. + Correlation(WebDriverBiDiLocateNodesResponseCorrelationError), +} + +impl Display for WebDriverBiDiLocateNodesResponseEnvelopeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingResponseId => { + formatter.write_str("WebDriver BiDi success response is missing its command id") + } + Self::UncorrelatableErrorResponse => formatter.write_str( + "WebDriver BiDi error response has no recoverable command id for correlation", + ), + Self::Correlation(error) => write!( + formatter, + "WebDriver BiDi response envelope rejected command correlation: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseEnvelopeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Correlation(error) => Some(error), + Self::MissingResponseId | Self::UncorrelatableErrorResponse => None, + } + } +} + /// Non-cloneable evidence that one `locateNodes` response matched the exact command id. /// /// Only [`WebDriverBiDiLocateNodesCommand::correlate_response_id`] can construct this value. It @@ -89,6 +138,41 @@ impl ValidatedWebDriverBiDiLocateNodesResponse { } } +/// Non-cloneable structured-envelope evidence for one correlated `locateNodes` response. +/// +/// This value deliberately keeps success and error envelopes distinguishable after exact response +/// id correlation. A correlated error response remains error evidence and cannot be converted into +/// [`ValidatedWebDriverBiDiLocateNodesResponse`] through this public API. A later trusted response +/// parser must classify the exact wire envelope before calling +/// [`WebDriverBiDiLocateNodesCommand::correlate_response_envelope`]. This value performs no raw JSON +/// parsing, browser or adapter authentication, node admission, policy authorization, or Agent +/// action authorization. +#[derive(Debug, PartialEq, Eq)] +pub struct CorrelatedWebDriverBiDiLocateNodesResponse { + kind: WebDriverBiDiCommandResponseKind, + correlated: ValidatedWebDriverBiDiLocateNodesResponse, +} + +impl CorrelatedWebDriverBiDiLocateNodesResponse { + /// Return whether the exact correlated envelope was classified as success or error. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiCommandResponseKind { + self.kind + } + + /// Return the exact command identifier proven to match the response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.correlated.command_id() + } + + /// Return the bounded browsing-context identifier serialized by the matched command. + #[must_use] + pub fn browsing_context(&self) -> &str { + self.correlated.browsing_context() + } +} + /// Deterministic serialized command envelope for one bounded WebDriver BiDi accessibility query. /// /// Construction accepts only a WebDriver BiDi `js-uint` command identifier, a bounded opaque @@ -218,6 +302,44 @@ impl WebDriverBiDiLocateNodesCommand { browsing_context: self.browsing_context, }) } + + /// Consume this command and admit one already classified response envelope for correlation. + /// + /// A success envelope must carry a response id. A WebDriver BiDi error envelope may have a null + /// id when no valid command id can be recovered; that case returns + /// [`WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse`] and produces + /// no correlation evidence. When an id is present, the same protocol-range and exact-id checks + /// as [`Self::correlate_response_id`] apply. The returned evidence retains whether the envelope + /// was success or error so an error cannot silently become success evidence. + /// + /// The caller must obtain `kind` and `response_id` from a separately reviewed exact response + /// parser. This method does not parse JSON, validate result payload shape, authenticate a browser + /// or adapter, admit nodes, or grant policy, typed-input, secret, or Agent authority. + pub fn correlate_response_envelope( + self, + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, + ) -> Result< + CorrelatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError, + > { + let response_id = match (kind, response_id) { + (WebDriverBiDiCommandResponseKind::Success, None) => { + return Err(WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId); + } + (WebDriverBiDiCommandResponseKind::Error, None) => { + return Err( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + ); + } + (_, Some(response_id)) => response_id, + }; + let correlated = self + .correlate_response_id(response_id) + .map_err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation)?; + + Ok(CorrelatedWebDriverBiDiLocateNodesResponse { kind, correlated }) + } } fn push_json_string(output: &mut String, value: &str) { From b088f233ab9ae6e3bd348814df44f284c89ea32b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:36:19 +0900 Subject: [PATCH 081/229] feat(core): export BiDi response envelope contract --- crates/originweave-core/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 7f372bae3..f04ced099 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -63,7 +63,8 @@ pub use browser_registry::{ }; pub use contracts::*; pub use webdriver_bidi_command::{ - MAX_WEBDRIVER_BIDI_COMMAND_ID, ValidatedWebDriverBiDiLocateNodesResponse, + CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, + ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, - WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; From 640289beede7b36ee42fee199432b1a65bb3de89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:38:32 +0900 Subject: [PATCH 082/229] style(core): canonicalize BiDi envelope exports --- crates/originweave-core/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index f04ced099..86ecbb044 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -66,5 +66,6 @@ pub use webdriver_bidi_command::{ CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, - WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, }; From 67e57de57a4eb4ed798434ee94ea427178c62dcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:42:21 +0900 Subject: [PATCH 083/229] docs(changelog): record BiDi response envelope correlation --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a41b6db9c..fa1487c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. - Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. +- Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. - 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. @@ -88,4 +89,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD From 469032055d1fca50efb9921681ec9698a237da0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:00:29 +0900 Subject: [PATCH 084/229] test(core): require fail-closed BiDi success evidence conversion --- ...ver_bidi_locate_nodes_response_envelope.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs index db9479529..c61ba6a0a 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs @@ -28,6 +28,17 @@ fn success_envelope_requires_and_retains_exact_response_id() -> Result<(), Box Result<(), Box> { + let validated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; + + assert_eq!(validated.command_id(), 42); + assert_eq!(validated.browsing_context(), "context-a"); + Ok(()) +} + #[test] fn error_envelope_with_id_is_correlated_but_remains_error_kind() -> Result<(), Box> { let correlated = locate_nodes_command(42)? @@ -39,6 +50,19 @@ fn error_envelope_with_id_is_correlated_but_remains_error_kind() -> Result<(), B Ok(()) } +#[test] +fn correlated_error_cannot_become_success_evidence() -> Result<(), Box> { + let result = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, Some(42))? + .into_validated_success(); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse) + ); + Ok(()) +} + #[test] fn success_envelope_rejects_missing_id() -> Result<(), Box> { let error = locate_nodes_command(42)? @@ -85,6 +109,7 @@ fn envelope_error_sources_distinguish_protocol_shape_from_correlation() { let direct_errors = [ WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId, WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse, ]; for error in direct_errors { assert!(error.source().is_none()); From c6fac94d40341f8186324875d628e677a8841ea1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:03:46 +0900 Subject: [PATCH 085/229] feat(core): reject BiDi error envelopes from success evidence --- .../src/webdriver_bidi_command.rs | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index bc7e01e12..a68774d20 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -80,6 +80,8 @@ pub enum WebDriverBiDiLocateNodesResponseEnvelopeError { MissingResponseId, /// An error envelope carried no recoverable command identifier and cannot be correlated. UncorrelatableErrorResponse, + /// A correlated error envelope cannot be converted into success response evidence. + CorrelatedErrorResponse, /// The present response identifier failed exact command correlation. Correlation(WebDriverBiDiLocateNodesResponseCorrelationError), } @@ -93,6 +95,9 @@ impl Display for WebDriverBiDiLocateNodesResponseEnvelopeError { Self::UncorrelatableErrorResponse => formatter.write_str( "WebDriver BiDi error response has no recoverable command id for correlation", ), + Self::CorrelatedErrorResponse => formatter.write_str( + "WebDriver BiDi error response cannot become success response evidence", + ), Self::Correlation(error) => write!( formatter, "WebDriver BiDi response envelope rejected command correlation: {error}" @@ -105,7 +110,9 @@ impl Error for WebDriverBiDiLocateNodesResponseEnvelopeError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Correlation(error) => Some(error), - Self::MissingResponseId | Self::UncorrelatableErrorResponse => None, + Self::MissingResponseId + | Self::UncorrelatableErrorResponse + | Self::CorrelatedErrorResponse => None, } } } @@ -141,9 +148,9 @@ impl ValidatedWebDriverBiDiLocateNodesResponse { /// Non-cloneable structured-envelope evidence for one correlated `locateNodes` response. /// /// This value deliberately keeps success and error envelopes distinguishable after exact response -/// id correlation. A correlated error response remains error evidence and cannot be converted into -/// [`ValidatedWebDriverBiDiLocateNodesResponse`] through this public API. A later trusted response -/// parser must classify the exact wire envelope before calling +/// id correlation. The only conversion into [`ValidatedWebDriverBiDiLocateNodesResponse`] is +/// [`Self::into_validated_success`], which fails closed for a correlated error envelope. A later +/// trusted response parser must classify the exact wire envelope before calling /// [`WebDriverBiDiLocateNodesCommand::correlate_response_envelope`]. This value performs no raw JSON /// parsing, browser or adapter authentication, node admission, policy authorization, or Agent /// action authorization. @@ -171,6 +178,26 @@ impl CorrelatedWebDriverBiDiLocateNodesResponse { pub fn browsing_context(&self) -> &str { self.correlated.browsing_context() } + + /// Consume this envelope and return correlation evidence only when it was a success response. + /// + /// A correlated WebDriver BiDi error envelope remains error evidence and is rejected as + /// [`WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse`]. This explicit + /// fail-closed conversion prevents downstream result/node admission code from accidentally + /// erasing the protocol response kind while reusing exact command correlation evidence. + pub fn into_validated_success( + self, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError, + > { + match self.kind { + WebDriverBiDiCommandResponseKind::Success => Ok(self.correlated), + WebDriverBiDiCommandResponseKind::Error => { + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse) + } + } + } } /// Deterministic serialized command envelope for one bounded WebDriver BiDi accessibility query. From 13a8a1a799a8098b1305876c9d0f318d0acaa6d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:06:44 +0900 Subject: [PATCH 086/229] style(core): apply canonical BiDi error formatting --- crates/originweave-core/src/webdriver_bidi_command.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index a68774d20..d83944e51 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -95,9 +95,8 @@ impl Display for WebDriverBiDiLocateNodesResponseEnvelopeError { Self::UncorrelatableErrorResponse => formatter.write_str( "WebDriver BiDi error response has no recoverable command id for correlation", ), - Self::CorrelatedErrorResponse => formatter.write_str( - "WebDriver BiDi error response cannot become success response evidence", - ), + Self::CorrelatedErrorResponse => formatter + .write_str("WebDriver BiDi error response cannot become success response evidence"), Self::Correlation(error) => write!( formatter, "WebDriver BiDi response envelope rejected command correlation: {error}" From d251ca52b7b36a10dc9bfd54e430c00ac14e88ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:33:24 +0900 Subject: [PATCH 087/229] test(core): bind locateNodes result budget to correlated response --- ...ver_bidi_locate_nodes_response_envelope.rs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs index c61ba6a0a..46116cc71 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs @@ -1,9 +1,9 @@ use std::error::Error; use originweave_core::{ - WebDriverBiDiAccessibilityQuery, WebDriverBiDiCommandResponseKind, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, - WebDriverBiDiLocateNodesResponseEnvelopeError, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; fn locate_nodes_command( @@ -39,6 +39,22 @@ fn correlated_success_can_be_consumed_as_success_evidence() -> Result<(), Box Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + let validated = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; + + assert_eq!(validated.max_node_count(), 1); + assert_eq!(validated.validate_result_count(1), Ok(())); + assert_eq!( + validated.validate_result_count(2), + Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded) + ); + Ok(()) +} + #[test] fn error_envelope_with_id_is_correlated_but_remains_error_kind() -> Result<(), Box> { let correlated = locate_nodes_command(42)? From c11f46bd372a406c3f26f752a2e7defb5760a1c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:37:28 +0900 Subject: [PATCH 088/229] style(core): apply canonical rustfmt to result-budget regression --- .../tests/webdriver_bidi_locate_nodes_response_envelope.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs index 46116cc71..4425be6ee 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs @@ -3,7 +3,8 @@ use std::error::Error; use originweave_core::{ WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, - WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, }; fn locate_nodes_command( From 429b02b24c7e58709e0ffd0ad06dc3bd86db49dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:39:09 +0900 Subject: [PATCH 089/229] fix(core): bind locateNodes result budget to correlated success --- .../src/webdriver_bidi_command.rs | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index d83944e51..9a019cc45 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -3,7 +3,8 @@ use std::fmt::{Display, Formatter}; use crate::{ MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, - WebDriverBiDiAccessibilityQuery, contains_disallowed_protocol_text, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + contains_disallowed_protocol_text, }; /// Maximum WebDriver BiDi command identifier representable by the protocol `js-uint` type. @@ -119,15 +120,16 @@ impl Error for WebDriverBiDiLocateNodesResponseEnvelopeError { /// Non-cloneable evidence that one `locateNodes` response matched the exact command id. /// /// Only [`WebDriverBiDiLocateNodesCommand::correlate_response_id`] can construct this value. It -/// retains the exact command identifier and bounded browsing-context identifier so a later trusted -/// transport boundary can carry correlation evidence forward without reconstructing it from -/// ambient metadata. It does not authenticate a browser or adapter, prove current OriginWeave -/// session/context/origin authority, validate response payload shape, admit nodes, or authorize an -/// Agent action. +/// retains the exact command identifier, bounded browsing-context identifier, and exact serialized +/// result budget so a later trusted transport boundary can carry correlation evidence forward +/// without reconstructing authority from ambient query state. It does not authenticate a browser or +/// adapter, prove current OriginWeave session/context/origin authority, validate response payload +/// shape, admit nodes, or authorize an Agent action. #[derive(Debug, PartialEq, Eq)] pub struct ValidatedWebDriverBiDiLocateNodesResponse { command_id: u64, browsing_context: String, + max_node_count: u16, } impl ValidatedWebDriverBiDiLocateNodesResponse { @@ -142,6 +144,28 @@ impl ValidatedWebDriverBiDiLocateNodesResponse { pub fn browsing_context(&self) -> &str { &self.browsing_context } + + /// Return the exact `maxNodeCount` serialized by the matched command. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.max_node_count + } + + /// Validate a parsed `locateNodes` result count against the matched command's exact budget. + /// + /// This check is intentionally carried by command-correlation evidence rather than by a + /// separately supplied query value, preventing downstream code from validating an untrusted + /// response against a different, more permissive result budget. Zero through the serialized + /// maximum are valid; any larger result fails closed before node normalization or admission. + pub fn validate_result_count( + &self, + returned_node_count: usize, + ) -> Result<(), WebDriverBiDiAccessibilityQueryError> { + if returned_node_count > usize::from(self.max_node_count) { + return Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded); + } + Ok(()) + } } /// Non-cloneable structured-envelope evidence for one correlated `locateNodes` response. @@ -215,6 +239,7 @@ impl CorrelatedWebDriverBiDiLocateNodesResponse { pub struct WebDriverBiDiLocateNodesCommand { command_id: u64, browsing_context: String, + max_node_count: u16, json: String, } @@ -270,6 +295,7 @@ impl WebDriverBiDiLocateNodesCommand { Ok(Self { command_id, browsing_context: browsing_context.to_owned(), + max_node_count: query.max_node_count(), json, }) } @@ -302,8 +328,10 @@ impl WebDriverBiDiLocateNodesCommand { /// /// The response identifier is validated against WebDriver BiDi's `js-uint` range before exact /// equality is checked. Success consumes the command and returns non-cloneable correlation - /// evidence, preventing this command value from being reused to validate another response. - /// This does not parse a response, authenticate the transport, or grant browser/Agent authority. + /// evidence, preventing this command value from being reused to validate another response. The + /// evidence also retains the exact `maxNodeCount` serialized by this command so later result + /// admission cannot substitute a different query budget. This does not parse a response, + /// authenticate the transport, or grant browser/Agent authority. pub fn correlate_response_id( self, response_id: u64, @@ -326,6 +354,7 @@ impl WebDriverBiDiLocateNodesCommand { Ok(ValidatedWebDriverBiDiLocateNodesResponse { command_id: self.command_id, browsing_context: self.browsing_context, + max_node_count: self.max_node_count, }) } From 9a53a5c09998e6516972b2b975fdf6702cf8fe6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:02:39 +0900 Subject: [PATCH 090/229] test(core): require correlated locateNodes result admission --- ...iver_bidi_locate_nodes_result_admission.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs 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 new file mode 100644 index 000000000..4c8634077 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs @@ -0,0 +1,83 @@ +use std::error::Error; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResultAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +fn correlated_success( + max_node_count: u16, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new( + Some("button"), + Some("Submit task"), + max_node_count, + )?; + Ok(WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?) +} + +#[test] +fn correlated_result_admission_retains_exact_command_and_normalized_nodes( +) -> Result<(), Box> { + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + assert_eq!(result.command_id(), 42); + assert_eq!(result.browsing_context(), "context-a"); + assert_eq!(result.max_node_count(), 2); + assert_eq!(result.nodes().len(), 2); + assert_eq!(result.nodes()[0].remote_type(), "node"); + assert_eq!(result.nodes()[0].shared_id(), "shared-node-a"); + assert_eq!(result.nodes()[1].shared_id(), "shared-node-b"); + Ok(()) +} + +#[test] +fn correlated_result_admission_rejects_over_budget_batch_before_node_normalization( +) -> Result<(), Box> { + let error = correlated_success(1)?.admit_result_nodes(&[ + ("not-a-node", None), + ("not-a-node", None), + ]); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResultAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_admission_rejects_invalid_remote_node_shape() -> Result<(), Box> { + let error = correlated_success(1)?.admit_result_nodes(&[("string", Some("shared-node-a"))]); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_admission_error_preserves_typed_source() { + let query_error = WebDriverBiDiLocateNodesResultAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ); + assert!(query_error.source().is_some()); + assert!(!query_error.to_string().is_empty()); + + let remote_error = WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ); + assert!(remote_error.source().is_some()); + assert!(!remote_error.to_string().is_empty()); +} From 86a18c52025f33b54553c2e41773daea6f8b4692 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:03:46 +0900 Subject: [PATCH 091/229] style(core): apply canonical locateNodes result test formatting --- ...iver_bidi_locate_nodes_result_admission.rs | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) 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 4c8634077..4ee81cc77 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 @@ -9,19 +9,18 @@ use originweave_core::{ fn correlated_success( max_node_count: u16, ) -> Result> { - let query = WebDriverBiDiAccessibilityQuery::new( - Some("button"), - Some("Submit task"), - max_node_count, - )?; - Ok(WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? - .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? - .into_validated_success()?) + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), max_node_count)?; + Ok( + WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?, + ) } #[test] -fn correlated_result_admission_retains_exact_command_and_normalized_nodes( -) -> Result<(), Box> { +fn correlated_result_admission_retains_exact_command_and_normalized_nodes() +-> Result<(), Box> { let result = correlated_success(2)?.admit_result_nodes(&[ ("node", Some("shared-node-a")), ("node", Some("shared-node-b")), @@ -38,12 +37,10 @@ fn correlated_result_admission_retains_exact_command_and_normalized_nodes( } #[test] -fn correlated_result_admission_rejects_over_budget_batch_before_node_normalization( -) -> Result<(), Box> { - let error = correlated_success(1)?.admit_result_nodes(&[ - ("not-a-node", None), - ("not-a-node", None), - ]); +fn correlated_result_admission_rejects_over_budget_batch_before_node_normalization() +-> Result<(), Box> { + let error = + correlated_success(1)?.admit_result_nodes(&[("not-a-node", None), ("not-a-node", None)]); assert_eq!( error, From e9a47b683175e9726f4570918085102aa25eae04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:05:34 +0900 Subject: [PATCH 092/229] feat(core): admit correlated locateNodes result batches --- .../src/webdriver_bidi_result.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_result.rs diff --git a/crates/originweave-core/src/webdriver_bidi_result.rs b/crates/originweave-core/src/webdriver_bidi_result.rs new file mode 100644 index 000000000..4cc22ed5b --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_result.rs @@ -0,0 +1,115 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, +}; + +/// Fail-closed errors while admitting one correlated `locateNodes` result batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResultAdmissionError { + /// The returned node count exceeded the exact serialized command budget. + Query(WebDriverBiDiAccessibilityQueryError), + /// One returned item was not an admissible WebDriver BiDi node remote value. + RemoteNode(WebDriverBiDiRemoteNodeReferenceError), +} + +impl Display for WebDriverBiDiLocateNodesResultAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query(error) => write!( + formatter, + "correlated locateNodes result violated the exact command budget: {error}" + ), + Self::RemoteNode(error) => write!( + formatter, + "correlated locateNodes result contained an inadmissible remote node: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResultAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Query(error) => Some(error), + Self::RemoteNode(error) => Some(error), + } + } +} + +/// Non-cloneable evidence for one correlated, bounded, structurally admitted `locateNodes` result. +/// +/// Construction consumes exact command-correlation evidence, validates the returned array length +/// against the exact `maxNodeCount` serialized by that command, and normalizes every returned item +/// through [`WebDriverBiDiRemoteNodeReference`]. The resulting batch therefore cannot be reused +/// with a different ambient query budget and cannot retain non-node remote values or unusable node +/// identifiers. +/// +/// 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 +/// action. A later reviewed current-authority boundary must consume these normalized references. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedWebDriverBiDiLocateNodesResult { + correlated: ValidatedWebDriverBiDiLocateNodesResponse, + nodes: Vec, +} + +impl ValidatedWebDriverBiDiLocateNodesResult { + /// Return the exact command identifier proven to own this result batch. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.correlated.command_id() + } + + /// Return the bounded browsing-context identifier serialized by the correlated command. + #[must_use] + pub fn browsing_context(&self) -> &str { + self.correlated.browsing_context() + } + + /// Return the exact `maxNodeCount` serialized by the correlated command. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.correlated.max_node_count() + } + + /// Return the normalized untrusted node references admitted from the result array. + #[must_use] + pub fn nodes(&self) -> &[WebDriverBiDiRemoteNodeReference] { + &self.nodes + } +} + +impl ValidatedWebDriverBiDiLocateNodesResponse { + /// Consume exact command-correlation evidence and admit one structured `locateNodes` result. + /// + /// The result count is checked before any item is normalized so an over-budget response fails + /// at the resource boundary even when its individual elements are malformed. Every in-budget + /// item must then be the exact WebDriver BiDi `node` remote-value type and carry a usable + /// `sharedId`. Success consumes the correlation evidence, preventing the same command response + /// from being admitted repeatedly or against a different result payload. + pub fn admit_result_nodes( + self, + items: &[(&str, Option<&str>)], + ) -> Result + { + self.validate_result_count(items.len()) + .map_err(WebDriverBiDiLocateNodesResultAdmissionError::Query)?; + + let mut nodes = Vec::with_capacity(items.len()); + for (remote_type, shared_id) in items { + nodes.push( + WebDriverBiDiRemoteNodeReference::new(remote_type, *shared_id) + .map_err(WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode)?, + ); + } + + Ok(ValidatedWebDriverBiDiLocateNodesResult { + correlated: self, + nodes, + }) + } +} From fe8bbf2b3fc57ebb5d7c95ec684d2ad77b9ff89c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:05:50 +0900 Subject: [PATCH 093/229] feat(core): export correlated locateNodes result admission --- crates/originweave-core/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 86ecbb044..654d40863 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -32,6 +32,7 @@ mod browser_registry; mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; +mod webdriver_bidi_result; pub use browser_authority_registry::BrowserAuthorityRegistry; pub use browser_protocol::{ @@ -69,3 +70,6 @@ pub use webdriver_bidi_command::{ WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; +pub use webdriver_bidi_result::{ + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, +}; From 2c535b83a9a0a7414a5a27766f39f768041744df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:13:32 +0900 Subject: [PATCH 094/229] test(core): require correlated result authority binding --- ...iver_bidi_locate_nodes_result_admission.rs | 189 +++++++++++++++++- 1 file changed, 187 insertions(+), 2 deletions(-) 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 4ee81cc77..8a44cc8c3 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 @@ -1,11 +1,21 @@ use std::error::Error; use originweave_core::{ - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, - WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiCommandResponseKind, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResultAdmissionError, WebDriverBiDiRemoteNodeReferenceError, }; +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 correlated_success( max_node_count: u16, ) -> Result> { @@ -18,6 +28,52 @@ fn correlated_success( ) } +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, + external_context: &str, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, external_context)?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + )) +} + +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, + ) +} + #[test] fn correlated_result_admission_retains_exact_command_and_normalized_nodes() -> Result<(), Box> { @@ -78,3 +134,132 @@ fn correlated_result_admission_error_preserves_typed_source() { assert!(remote_error.source().is_some()); assert!(!remote_error.to_string().is_empty()); } + +#[test] +fn correlated_result_binds_only_to_its_exact_registered_context() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example")?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + let handles = result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target)?; + + assert_eq!(handles.len(), 2); + assert_eq!(handles[0].browsing_context(), target.context_origin().context().browsing_context()); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + Ok(()) +} + +#[test] +fn correlated_result_rejects_cross_context_rebinding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example")?; + let target = current_target(&mut registry, &origin, "context-b")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + let error = result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch, + )) + ); + let error = error.err().ok_or("expected context mismatch")?; + assert!(error.to_string().contains("external identifier")); + Ok(()) +} + +#[test] +fn correlated_result_rejects_non_bidi_protocol_proof() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example")?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes( + protocol_proof( + BrowserProtocolKind::Cdp, + BrowserProtocolCapability::SemanticObservation, + )?, + &mut registry, + target, + ), + Err(WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::Cdp, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_non_observation_protocol_proof() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example")?; + let target = current_target(&mut registry, &origin, "context-a")?; + 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(WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example")?; + let target = current_target(&mut registry, &origin, "context-a")?; + let context = target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + target.context_origin().context().browser_session(), + context, + &origin, + )?; + 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::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }) + ); + Ok(()) +} + +#[test] +fn correlated_result_keeps_node_binding_transactional_on_identifier_exhaustion() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let origin = Origin::parse("https://app.example")?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + assert_eq!( + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted, + )) + ); + Ok(()) +} From 3488eead61ca21dd7f58c61d5fa2826c4eb6c957 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:16:16 +0900 Subject: [PATCH 095/229] style(core): apply canonical correlated result test formatting --- ...iver_bidi_locate_nodes_result_admission.rs | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) 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 8a44cc8c3..53e9c5350 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 @@ -145,10 +145,14 @@ fn correlated_result_binds_only_to_its_exact_registered_context() -> Result<(), ("node", Some("shared-node-b")), ])?; - let handles = result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target)?; + let handles = + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target)?; assert_eq!(handles.len(), 2); - assert_eq!(handles[0].browsing_context(), target.context_origin().context().browsing_context()); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); assert_eq!(handles[0].origin(), &origin); assert_eq!(handles[0].document_epoch(), target.expected_epoch()); Ok(()) @@ -190,9 +194,11 @@ fn correlated_result_rejects_non_bidi_protocol_proof() -> Result<(), Box Result<(), Box< &mut registry, target, ), - Err(WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( - BrowserProtocolCapability::TypedInput, - )) + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput, + ) + ) ); Ok(()) } @@ -236,10 +244,12 @@ fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box assert_eq!( result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), - Err(WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { - expected: target.expected_epoch(), - current: current_epoch, - }) + Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + } + ) ); Ok(()) } From 44ddc32b02a458c661cde1bafe6502e6e51178fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:18:16 +0900 Subject: [PATCH 096/229] test(core): isolate correlated result authority RED --- ...iver_bidi_locate_nodes_result_admission.rs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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 53e9c5350..c302cbda4 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 @@ -45,6 +45,10 @@ fn current_target<'a>( )) } +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + fn protocol_proof( kind: BrowserProtocolKind, capability: BrowserProtocolCapability, @@ -138,7 +142,7 @@ fn correlated_result_admission_error_preserves_typed_source() { #[test] fn correlated_result_binds_only_to_its_exact_registered_context() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let origin = Origin::parse("https://app.example")?; + let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-a")?; let result = correlated_success(2)?.admit_result_nodes(&[ ("node", Some("shared-node-a")), @@ -161,7 +165,7 @@ fn correlated_result_binds_only_to_its_exact_registered_context() -> Result<(), #[test] fn correlated_result_rejects_cross_context_rebinding() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let origin = Origin::parse("https://app.example")?; + let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-b")?; let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; @@ -181,14 +185,14 @@ fn correlated_result_rejects_cross_context_rebinding() -> Result<(), Box Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let origin = Origin::parse("https://app.example")?; + let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-a")?; let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; assert_eq!( result.bind_current_nodes( protocol_proof( - BrowserProtocolKind::Cdp, + BrowserProtocolKind::ChromeDevToolsProtocol, BrowserProtocolCapability::SemanticObservation, )?, &mut registry, @@ -196,7 +200,7 @@ fn correlated_result_rejects_non_bidi_protocol_proof() -> Result<(), Box Result<(), Box Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let origin = Origin::parse("https://app.example")?; + let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-a")?; let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; @@ -231,7 +235,7 @@ fn correlated_result_rejects_non_observation_protocol_proof() -> Result<(), Box< #[test] fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let origin = Origin::parse("https://app.example")?; + let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-a")?; let context = target.context_origin().context().browsing_context(); let current_epoch = registry.advance_document(context)?; @@ -258,7 +262,7 @@ fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box fn correlated_result_keeps_node_binding_transactional_on_identifier_exhaustion() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); - let origin = Origin::parse("https://app.example")?; + let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-a")?; let result = correlated_success(2)?.admit_result_nodes(&[ ("node", Some("shared-node-a")), From 61508eb7e81274a038c1cae4f583970820f4b487 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:16:43 +0900 Subject: [PATCH 097/229] feat(core): bind correlated context identifiers fail-closed --- .../originweave-core/src/browser_registry.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 1e9cc731b..26a05249f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -227,6 +227,26 @@ impl BrowserAuthorityRegistry { self.current_epoch(browsing_context) } + /// Require an opaque external browsing-context identifier to name this exact context. + /// + /// This read-only check binds transport-level context text back to the already-registered + /// OriginWeave session/context pair. It never registers a new external context as a side effect, + /// so an untrusted result cannot create authority merely by presenting a different identifier. + pub(crate) fn require_context_external_identifier( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + validate_external_identifier(external_identifier)?; + self.current_context_epoch(browser_session, browsing_context)?; + let key = (browser_session, external_identifier.to_owned()); + if self.context_by_external.get(&key).copied() != Some(browsing_context) { + return Err(BrowserRegistryError::ContextExternalIdentifierMismatch); + } + Ok(()) + } + /// Bind the canonical origin observed for the exact current browser document. /// /// This boundary lets a trusted browser adapter establish current document-origin state before @@ -420,6 +440,8 @@ pub enum BrowserRegistryError { /// Session supplied by the current caller. actual: BrowserSessionId, }, + /// The transport-level browsing-context identifier does not name the supplied registered context. + ContextExternalIdentifierMismatch, /// The current document has no canonical origin bound to the browsing context. ContextOriginNotBound, /// The context origin changed without first rotating the document epoch. @@ -450,6 +472,9 @@ impl fmt::Display for BrowserRegistryError { expected.value(), actual.value() ), + Self::ContextExternalIdentifierMismatch => formatter.write_str( + "browsing context external identifier does not match the registered context", + ), Self::ContextOriginNotBound => formatter.write_str( "browsing context has no canonical origin bound for the current document", ), @@ -627,6 +652,18 @@ mod tests { let contexts = values(registry.register_context(session, "context-a")); assert_eq!(contexts.len(), 1); let context = contexts[0]; + assert_eq!( + registry.require_context_external_identifier(session, context, "context-a"), + Ok(()) + ); + assert_eq!( + registry.require_context_external_identifier(session, context, "context-b"), + Err(BrowserRegistryError::ContextExternalIdentifierMismatch) + ); + assert_eq!( + registry.require_context_external_identifier(session, context, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); assert_eq!(maximum_epochs.len(), 1); @@ -641,6 +678,14 @@ mod tests { let unknown_contexts = values(BrowsingContextId::new(999)); assert_eq!(unknown_sessions.len(), 1); assert_eq!(unknown_contexts.len(), 1); + assert_eq!( + registry.require_context_external_identifier(unknown_sessions[0], context, "context-a"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + registry.require_context_external_identifier(session, unknown_contexts[0], "context-a"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); assert_eq!( registry.bind_node(unknown_sessions[0], context, origin, "node"), Err(BrowserRegistryError::UnknownBrowserSession) @@ -803,6 +848,7 @@ mod tests { expected: expected_values[0], actual: actual_values[0], }, + BrowserRegistryError::ContextExternalIdentifierMismatch, BrowserRegistryError::ContextOriginNotBound, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, BrowserRegistryError::IdentifierSpaceExhausted, From 8ce29ea567db0cfb6f87eec6a51a1c8ef9358fcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:17:07 +0900 Subject: [PATCH 098/229] feat(core): expose crate-private context identity check --- .../src/browser_authority_registry.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index b97daae6e..3af93cb07 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -89,6 +89,20 @@ impl BrowserAuthorityRegistry { .current_context_epoch(browser_session, browsing_context) } + /// Require an opaque external browsing-context identifier to name this exact context. + pub(crate) fn require_context_external_identifier( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + self.inner.require_context_external_identifier( + browser_session, + browsing_context, + external_identifier, + ) + } + /// Bind the canonical origin observed for the exact current browser document. pub fn bind_context_origin( &mut self, From cfee13a04ac4ff7d51321d539035f979fc8dfebf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:17:33 +0900 Subject: [PATCH 099/229] feat(core): bind correlated locateNodes results to current authority --- .../src/webdriver_bidi_result.rs | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/webdriver_bidi_result.rs b/crates/originweave-core/src/webdriver_bidi_result.rs index 4cc22ed5b..ba3288269 100644 --- a/crates/originweave-core/src/webdriver_bidi_result.rs +++ b/crates/originweave-core/src/webdriver_bidi_result.rs @@ -2,8 +2,11 @@ use std::error::Error; use std::fmt::{Display, Formatter}; use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserProtocolCapability, + BrowserProtocolKind, ObservedNodeHandle, ValidatedBrowserProtocolUse, ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiAccessibilityQueryError, - WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, }; /// Fail-closed errors while admitting one correlated `locateNodes` result batch. @@ -81,6 +84,76 @@ impl ValidatedWebDriverBiDiLocateNodesResult { pub fn nodes(&self) -> &[WebDriverBiDiRemoteNodeReference] { &self.nodes } + + /// Consume this correlated result and bind its nodes to exact current browser authority. + /// + /// The consumed protocol-use proof must be WebDriver BiDi SemanticObservation authority. The + /// exact browsing-context identifier serialized by the correlated command must still map to + /// the supplied OriginWeave context; this check is read-only and never registers a missing or + /// 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. + pub fn bind_current_nodes( + self, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), + ); + } + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_observation_proof = validated; + let context_origin = target.context_origin(); + let context = context_origin.context(); + authority_registry + .require_context_external_identifier( + context.browser_session(), + context.browsing_context(), + self.browsing_context(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }, + ); + } + + let shared_ids = self + .nodes + .iter() + .map(WebDriverBiDiRemoteNodeReference::shared_id) + .collect::>(); + authority_registry + .bind_nodes( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + &shared_ids, + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority) + } } impl ValidatedWebDriverBiDiLocateNodesResponse { From 31faaac806ed98384fc84a759b96af94573a3955 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:34:30 +0900 Subject: [PATCH 100/229] test(core): cover missing origin binding rejection --- ...river_bidi_locate_nodes_result_admission.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 c302cbda4..5c70cdc7a 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 @@ -232,6 +232,24 @@ fn correlated_result_rejects_non_observation_protocol_proof() -> Result<(), Box< Ok(()) } +#[test] +fn correlated_result_rejects_missing_current_origin_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let context = target.context_origin().context().browsing_context(); + registry.advance_document(context)?; + 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( + BrowserRegistryError::ContextOriginNotBound, + )) + ); + Ok(()) +} + #[test] fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); From 67d0a3be55a054fffbd59647a0f1304c0bc194cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:33:28 +0900 Subject: [PATCH 101/229] test(core): require bounded BiDi response documents --- ...webdriver_bidi_response_document_budget.rs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs new file mode 100644 index 000000000..a0d1c5f61 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -0,0 +1,70 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; + +#[test] +fn bounded_response_document_retains_exact_wire_text() -> Result<(), Box> { + let raw = " \r\n{\"id\":42,\"type\":\"success\",\"result\":{}}\t"; + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + + assert_eq!(document.as_str(), raw); + Ok(()) +} + +#[test] +fn empty_or_json_whitespace_only_response_document_fails_closed() { + for raw in ["", " ", "\t\r\n"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument) + ); + } +} + +#[test] +fn response_document_requires_an_object_boundary_without_claiming_json_validation() { + for raw in ["[]", "null", "{", "}", "\u{00a0}{}\u{00a0}"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) + ); + } + + let coarse_only = BoundedWebDriverBiDiResponseDocument::new("{not-json}") + .expect("coarse admission intentionally does not parse JSON"); + assert_eq!(coarse_only.as_str(), "{not-json}"); +} + +#[test] +fn response_document_budget_accepts_exact_limit_and_rejects_one_more_byte() -> Result<(), Box> { + const OBJECT_OVERHEAD_BYTES: usize = 8; + let exact = format!( + "{{\"x\":\"{}\"}}", + "a".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - OBJECT_OVERHEAD_BYTES) + ); + assert_eq!(exact.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + assert!(BoundedWebDriverBiDiResponseDocument::new(&exact).is_ok()); + + let oversized = format!("{exact} "); + assert_eq!(oversized.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&oversized), + Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) + ); + Ok(()) +} + +#[test] +fn response_document_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument, + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} From 192b6f27a426905070755f51c5b41d644dfb5513 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:35:04 +0900 Subject: [PATCH 102/229] test(core): format BiDi response document RED --- .../webdriver_bidi_response_document_budget.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs index a0d1c5f61..7909c4f28 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -25,7 +25,8 @@ fn empty_or_json_whitespace_only_response_document_fails_closed() { } #[test] -fn response_document_requires_an_object_boundary_without_claiming_json_validation() { +fn response_document_requires_an_object_boundary_without_claiming_json_validation() +-> Result<(), Box> { for raw in ["[]", "null", "{", "}", "\u{00a0}{}\u{00a0}"] { assert_eq!( BoundedWebDriverBiDiResponseDocument::new(raw), @@ -33,13 +34,14 @@ fn response_document_requires_an_object_boundary_without_claiming_json_validatio ); } - let coarse_only = BoundedWebDriverBiDiResponseDocument::new("{not-json}") - .expect("coarse admission intentionally does not parse JSON"); + let coarse_only = BoundedWebDriverBiDiResponseDocument::new("{not-json}")?; assert_eq!(coarse_only.as_str(), "{not-json}"); + Ok(()) } #[test] -fn response_document_budget_accepts_exact_limit_and_rejects_one_more_byte() -> Result<(), Box> { +fn response_document_budget_accepts_exact_limit_and_rejects_one_more_byte() +-> Result<(), Box> { const OBJECT_OVERHEAD_BYTES: usize = 8; let exact = format!( "{{\"x\":\"{}\"}}", @@ -49,7 +51,10 @@ fn response_document_budget_accepts_exact_limit_and_rejects_one_more_byte() -> R assert!(BoundedWebDriverBiDiResponseDocument::new(&exact).is_ok()); let oversized = format!("{exact} "); - assert_eq!(oversized.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1); + assert_eq!( + oversized.len(), + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1 + ); assert_eq!( BoundedWebDriverBiDiResponseDocument::new(&oversized), Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) From 810c76212bd2df637c4a1e87941fbd98f3ed2588 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:36:36 +0900 Subject: [PATCH 103/229] feat(core): bound raw BiDi response documents --- .../src/webdriver_bidi_response_document.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_response_document.rs diff --git a/crates/originweave-core/src/webdriver_bidi_response_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document.rs new file mode 100644 index 000000000..4eacf84cb --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document.rs @@ -0,0 +1,76 @@ +use std::fmt; + +/// Maximum raw WebDriver BiDi response-document size admitted before parsing. +/// +/// This is an OriginWeave product safety budget, not a WebDriver BiDi protocol +/// limit. Browser adapters must enforce it before handing raw response text to a +/// JSON parser so an untrusted or malfunctioning peer cannot cause unbounded +/// parser input allocation. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES: usize = 65_536; + +/// Fail-closed reasons for rejecting a raw WebDriver BiDi response document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiResponseDocumentAdmissionError { + /// The response contains no JSON document after removing JSON whitespace. + EmptyDocument, + /// The raw response exceeds the OriginWeave pre-parser byte budget. + DocumentTooLarge, + /// The first and last non-whitespace bytes do not delimit a JSON object. + InvalidObjectBoundary, +} + +impl fmt::Display for WebDriverBiDiResponseDocumentAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyDocument => formatter.write_str("WebDriver BiDi response document is empty"), + Self::DocumentTooLarge => write!( + formatter, + "WebDriver BiDi response document exceeds {MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES} bytes" + ), + Self::InvalidObjectBoundary => formatter.write_str( + "WebDriver BiDi response document must have a top-level JSON object boundary", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiResponseDocumentAdmissionError {} + +/// Exact raw WebDriver BiDi response text admitted to the parser boundary. +/// +/// Construction proves only the OriginWeave byte budget and an obvious +/// top-level object boundary. It deliberately does not claim JSON validity, +/// response correlation, browser authenticity, or action authority. The exact +/// text is retained so downstream parsing/evidence can remain bound to the +/// admitted bytes. +#[derive(Debug, PartialEq, Eq)] +pub struct BoundedWebDriverBiDiResponseDocument { + raw: String, +} + +impl BoundedWebDriverBiDiResponseDocument { + /// Admits exact raw response text under the pre-parser safety contract. + pub fn new(raw: &str) -> Result { + if raw.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES { + return Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge); + } + + let bounded = raw.trim_matches(|character| matches!(character, ' ' | '\t' | '\r' | '\n')); + if bounded.is_empty() { + return Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument); + } + if !bounded.starts_with('{') || !bounded.ends_with('}') { + return Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary); + } + + Ok(Self { + raw: raw.to_owned(), + }) + } + + /// Returns the exact admitted response text, including surrounding JSON whitespace. + #[must_use] + pub fn as_str(&self) -> &str { + &self.raw + } +} From f4cf87ba6daddecd861a9ec1e1dc3f1b9ecad538 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:36:55 +0900 Subject: [PATCH 104/229] feat(core): export BiDi response document boundary --- crates/originweave-core/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 654d40863..fb43a48b9 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -32,6 +32,7 @@ mod browser_registry; mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; +mod webdriver_bidi_response_document; mod webdriver_bidi_result; pub use browser_authority_registry::BrowserAuthorityRegistry; @@ -70,6 +71,10 @@ pub use webdriver_bidi_command::{ WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; +pub use webdriver_bidi_response_document::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; From afe81c45b5d5be09f980bbd1dd153874b628fef1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:40:30 +0900 Subject: [PATCH 105/229] docs: record bounded BiDi response documents --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1487c17..8afb9ec7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. - Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. - Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. +- Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. - 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. From b2c458cfcbfc5781f38203dd27f9fbe90577ebea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:08:25 +0900 Subject: [PATCH 106/229] test(core): require bounded BiDi response envelope parsing --- ...webdriver_bidi_response_envelope_parser.rs | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs new file mode 100644 index 000000000..b09d2461b --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs @@ -0,0 +1,262 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, + MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS, + ParsedWebDriverBiDiCommandResponseEnvelope, WebDriverBiDiCommandResponseKind, + WebDriverBiDiResponseEnvelopeParseError, +}; + +#[test] +fn parser_classifies_exact_success_and_nullable_error_envelopes() -> Result<(), Box> { + let success_raw = + " {\"type\":\"success\",\"id\":42,\"result\":{\"nodes\":[]}}\r\n"; + let success = BoundedWebDriverBiDiResponseDocument::new(success_raw)? + .parse_command_response()?; + assert_eq!(success.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(success.response_id(), Some(42)); + assert_eq!(success.as_str(), success_raw); + + let error_raw = + "{\"type\":\"error\",\"id\":null,\"error\":\"invalid argument\",\"message\":\"bad request\"}"; + let error = BoundedWebDriverBiDiResponseDocument::new(error_raw)? + .parse_command_response()?; + assert_eq!(error.kind(), WebDriverBiDiCommandResponseKind::Error); + assert_eq!(error.response_id(), None); + assert_eq!(error.as_str(), error_raw); + Ok(()) +} + +#[test] +fn parser_accepts_extensible_fields_only_when_the_complete_json_is_valid() +-> Result<(), Box> { + let raw = concat!( + "{\"vendor\":{\"nested\":[true,false,null,{\"text\":\"a\\\\b\\\"c\\u263a\"}]},", + "\"id\":7,\"result\":{},\"type\":\"success\"}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)? + .parse_command_response()?; + assert_eq!(parsed.response_id(), Some(7)); + + for malformed in [ + "{\"type\":\"success\",\"id\":7,\"result\":{},}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":01}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":\"\\q\"}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":[1,]}", + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(malformed)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + } + Ok(()) +} + +#[test] +fn parser_rejects_missing_duplicate_or_unexpected_response_discriminators() +-> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseType, + ), + ( + "{\"type\":\"event\",\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType, + ), + ( + "{\"type\":\"success\",\"type\":\"error\",\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + ), + ( + "{\"type\":\"success\",\"id\":1,\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + Ok(()) +} + +#[test] +fn parser_requires_a_present_protocol_range_id_and_success_result() +-> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"type\":\"success\",\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + ), + ( + "{\"type\":\"error\",\"error\":\"invalid argument\",\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + ), + ( + "{\"type\":\"success\",\"id\":null,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":-1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1.0,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1e0,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":9007199254740992,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"success\",\"id\":1,\"result\":[]}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + + let maximum = format!( + "{{\"type\":\"success\",\"id\":{MAX_WEBDRIVER_BIDI_COMMAND_ID},\"result\":{{}}}}" + ); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&maximum)? + .parse_command_response()? + .response_id(), + Some(MAX_WEBDRIVER_BIDI_COMMAND_ID) + ); + Ok(()) +} + +#[test] +fn parser_requires_error_code_message_and_string_stacktrace() -> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"type\":\"error\",\"id\":1,\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":1,\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\",\"message\":false}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\",\"message\":\"bad\",\"stacktrace\":[]}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + + let valid = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"error\",\"id\":7,\"error\":\"invalid argument\",\"message\":\"bad\",\"stacktrace\":\"frame\"}", + )? + .parse_command_response()?; + assert_eq!(valid.response_id(), Some(7)); + Ok(()) +} + +#[test] +fn parser_enforces_top_level_field_and_json_depth_budgets() -> Result<(), Box> { + let mut fields = vec![ + "\"type\":\"success\"".to_owned(), + "\"id\":1".to_owned(), + "\"result\":{}".to_owned(), + ]; + while fields.len() < MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS { + fields.push(format!("\"x{}\":null", fields.len())); + } + let exact_fields = format!("{{{}}}", fields.join(",")); + assert!( + BoundedWebDriverBiDiResponseDocument::new(&exact_fields)? + .parse_command_response() + .is_ok() + ); + fields.push("\"overflow\":null".to_owned()); + let over_fields = format!("{{{}}}", fields.join(",")); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&over_fields)? + .parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded) + ); + + let exact_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "[".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 2), + "]".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 2) + ); + assert!( + BoundedWebDriverBiDiResponseDocument::new(&exact_nested)? + .parse_command_response() + .is_ok() + ); + + let over_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "[".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1), + "]".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1) + ); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&over_nested)? + .parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + ); + Ok(()) +} + +#[test] +fn parser_normalizes_escaped_top_level_names_before_duplicate_detection() +-> Result<(), Box> { + let duplicate = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"success\",\"\\u0069d\":1,\"id\":1,\"result\":{}}", + )?; + assert_eq!( + duplicate.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField) + ); + + let unicode_extension = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"success\",\"id\":1,\"result\":{},\"메타\":\"값\"}", + )? + .parse_command_response()?; + assert_eq!(unicode_extension.response_id(), Some(1)); + Ok(()) +} + +#[test] +fn response_envelope_parse_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded, + WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded, + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + WebDriverBiDiResponseEnvelopeParseError::MissingResponseType, + WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType, + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} + +fn _parsed_type_is_public(_parsed: ParsedWebDriverBiDiCommandResponseEnvelope) {} From 89bd3db5d5f2ee66c1e5599c3a47dcfac1d40ebd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:13:25 +0900 Subject: [PATCH 107/229] style(core): apply canonical BiDi parser test formatting --- ...webdriver_bidi_response_envelope_parser.rs | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs index b09d2461b..d15a525b0 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs @@ -9,18 +9,15 @@ use originweave_core::{ #[test] fn parser_classifies_exact_success_and_nullable_error_envelopes() -> Result<(), Box> { - let success_raw = - " {\"type\":\"success\",\"id\":42,\"result\":{\"nodes\":[]}}\r\n"; - let success = BoundedWebDriverBiDiResponseDocument::new(success_raw)? - .parse_command_response()?; + let success_raw = " {\"type\":\"success\",\"id\":42,\"result\":{\"nodes\":[]}}\r\n"; + let success = + BoundedWebDriverBiDiResponseDocument::new(success_raw)?.parse_command_response()?; assert_eq!(success.kind(), WebDriverBiDiCommandResponseKind::Success); assert_eq!(success.response_id(), Some(42)); assert_eq!(success.as_str(), success_raw); - let error_raw = - "{\"type\":\"error\",\"id\":null,\"error\":\"invalid argument\",\"message\":\"bad request\"}"; - let error = BoundedWebDriverBiDiResponseDocument::new(error_raw)? - .parse_command_response()?; + let error_raw = "{\"type\":\"error\",\"id\":null,\"error\":\"invalid argument\",\"message\":\"bad request\"}"; + let error = BoundedWebDriverBiDiResponseDocument::new(error_raw)?.parse_command_response()?; assert_eq!(error.kind(), WebDriverBiDiCommandResponseKind::Error); assert_eq!(error.response_id(), None); assert_eq!(error.as_str(), error_raw); @@ -34,8 +31,7 @@ fn parser_accepts_extensible_fields_only_when_the_complete_json_is_valid() "{\"vendor\":{\"nested\":[true,false,null,{\"text\":\"a\\\\b\\\"c\\u263a\"}]},", "\"id\":7,\"result\":{},\"type\":\"success\"}" ); - let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)? - .parse_command_response()?; + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; assert_eq!(parsed.response_id(), Some(7)); for malformed in [ @@ -81,8 +77,7 @@ fn parser_rejects_missing_duplicate_or_unexpected_response_discriminators() } #[test] -fn parser_requires_a_present_protocol_range_id_and_success_result() --> Result<(), Box> { +fn parser_requires_a_present_protocol_range_id_and_success_result() -> Result<(), Box> { for (raw, expected) in [ ( "{\"type\":\"success\",\"result\":{}}", @@ -125,9 +120,8 @@ fn parser_requires_a_present_protocol_range_id_and_success_result() assert_eq!(document.parse_command_response(), Err(expected)); } - let maximum = format!( - "{{\"type\":\"success\",\"id\":{MAX_WEBDRIVER_BIDI_COMMAND_ID},\"result\":{{}}}}" - ); + let maximum = + format!("{{\"type\":\"success\",\"id\":{MAX_WEBDRIVER_BIDI_COMMAND_ID},\"result\":{{}}}}"); assert_eq!( BoundedWebDriverBiDiResponseDocument::new(&maximum)? .parse_command_response()? @@ -192,8 +186,7 @@ fn parser_enforces_top_level_field_and_json_depth_budgets() -> Result<(), Box Result<(), Box Date: Tue, 18 Aug 2026 10:18:32 +0900 Subject: [PATCH 108/229] feat(core): parse bounded BiDi response envelopes --- .../src/webdriver_bidi_response_envelope.rs | 595 ++++++++++++++++++ 1 file changed, 595 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_response_envelope.rs diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs new file mode 100644 index 000000000..0dc2c5add --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -0,0 +1,595 @@ +use std::{error::Error, fmt}; + +use crate::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, + WebDriverBiDiCommandResponseKind, +}; + +/// Maximum accepted JSON container nesting depth for one WebDriver BiDi response document. +/// +/// The top-level response object is depth 1. The limit is an OriginWeave resource-safety +/// budget, not a WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH: usize = 64; + +/// Maximum accepted number of fields in one top-level WebDriver BiDi response object. +/// +/// The limit is an OriginWeave resource-safety budget, not a WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS: usize = 64; + +/// Fail-closed reasons a bounded WebDriver BiDi response document cannot become typed envelope +/// evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiResponseEnvelopeParseError { + /// The document is not syntactically valid JSON with one complete top-level object. + InvalidJson, + /// JSON object/array nesting exceeded the configured parser safety budget. + JsonDepthExceeded, + /// The top-level response object contains more fields than the configured safety budget. + TopLevelFieldCountExceeded, + /// The top-level response object repeats a field after JSON string escape decoding. + DuplicateTopLevelField, + /// The response object omits the required `type` discriminator. + MissingResponseType, + /// The response `type` is not exactly `success` or `error`. + UnexpectedResponseType, + /// The response object omits the required `id` field. + MissingResponseId, + /// The response `id` is not a protocol-range JSON integer, or is `null` where forbidden. + InvalidResponseId, + /// The selected response kind omits one of its required payload fields. + MissingRequiredPayload, + /// A required response payload field has the wrong JSON value type. + InvalidRequiredPayloadType, +} + +impl fmt::Display for WebDriverBiDiResponseEnvelopeParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidJson => "WebDriver BiDi response document is not valid JSON", + Self::JsonDepthExceeded => "WebDriver BiDi response JSON depth exceeds the safety budget", + Self::TopLevelFieldCountExceeded => { + "WebDriver BiDi response top-level field count exceeds the safety budget" + } + Self::DuplicateTopLevelField => { + "WebDriver BiDi response contains a duplicate top-level field" + } + Self::MissingResponseType => "WebDriver BiDi response is missing its type field", + Self::UnexpectedResponseType => "WebDriver BiDi response type is not success or error", + Self::MissingResponseId => "WebDriver BiDi response is missing its id field", + Self::InvalidResponseId => "WebDriver BiDi response id is invalid", + Self::MissingRequiredPayload => { + "WebDriver BiDi response is missing a required payload field" + } + Self::InvalidRequiredPayloadType => { + "WebDriver BiDi response payload field has an invalid JSON type" + } + }) + } +} + +impl Error for WebDriverBiDiResponseEnvelopeParseError {} + +/// Typed evidence that one bounded raw document is a syntactically valid WebDriver BiDi command +/// response envelope. +/// +/// The value retains the exact admitted wire text and exposes only the command-response kind and +/// parsed response identifier needed by later correlation. Parsing does not authenticate a browser +/// or transport and does not grant browser, node, policy, or Agent authority. +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedWebDriverBiDiCommandResponseEnvelope { + document: BoundedWebDriverBiDiResponseDocument, + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, +} + +impl ParsedWebDriverBiDiCommandResponseEnvelope { + /// Returns whether the parsed command response is a success or error envelope. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiCommandResponseKind { + self.kind + } + + /// Returns the parsed command identifier, or `None` only for an error response whose required + /// `id` field was explicitly JSON `null`. + #[must_use] + pub const fn response_id(&self) -> Option { + self.response_id + } + + /// Returns the exact bounded wire text from which this envelope evidence was parsed. + #[must_use] + pub fn as_str(&self) -> &str { + self.document.as_str() + } +} + +impl BoundedWebDriverBiDiResponseDocument { + /// Parses this already-bounded raw document into typed command-response envelope evidence. + /// + /// Complete JSON syntax, decoded top-level field uniqueness, response-kind requirements, + /// protocol-range response identifiers, and explicit parser resource budgets are enforced + /// before the value can be used by a later correlation boundary. + pub fn parse_command_response( + self, + ) -> Result + { + let parsed = ResponseEnvelopeParser::new(self.as_str()).parse()?; + Ok(ParsedWebDriverBiDiCommandResponseEnvelope { + document: self, + kind: parsed.kind, + response_id: parsed.response_id, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +enum ParsedJsonValue { + Object, + Array, + String(Vec), + Number(String), + Boolean, + Null, +} + +struct ParsedEnvelopeFields { + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, +} + +struct ResponseEnvelopeParser<'input> { + input: &'input str, + position: usize, +} + +impl<'input> ResponseEnvelopeParser<'input> { + const fn new(input: &'input str) -> Self { + Self { input, position: 0 } + } + + fn parse( + mut self, + ) -> Result { + self.skip_whitespace(); + self.expect_byte(b'{')?; + self.skip_whitespace(); + + let mut seen_fields: Vec> = Vec::new(); + let mut response_type = None; + let mut response_id = None; + let mut result = None; + let mut error_code = None; + let mut message = None; + let mut stacktrace = None; + + if self.peek_byte() != Some(b'}') { + loop { + if seen_fields.len() >= MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS { + return Err( + WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded, + ); + } + + let field_name = self.parse_string()?; + if seen_fields.contains(&field_name) { + return Err(WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField); + } + seen_fields.push(field_name.clone()); + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + let value = self.parse_value(2)?; + + match field_name.as_slice() { + b"type" => response_type = Some(value), + b"id" => response_id = Some(value), + b"result" => result = Some(value), + b"error" => error_code = Some(value), + b"message" => message = Some(value), + b"stacktrace" => stacktrace = Some(value), + _ => {} + } + + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => break, + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } + + self.expect_byte(b'}')?; + self.skip_whitespace(); + if self.position != self.input.len() { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + + let kind = Self::parse_response_type(response_type)?; + let response_id = Self::parse_response_id(response_id, kind)?; + Self::validate_required_payload(kind, result, error_code, message, stacktrace)?; + + Ok(ParsedEnvelopeFields { kind, response_id }) + } + + fn parse_response_type( + value: Option, + ) -> Result { + let value = value.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingResponseType)?; + match value { + ParsedJsonValue::String(value) if value == b"success" => { + Ok(WebDriverBiDiCommandResponseKind::Success) + } + ParsedJsonValue::String(value) if value == b"error" => { + Ok(WebDriverBiDiCommandResponseKind::Error) + } + _ => Err(WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType), + } + } + + fn parse_response_id( + value: Option, + kind: WebDriverBiDiCommandResponseKind, + ) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + let value = value.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingResponseId)?; + let raw = match value { + ParsedJsonValue::Null if kind == WebDriverBiDiCommandResponseKind::Error => { + return Ok(None); + } + ParsedJsonValue::Number(raw) => raw, + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId), + }; + if !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId); + } + let parsed = raw + .parse::() + .map_err(|_| WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId)?; + if parsed > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId); + } + Ok(Some(parsed)) + } + + fn validate_required_payload( + kind: WebDriverBiDiCommandResponseKind, + result: Option, + error_code: Option, + message: Option, + stacktrace: Option, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + match kind { + WebDriverBiDiCommandResponseKind::Success => { + let result = + result.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + if !matches!(result, ParsedJsonValue::Object) { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + } + WebDriverBiDiCommandResponseKind::Error => { + let error_code = error_code + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + let message = + message.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + if !matches!(error_code, ParsedJsonValue::String(_)) + || !matches!(message, ParsedJsonValue::String(_)) + { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + if let Some(stacktrace) = stacktrace { + if !matches!(stacktrace, ParsedJsonValue::String(_)) { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + } + } + } + Ok(()) + } + + fn parse_value( + &mut self, + container_depth: usize, + ) -> Result { + match self.peek_byte() { + Some(b'{') => { + self.parse_object(container_depth)?; + Ok(ParsedJsonValue::Object) + } + Some(b'[') => { + self.parse_array(container_depth)?; + Ok(ParsedJsonValue::Array) + } + Some(b'"') => Ok(ParsedJsonValue::String(self.parse_string()?)), + Some(b'-' | b'0'..=b'9') => Ok(ParsedJsonValue::Number(self.parse_number()?)), + Some(b't') => { + self.parse_literal(b"true")?; + Ok(ParsedJsonValue::Boolean) + } + Some(b'f') => { + self.parse_literal(b"false")?; + Ok(ParsedJsonValue::Boolean) + } + Some(b'n') => { + self.parse_literal(b"null")?; + Ok(ParsedJsonValue::Null) + } + _ => Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + + fn parse_object( + &mut self, + depth: usize, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + Self::require_depth(depth)?; + self.expect_byte(b'{')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Ok(()); + } + + loop { + self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + self.parse_value(depth + 1)?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + return Ok(()); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } + + fn parse_array( + &mut self, + depth: usize, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + Self::require_depth(depth)?; + self.expect_byte(b'[')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(()); + } + + loop { + self.parse_value(depth + 1)?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(()); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } + + fn require_depth(depth: usize) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + if depth > MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH { + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + } else { + Ok(()) + } + } + + fn parse_string(&mut self) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + self.expect_byte(b'"')?; + let mut decoded = Vec::new(); + loop { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + match byte { + b'"' => { + self.position += 1; + return Ok(decoded); + } + b'\\' => { + self.position += 1; + self.parse_escape(&mut decoded)?; + } + 0x00..=0x1f => { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + _ => { + decoded.push(byte); + self.position += 1; + } + } + } + } + + fn parse_escape( + &mut self, + decoded: &mut Vec, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + let escaped = self + .peek_byte() + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + self.position += 1; + match escaped { + b'"' => decoded.push(b'"'), + b'\\' => decoded.push(b'\\'), + b'/' => decoded.push(b'/'), + b'b' => decoded.push(0x08), + b'f' => decoded.push(0x0c), + b'n' => decoded.push(b'\n'), + b'r' => decoded.push(b'\r'), + b't' => decoded.push(b'\t'), + b'u' => { + let scalar = self.parse_unicode_escape()?; + Self::push_utf8(decoded, scalar); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + Ok(()) + } + + fn parse_unicode_escape(&mut self) -> Result { + let first = self.parse_hex_code_unit()?; + if (0xd800..=0xdbff).contains(&first) { + if self.peek_byte() != Some(b'\\') { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position += 1; + if self.peek_byte() != Some(b'u') { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position += 1; + let second = self.parse_hex_code_unit()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + return Ok( + 0x1_0000 + + ((u32::from(first) - 0xd800) << 10) + + (u32::from(second) - 0xdc00), + ); + } + if (0xdc00..=0xdfff).contains(&first) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + Ok(u32::from(first)) + } + + fn push_utf8(decoded: &mut Vec, scalar: u32) { + if scalar <= 0x7f { + decoded.push(scalar as u8); + } else if scalar <= 0x7ff { + decoded.push((0xc0 | (scalar >> 6)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } else if scalar <= 0xffff { + decoded.push((0xe0 | (scalar >> 12)) as u8); + decoded.push((0x80 | ((scalar >> 6) & 0x3f)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } else { + decoded.push((0xf0 | (scalar >> 18)) as u8); + decoded.push((0x80 | ((scalar >> 12) & 0x3f)) as u8); + decoded.push((0x80 | ((scalar >> 6) & 0x3f)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } + } + + fn parse_hex_code_unit(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + let digit = Self::hex_value(byte) + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + value = (value << 4) | u16::from(digit); + self.position += 1; + } + Ok(value) + } + + const fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } + } + + fn parse_number(&mut self) -> Result { + let start = self.position; + if self.peek_byte() == Some(b'-') { + self.position += 1; + } + + match self.peek_byte() { + Some(b'0') => { + self.position += 1; + if matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + } + Some(b'1'..=b'9') => self.consume_digits(), + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + + if self.peek_byte() == Some(b'.') { + self.position += 1; + if !matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.consume_digits(); + } + + if matches!(self.peek_byte(), Some(b'e' | b'E')) { + self.position += 1; + if matches!(self.peek_byte(), Some(b'+' | b'-')) { + self.position += 1; + } + if !matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.consume_digits(); + } + + Ok(self.input[start..self.position].to_owned()) + } + + fn consume_digits(&mut self) { + while matches!(self.peek_byte(), Some(b'0'..=b'9')) { + self.position += 1; + } + } + + fn parse_literal( + &mut self, + literal: &[u8], + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + let end = self.position + literal.len(); + if self.input.as_bytes().get(self.position..end) != Some(literal) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position = end; + Ok(()) + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) { + self.position += 1; + } + } + + fn expect_byte(&mut self, expected: u8) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + if self.peek_byte() == Some(expected) { + self.position += 1; + Ok(()) + } else { + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + } + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.position).copied() + } +} From 9f924d261209874c09fa14c31b72d863bd701200 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:19:00 +0900 Subject: [PATCH 109/229] feat(core): expose BiDi response envelope parser --- crates/originweave-core/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index fb43a48b9..c8cfd5290 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -33,6 +33,7 @@ mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; mod webdriver_bidi_response_document; +mod webdriver_bidi_response_envelope; mod webdriver_bidi_result; pub use browser_authority_registry::BrowserAuthorityRegistry; @@ -75,6 +76,10 @@ pub use webdriver_bidi_response_document::{ BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiResponseDocumentAdmissionError, }; +pub use webdriver_bidi_response_envelope::{ + MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS, + ParsedWebDriverBiDiCommandResponseEnvelope, WebDriverBiDiResponseEnvelopeParseError, +}; pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; From addb63f1c4374dfad18256875a4e77d4e6405399 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:22:29 +0900 Subject: [PATCH 110/229] style(core): apply canonical response parser formatting --- .../src/webdriver_bidi_response_envelope.rs | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs index 0dc2c5add..aba118bba 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -46,7 +46,9 @@ impl fmt::Display for WebDriverBiDiResponseEnvelopeParseError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::InvalidJson => "WebDriver BiDi response document is not valid JSON", - Self::JsonDepthExceeded => "WebDriver BiDi response JSON depth exceeds the safety budget", + Self::JsonDepthExceeded => { + "WebDriver BiDi response JSON depth exceeds the safety budget" + } Self::TopLevelFieldCountExceeded => { "WebDriver BiDi response top-level field count exceeds the safety budget" } @@ -147,9 +149,7 @@ impl<'input> ResponseEnvelopeParser<'input> { Self { input, position: 0 } } - fn parse( - mut self, - ) -> Result { + fn parse(mut self) -> Result { self.skip_whitespace(); self.expect_byte(b'{')?; self.skip_whitespace(); @@ -263,8 +263,8 @@ impl<'input> ResponseEnvelopeParser<'input> { ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { match kind { WebDriverBiDiCommandResponseKind::Success => { - let result = - result.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + let result = result + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; if !matches!(result, ParsedJsonValue::Object) { return Err( WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, @@ -274,8 +274,8 @@ impl<'input> ResponseEnvelopeParser<'input> { WebDriverBiDiCommandResponseKind::Error => { let error_code = error_code .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; - let message = - message.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + let message = message + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; if !matches!(error_code, ParsedJsonValue::String(_)) || !matches!(message, ParsedJsonValue::String(_)) { @@ -359,10 +359,7 @@ impl<'input> ResponseEnvelopeParser<'input> { } } - fn parse_array( - &mut self, - depth: usize, - ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + fn parse_array(&mut self, depth: usize) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { Self::require_depth(depth)?; self.expect_byte(b'[')?; self.skip_whitespace(); @@ -464,11 +461,9 @@ impl<'input> ResponseEnvelopeParser<'input> { if !(0xdc00..=0xdfff).contains(&second) { return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); } - return Ok( - 0x1_0000 - + ((u32::from(first) - 0xd800) << 10) - + (u32::from(second) - 0xdc00), - ); + return Ok(0x1_0000 + + ((u32::from(first) - 0xd800) << 10) + + (u32::from(second) - 0xdc00)); } if (0xdc00..=0xdfff).contains(&first) { return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); From eb086b09a7c58bd5fb1086e587d3cc3ec7ce1256 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:24:47 +0900 Subject: [PATCH 111/229] test(core): exercise hostile BiDi response JSON grammar --- ...ver_bidi_response_envelope_hostile_json.rs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs new file mode 100644 index 000000000..5b56399be --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs @@ -0,0 +1,107 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiResponseEnvelopeParseError, +}; + +fn assert_invalid_json(raw: &str) -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + Ok(()) +} + +#[test] +fn parser_rejects_top_level_separator_trailing_document_and_missing_colon_faults() +-> Result<(), Box> { + for raw in [ + "{\"type\":\"success\" \"id\":1,\"result\":{}}", + "{\"type\" \"success\",\"id\":1,\"result\":{}}", + "{\"type\":\"success\",\"id\":1,\"result\":{}} {}", + ] { + assert_invalid_json(raw)?; + } + + let empty = BoundedWebDriverBiDiResponseDocument::new("{}")?; + assert_eq!( + empty.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::MissingResponseType) + ); + Ok(()) +} + +#[test] +fn parser_rejects_malformed_nested_object_array_string_and_literal_values() +-> Result<(), Box> { + for raw in [ + "{\"type\":\"success\",\"id\":1,\"result\":{\"a\":1 \"b\":2}}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":[1 2]}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":tru}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":-}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":1.}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":1e}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":\"unterminated}", + ] { + assert_invalid_json(raw)?; + } + + let raw_control = "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":\"bad\u{0001}text\"}"; + assert_invalid_json(raw_control)?; + Ok(()) +} + +#[test] +fn parser_accepts_complete_json_escape_number_and_nested_container_forms() +-> Result<(), Box> { + let raw = concat!( + r#"{"type":"success","id":1,"result":{"a":1,"b":2},"esc":""#, + r#"\"\\\/\b\f\n\r\t","zero":0,"signed_exponent":1e+2,"nested":[1,2,{"ok":true}]}"#, + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(1)); + Ok(()) +} + +#[test] +fn parser_accepts_all_utf8_widths_from_json_unicode_escapes() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"text":"\u0041"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00E9"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u263A"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\uDE00"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00AF"}"#, + ] { + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(1)); + } + Ok(()) +} + +#[test] +fn parser_rejects_invalid_unicode_escape_sequences() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"text":"\uD83D"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\x"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\u0041"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uDE00"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u12"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00G0"}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_integer_overflow_even_before_protocol_range_validation() +-> Result<(), Box> { + let raw = "{\"type\":\"success\",\"id\":18446744073709551616,\"result\":{}}"; + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId) + ); + Ok(()) +} From 5c8df7608d3a1b985e361f1e7f69e99fc759ae30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:09:05 +0900 Subject: [PATCH 112/229] fix(core): satisfy strict BiDi parser clippy --- .../src/webdriver_bidi_response_envelope.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs index aba118bba..a5d5b34ba 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -283,12 +283,12 @@ impl<'input> ResponseEnvelopeParser<'input> { WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, ); } - if let Some(stacktrace) = stacktrace { - if !matches!(stacktrace, ParsedJsonValue::String(_)) { - return Err( - WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, - ); - } + if let Some(stacktrace) = stacktrace + && !matches!(stacktrace, ParsedJsonValue::String(_)) + { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); } } } From e455fe8846d36ee3ee7976ea1b945df250f891b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:09:39 -0700 Subject: [PATCH 113/229] test(core): cover response parser failure edges --- ...er_bidi_response_envelope_failure_edges.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs new file mode 100644 index 000000000..9eebae62e --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs @@ -0,0 +1,65 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, + WebDriverBiDiResponseEnvelopeParseError, +}; + +fn assert_invalid_json(raw: &str) -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + Ok(()) +} + +#[test] +fn parser_rejects_non_object_and_truncated_literal_documents() -> Result<(), Box> { + for raw in [ + "[]", + r#"{"type":"success","id":1,"result":{},"x":falsX}"#, + r#"{"type":"success","id":1,"result":{},"x":nulX}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_truncated_escape_and_unicode_code_units() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":"\"#, + r#"{"type":"success","id":1,"result":{},"x":"\u12"#, + r#"{"type":"success","id":1,"result":{},"x":"\uD83D\u"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_nested_object_key_and_colon_faults() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":{1:2}}"#, + r#"{"type":"success","id":1,"result":{},"x":{"a" 1}}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_enforces_depth_budget_for_object_nesting() -> Result<(), Box> { + let over_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "{\"k\":".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1), + "}".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1) + ); + let document = BoundedWebDriverBiDiResponseDocument::new(&over_nested)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + ); + Ok(()) +} From 944787908619bb45df6a6430222d03be5c7357cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:12:04 -0700 Subject: [PATCH 114/229] fix(core): remove unreachable parser error edges --- .../src/webdriver_bidi_response_envelope.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs index a5d5b34ba..a8c4681b1 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -196,13 +196,17 @@ impl<'input> ResponseEnvelopeParser<'input> { self.position += 1; self.skip_whitespace(); } - Some(b'}') => break, + Some(b'}') => { + self.position += 1; + break; + } _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), } } + } else { + self.position += 1; } - self.expect_byte(b'}')?; self.skip_whitespace(); if self.position != self.input.len() { return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); @@ -331,7 +335,7 @@ impl<'input> ResponseEnvelopeParser<'input> { depth: usize, ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { Self::require_depth(depth)?; - self.expect_byte(b'{')?; + self.position += 1; self.skip_whitespace(); if self.peek_byte() == Some(b'}') { self.position += 1; @@ -361,7 +365,7 @@ impl<'input> ResponseEnvelopeParser<'input> { fn parse_array(&mut self, depth: usize) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { Self::require_depth(depth)?; - self.expect_byte(b'[')?; + self.position += 1; self.skip_whitespace(); if self.peek_byte() == Some(b']') { self.position += 1; From d45118b9ac39e04cb36ed8486211fe025001db34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:09:59 -0700 Subject: [PATCH 115/229] test(core): respect bounded response admission boundary --- ...er_bidi_response_envelope_failure_edges.rs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs index 9eebae62e..c2050b517 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs @@ -2,7 +2,7 @@ use std::error::Error; use originweave_core::{ BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, - WebDriverBiDiResponseEnvelopeParseError, + WebDriverBiDiResponseDocumentAdmissionError, WebDriverBiDiResponseEnvelopeParseError, }; fn assert_invalid_json(raw: &str) -> Result<(), Box> { @@ -15,9 +15,17 @@ fn assert_invalid_json(raw: &str) -> Result<(), Box> { } #[test] -fn parser_rejects_non_object_and_truncated_literal_documents() -> Result<(), Box> { +fn non_object_response_stops_at_document_admission_before_parser() { + let error = BoundedWebDriverBiDiResponseDocument::new("[]").unwrap_err(); + assert_eq!( + error, + WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary + ); +} + +#[test] +fn parser_rejects_truncated_literal_documents() -> Result<(), Box> { for raw in [ - "[]", r#"{"type":"success","id":1,"result":{},"x":falsX}"#, r#"{"type":"success","id":1,"result":{},"x":nulX}"#, ] { @@ -27,11 +35,11 @@ fn parser_rejects_non_object_and_truncated_literal_documents() -> Result<(), Box } #[test] -fn parser_rejects_truncated_escape_and_unicode_code_units() -> Result<(), Box> { +fn parser_rejects_malformed_escape_and_unicode_code_units() -> Result<(), Box> { for raw in [ - r#"{"type":"success","id":1,"result":{},"x":"\"#, - r#"{"type":"success","id":1,"result":{},"x":"\u12"#, - r#"{"type":"success","id":1,"result":{},"x":"\uD83D\u"#, + r#"{"type":"success","id":1,"result":{},"x":"\q"}"#, + r#"{"type":"success","id":1,"result":{},"x":"\u12G4"}"#, + r#"{"type":"success","id":1,"result":{},"x":"\uD83D\u12G4"}"#, ] { assert_invalid_json(raw)?; } From 1b157efe48d82d68d2f3bb25a10975a3140734d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:13:05 -0700 Subject: [PATCH 116/229] test(core): avoid panic-prone admission assertion --- .../webdriver_bidi_response_envelope_failure_edges.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs index c2050b517..692e7ea68 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs @@ -16,11 +16,10 @@ fn assert_invalid_json(raw: &str) -> Result<(), Box> { #[test] fn non_object_response_stops_at_document_admission_before_parser() { - let error = BoundedWebDriverBiDiResponseDocument::new("[]").unwrap_err(); - assert_eq!( - error, - WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary - ); + assert!(matches!( + BoundedWebDriverBiDiResponseDocument::new("[]"), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) + )); } #[test] From 67f5ca317d1985c90c2402403105f4c005053fdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:17:48 -0700 Subject: [PATCH 117/229] fix(core): remove unreachable parser coverage branches --- .../src/webdriver_bidi_response_envelope.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs index a8c4681b1..db3d2b846 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -151,7 +151,8 @@ impl<'input> ResponseEnvelopeParser<'input> { fn parse(mut self) -> Result { self.skip_whitespace(); - self.expect_byte(b'{')?; + // The bounded-document constructor proves the first non-whitespace byte is `{`. + self.position += 1; self.skip_whitespace(); let mut seen_fields: Vec> = Vec::new(); @@ -428,9 +429,9 @@ impl<'input> ResponseEnvelopeParser<'input> { &mut self, decoded: &mut Vec, ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { - let escaped = self - .peek_byte() - .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + // The bounded top-level object guarantees a following byte; map any violated internal + // invariant to the existing fail-closed invalid-JSON path without a second unreachable branch. + let escaped = self.peek_byte().unwrap_or_default(); self.position += 1; match escaped { b'"' => decoded.push(b'"'), @@ -496,9 +497,9 @@ impl<'input> ResponseEnvelopeParser<'input> { fn parse_hex_code_unit(&mut self) -> Result { let mut value = 0_u16; for _ in 0..4 { - let byte = self - .peek_byte() - .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + // A truncated escape cannot run past the admitted top-level closing `}`. If an + // internal invariant is ever violated, zero still deterministically fails hex decoding. + let byte = self.peek_byte().unwrap_or_default(); let digit = Self::hex_value(byte) .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; value = (value << 4) | u16::from(digit); From e65df259da94082b9475e300f2854c820ae02d04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:17:45 -0700 Subject: [PATCH 118/229] test(core): require parsed BiDi response correlation --- ...ver_bidi_locate_nodes_response_document.rs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs new file mode 100644 index 000000000..a23ae5f26 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs @@ -0,0 +1,100 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiLocateNodesResponseEnvelopeError, + WebDriverBiDiResponseEnvelopeParseError, +}; + +fn locate_nodes_command(command_id: u64) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn bounded_success_document_is_parsed_and_correlated_in_one_consuming_boundary() +-> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[]}}"#, + )?; + let correlated = locate_nodes_command(42)?.correlate_response_document(document)?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn malformed_bounded_document_preserves_the_parser_failure() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{},}"#, + )?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Parse( + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + )) + ); + Ok(()) +} + +#[test] +fn parsed_response_id_mismatch_preserves_exact_correlation_failure() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{}}"#, + )?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + }, + ), + )) + ); + Ok(()) +} + +#[test] +fn nullable_error_document_remains_explicitly_uncorrelatable() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":null,"error":"invalid argument","message":"bad request"}"#, + )?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )) + ); + Ok(()) +} + +#[test] +fn document_correlation_error_preserves_nested_error_sources() { + let parse = WebDriverBiDiLocateNodesResponseDocumentError::Parse( + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + ); + assert!(parse.source().is_some()); + assert!(!parse.to_string().is_empty()); + + let envelope = WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId, + ); + assert!(envelope.source().is_some()); + assert!(!envelope.to_string().is_empty()); +} From e9d90dfa73552a8d0dd4e1d022478864b3300167 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:18:50 -0700 Subject: [PATCH 119/229] test(core): apply canonical BiDi correlation formatting --- ...ebdriver_bidi_locate_nodes_response_document.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs index a23ae5f26..811def1f3 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs @@ -8,7 +8,9 @@ use originweave_core::{ WebDriverBiDiResponseEnvelopeParseError, }; -fn locate_nodes_command(command_id: u64) -> Result> { +fn locate_nodes_command( + command_id: u64, +) -> Result> { let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; Ok(WebDriverBiDiLocateNodesCommand::new( command_id, @@ -33,9 +35,8 @@ fn bounded_success_document_is_parsed_and_correlated_in_one_consuming_boundary() #[test] fn malformed_bounded_document_preserves_the_parser_failure() -> Result<(), Box> { - let document = BoundedWebDriverBiDiResponseDocument::new( - r#"{"type":"success","id":42,"result":{},}"#, - )?; + let document = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":42,"result":{},}"#)?; let result = locate_nodes_command(42)?.correlate_response_document(document); assert_eq!( @@ -49,9 +50,8 @@ fn malformed_bounded_document_preserves_the_parser_failure() -> Result<(), Box Result<(), Box> { - let document = BoundedWebDriverBiDiResponseDocument::new( - r#"{"type":"success","id":41,"result":{}}"#, - )?; + let document = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":41,"result":{}}"#)?; let result = locate_nodes_command(42)?.correlate_response_document(document); assert_eq!( From 5481255ee62f9ab5ba936cb87373f416f5456fbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:34:47 -0700 Subject: [PATCH 120/229] feat(core): correlate bounded BiDi response documents --- crates/originweave-core/src/lib.rs | 2 + ...iver_bidi_response_document_correlation.rs | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index c8cfd5290..dc9ce4467 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -33,6 +33,7 @@ mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; mod webdriver_bidi_response_document; +mod webdriver_bidi_response_document_correlation; mod webdriver_bidi_response_envelope; mod webdriver_bidi_result; @@ -76,6 +77,7 @@ pub use webdriver_bidi_response_document::{ BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiResponseDocumentAdmissionError, }; +pub use webdriver_bidi_response_document_correlation::WebDriverBiDiLocateNodesResponseDocumentError; pub use webdriver_bidi_response_envelope::{ MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS, ParsedWebDriverBiDiCommandResponseEnvelope, WebDriverBiDiResponseEnvelopeParseError, diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs new file mode 100644 index 000000000..c0dd26b08 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -0,0 +1,66 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::webdriver_bidi_command::{ + CorrelatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; +use crate::webdriver_bidi_response_document::BoundedWebDriverBiDiResponseDocument; +use crate::webdriver_bidi_response_envelope::WebDriverBiDiResponseEnvelopeParseError; + +/// Fail-closed errors while parsing and correlating one bounded WebDriver BiDi response document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseDocumentError { + /// The bounded document failed complete WebDriver BiDi response-envelope parsing. + Parse(WebDriverBiDiResponseEnvelopeParseError), + /// The parsed envelope failed exact command correlation. + Envelope(WebDriverBiDiLocateNodesResponseEnvelopeError), +} + +impl Display for WebDriverBiDiLocateNodesResponseDocumentError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Parse(error) => write!( + formatter, + "WebDriver BiDi response document rejected envelope parsing: {error}" + ), + Self::Envelope(error) => write!( + formatter, + "WebDriver BiDi response document rejected command correlation: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseDocumentError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Parse(error) => Some(error), + Self::Envelope(error) => Some(error), + } + } +} + +impl WebDriverBiDiLocateNodesCommand { + /// Consume this command and one bounded raw response through parsing and exact correlation. + /// + /// The document must first pass complete response-envelope parsing. Only the resulting typed + /// response kind and protocol-range response id are then admitted to the existing exact command + /// correlation boundary. Parser and correlation failures remain distinguishable and preserve + /// their causal error sources. This boundary does not authenticate Chromium, ChromeDriver, or + /// WebSocket transport provenance, validate `locateNodes` result nodes, mint node authority, + /// authorize an Agent action, execute browser input, or prove a post-condition. + pub fn correlate_response_document( + self, + document: BoundedWebDriverBiDiResponseDocument, + ) -> Result< + CorrelatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseDocumentError, + > { + let parsed = document + .parse_command_response() + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; + self.correlate_response_envelope(parsed.kind(), parsed.response_id()) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope) + } +} From 35a9f4290adfafed4f6016e1453bc66aad0b0906 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:39:06 -0700 Subject: [PATCH 121/229] docs(changelog): record bounded BiDi document correlation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8afb9ec7c..f87011269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. - Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. - Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. +- One consuming bounded WebDriver BiDi response-document correlation boundary that parses the exact admitted JSON document and feeds only its typed response kind and protocol-range id into the existing exact command-correlation gate, preserving nested parser/correlation error sources without authenticating transport or granting browser/Agent authority. - 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. From e0b502ae8492d680babffdae19f1ca955051f843 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:08:52 -0700 Subject: [PATCH 122/229] test(core): reject unknown WebDriver BiDi error codes --- .../webdriver_bidi_response_error_code.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_error_code.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs new file mode 100644 index 000000000..3f8ee520d --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs @@ -0,0 +1,58 @@ +use std::error::Error; + +use originweave_core::BoundedWebDriverBiDiResponseDocument; + +const CURRENT_WEBDRIVER_BIDI_ERROR_CODES: &[&str] = &[ + "invalid argument", + "invalid selector", + "invalid session id", + "invalid web extension", + "move target out of bounds", + "no such alert", + "no such network collector", + "no such element", + "no such frame", + "no such handle", + "no such history entry", + "no such intercept", + "no such network data", + "no such node", + "no such request", + "no such screencast", + "no such script", + "no such storage partition", + "no such user context", + "no such web extension", + "session not created", + "unable to capture screen", + "unable to close browser", + "unable to set cookie", + "unable to set file input", + "unavailable network data", + "underspecified storage partition", + "unknown command", + "unknown error", + "unsupported operation", +]; + +#[test] +fn parser_accepts_current_webdriver_bidi_error_code_vocabulary() -> Result<(), Box> { + for error_code in CURRENT_WEBDRIVER_BIDI_ERROR_CODES { + let raw = format!( + "{{\"type\":\"error\",\"id\":7,\"error\":\"{error_code}\",\"message\":\"browser rejected command\"}}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response(); + assert!(parsed.is_ok(), "current WebDriver BiDi error code must remain admissible: {error_code}"); + } + Ok(()) +} + +#[test] +fn parser_rejects_unknown_webdriver_bidi_error_code() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"error\",\"id\":7,\"error\":\"made up browser failure\",\"message\":\"untrusted adapter text\"}", + )?; + + assert!(document.parse_command_response().is_err()); + Ok(()) +} From ebc127c8372de73d95922f589d3bd58e52cfe099 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:11:44 -0700 Subject: [PATCH 123/229] test(core): format WebDriver BiDi error-code RED --- .../tests/webdriver_bidi_response_error_code.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs index 3f8ee520d..5d4602647 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs @@ -42,7 +42,10 @@ fn parser_accepts_current_webdriver_bidi_error_code_vocabulary() -> Result<(), B "{{\"type\":\"error\",\"id\":7,\"error\":\"{error_code}\",\"message\":\"browser rejected command\"}}" ); let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response(); - assert!(parsed.is_ok(), "current WebDriver BiDi error code must remain admissible: {error_code}"); + assert!( + parsed.is_ok(), + "current WebDriver BiDi error code must remain admissible: {error_code}" + ); } Ok(()) } @@ -53,6 +56,10 @@ fn parser_rejects_unknown_webdriver_bidi_error_code() -> Result<(), Box Date: Tue, 18 Aug 2026 00:16:23 -0700 Subject: [PATCH 124/229] fix(core): define WebDriver BiDi error-code vocabulary --- .../src/webdriver_bidi_error_code.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_error_code.rs diff --git a/crates/originweave-core/src/webdriver_bidi_error_code.rs b/crates/originweave-core/src/webdriver_bidi_error_code.rs new file mode 100644 index 000000000..ac7b24703 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_error_code.rs @@ -0,0 +1,37 @@ +/// Returns whether `value` is one of the error codes admitted by the current WebDriver BiDi specification. +pub(crate) fn is_webdriver_bidi_error_code(value: &[u8]) -> bool { + const ERROR_CODES: &[&[u8]] = &[ + b"invalid argument", + b"invalid selector", + b"invalid session id", + b"invalid web extension", + b"move target out of bounds", + b"no such alert", + b"no such network collector", + b"no such element", + b"no such frame", + b"no such handle", + b"no such history entry", + b"no such intercept", + b"no such network data", + b"no such node", + b"no such request", + b"no such screencast", + b"no such script", + b"no such storage partition", + b"no such user context", + b"no such web extension", + b"session not created", + b"unable to capture screen", + b"unable to close browser", + b"unable to set cookie", + b"unable to set file input", + b"unavailable network data", + b"underspecified storage partition", + b"unknown command", + b"unknown error", + b"unsupported operation", + ]; + + ERROR_CODES.contains(&value) +} From 9b85be515cc2159439c95774627642b3210283e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:16:46 -0700 Subject: [PATCH 125/229] fix(core): wire WebDriver BiDi error-code validator --- crates/originweave-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index dc9ce4467..8571f9d87 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -32,6 +32,7 @@ mod browser_registry; mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; +mod webdriver_bidi_error_code; mod webdriver_bidi_response_document; mod webdriver_bidi_response_document_correlation; mod webdriver_bidi_response_envelope; From e8159640d3c36b07838b37400aab0ddc90e810be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:20:11 -0700 Subject: [PATCH 126/229] fix(core): reject unknown WebDriver BiDi error codes --- .../src/webdriver_bidi_response_envelope.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs index db3d2b846..066acce68 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -1,6 +1,7 @@ use std::{error::Error, fmt}; use crate::{ + webdriver_bidi_error_code::is_webdriver_bidi_error_code, BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiCommandResponseKind, }; @@ -40,6 +41,8 @@ pub enum WebDriverBiDiResponseEnvelopeParseError { MissingRequiredPayload, /// A required response payload field has the wrong JSON value type. InvalidRequiredPayloadType, + /// The error response uses a string outside the current WebDriver BiDi `ErrorCode` vocabulary. + UnexpectedErrorCode, } impl fmt::Display for WebDriverBiDiResponseEnvelopeParseError { @@ -65,6 +68,7 @@ impl fmt::Display for WebDriverBiDiResponseEnvelopeParseError { Self::InvalidRequiredPayloadType => { "WebDriver BiDi response payload field has an invalid JSON type" } + Self::UnexpectedErrorCode => "WebDriver BiDi response error code is not recognized", }) } } @@ -281,12 +285,18 @@ impl<'input> ResponseEnvelopeParser<'input> { .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; let message = message .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; - if !matches!(error_code, ParsedJsonValue::String(_)) - || !matches!(message, ParsedJsonValue::String(_)) - { + let ParsedJsonValue::String(error_code) = error_code else { return Err( WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, ); + }; + if !matches!(message, ParsedJsonValue::String(_)) { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + if !is_webdriver_bidi_error_code(&error_code) { + return Err(WebDriverBiDiResponseEnvelopeParseError::UnexpectedErrorCode); } if let Some(stacktrace) = stacktrace && !matches!(stacktrace, ParsedJsonValue::String(_)) From 5b829fe90da6712bb3fbdd890ea5a18325288f32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:22:43 -0700 Subject: [PATCH 127/229] style(core): apply canonical rustfmt ordering --- .../originweave-core/src/webdriver_bidi_response_envelope.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs index 066acce68..29e3e6728 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -1,9 +1,8 @@ use std::{error::Error, fmt}; use crate::{ - webdriver_bidi_error_code::is_webdriver_bidi_error_code, BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, - WebDriverBiDiCommandResponseKind, + WebDriverBiDiCommandResponseKind, webdriver_bidi_error_code::is_webdriver_bidi_error_code, }; /// Maximum accepted JSON container nesting depth for one WebDriver BiDi response document. From f5e087d58f5540993946c5f4057bef3f1623def9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:01:51 -0700 Subject: [PATCH 128/229] test(core): cover current BiDi client-window error --- .../originweave-core/tests/webdriver_bidi_response_error_code.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs index 5d4602647..3cc7785b1 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs @@ -9,6 +9,7 @@ const CURRENT_WEBDRIVER_BIDI_ERROR_CODES: &[&str] = &[ "invalid web extension", "move target out of bounds", "no such alert", + "no such client window", "no such network collector", "no such element", "no such frame", From 4077a79a49d77cb929a3818371b33c6e90cc7f56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:04:21 -0700 Subject: [PATCH 129/229] fix(core): admit current BiDi client-window error --- crates/originweave-core/src/webdriver_bidi_error_code.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-core/src/webdriver_bidi_error_code.rs b/crates/originweave-core/src/webdriver_bidi_error_code.rs index ac7b24703..ffcea6a1b 100644 --- a/crates/originweave-core/src/webdriver_bidi_error_code.rs +++ b/crates/originweave-core/src/webdriver_bidi_error_code.rs @@ -7,6 +7,7 @@ pub(crate) fn is_webdriver_bidi_error_code(value: &[u8]) -> bool { b"invalid web extension", b"move target out of bounds", b"no such alert", + b"no such client window", b"no such network collector", b"no such element", b"no such frame", From b7e5ba55090f22fefefd89bab638858ca42610d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:06:27 -0700 Subject: [PATCH 130/229] test(core): keep BiDi failure regression panic-free --- .../tests/webdriver_bidi_response_error_code.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs index 3cc7785b1..faed05b61 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs @@ -57,9 +57,15 @@ fn parser_rejects_unknown_webdriver_bidi_error_code() -> Result<(), Box { + return Err(std::io::Error::other( + "unknown WebDriver BiDi error code was unexpectedly accepted", + ) + .into()); + } + Err(error) => error, + }; assert!(!error.to_string().is_empty()); assert!(error.source().is_none()); Ok(()) From 9d39c5907c8d983d8c446f6f1df9654990576c60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:11:02 -0700 Subject: [PATCH 131/229] docs(doctoring): record current BiDi error-code contract --- docs/doctoring/browser-agent-protocols.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 9d35587d3..dbf3ef731 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -14,6 +14,8 @@ For the bounded `browsingContext.locateNodes` command-serialization boundary, th WebDriver BiDi commands may execute concurrently and finish out of order. The Editor’s Draft defines the command id as the local end’s correlation identifier and sets a successful `CommandResponse.id` to that exact command id; an `ErrorResponse.id` may be `null` when no valid command id can be recovered. OriginWeave therefore fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. +The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that currently includes `no such client window`. OriginWeave admits only the reviewed vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed; adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. + Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). ## Chrome Manifest V3 From 0e8414c7c159c23d8dd1d6800cbd4d0199a524c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:35:10 -0700 Subject: [PATCH 132/229] test(core): require wire-derived locateNodes result admission --- ...webdriver_bidi_locate_nodes_wire_result.rs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs new file mode 100644 index 000000000..1f91c02cb --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -0,0 +1,127 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, +}; + +fn locate_nodes_command( + command_id: u64, + max_node_count: u16, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new( + Some("button"), + Some("Submit task"), + max_node_count, + )?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn bounded_locate_nodes_document_admits_exact_wire_nodes_without_caller_selected_payload() +-> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b","value":{"nodeType":1}}]}}"#, + )?; + let admitted = locate_nodes_command(42, 2)?.admit_response_document_nodes(document)?; + + assert_eq!(admitted.command_id(), 42); + assert_eq!(admitted.browsing_context(), "context-a"); + assert_eq!(admitted.max_node_count(), 2); + assert_eq!(admitted.nodes().len(), 2); + assert_eq!(admitted.nodes()[0].remote_type(), "node"); + assert_eq!(admitted.nodes()[0].shared_id(), "node-a"); + assert_eq!(admitted.nodes()[1].shared_id(), "node-b"); + Ok(()) +} + +#[test] +fn wire_result_preserves_exact_command_node_budget() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_rejects_missing_shared_id() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_rejects_non_node_remote_value() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"window","sharedId":"node-a"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_requires_nodes_array() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":42,"result":{}}"#, + r#"{"type":"success","id":42,"result":{"nodes":{}}}"#, + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + } + Ok(()) +} + +#[test] +fn wire_result_rejects_ambiguous_duplicate_result_or_node_fields() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":42,"result":{"nodes":[],"nodes":[]}}"#, + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","type":"node","sharedId":"node-a"}]}}"#, + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a","sharedId":"node-a"}]}}"#, + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + } + Ok(()) +} + +#[test] +fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"res\u0075lt":{"no\u0064es":[{"ty\u0070e":"no\u0064e","shared\u0049d":"node-\u03b1"}]}}"#, + )?; + let admitted = locate_nodes_command(42, 1)?.admit_response_document_nodes(document)?; + + assert_eq!(admitted.nodes().len(), 1); + assert_eq!(admitted.nodes()[0].shared_id(), "node-α"); + Ok(()) +} From 3ad1c5fb34a21aafac188e4084e2ff9e2bfcf75c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:36:12 -0700 Subject: [PATCH 133/229] style(core): apply canonical wire-result test formatting --- .../tests/webdriver_bidi_locate_nodes_wire_result.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index 1f91c02cb..349d7912f 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -9,11 +9,8 @@ fn locate_nodes_command( command_id: u64, max_node_count: u16, ) -> Result> { - let query = WebDriverBiDiAccessibilityQuery::new( - Some("button"), - Some("Submit task"), - max_node_count, - )?; + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), max_node_count)?; Ok(WebDriverBiDiLocateNodesCommand::new( command_id, "context-a", @@ -115,7 +112,8 @@ fn wire_result_rejects_ambiguous_duplicate_result_or_node_fields() -> Result<(), } #[test] -fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result<(), Box> { +fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result<(), Box> +{ let document = BoundedWebDriverBiDiResponseDocument::new( r#"{"type":"success","id":42,"res\u0075lt":{"no\u0064es":[{"ty\u0070e":"no\u0064e","shared\u0049d":"node-\u03b1"}]}}"#, )?; From ee545e6650703c27a464d32c4c68b2cd6e79b289 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:39:38 -0700 Subject: [PATCH 134/229] feat(core): admit locateNodes nodes from bounded wire response --- ...iver_bidi_response_document_correlation.rs | 104 +++- .../locate_nodes_result_document.rs | 455 ++++++++++++++++++ 2 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs 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 c0dd26b08..570ec9ddc 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -1,20 +1,46 @@ use std::error::Error; use std::fmt::{Display, Formatter}; +mod locate_nodes_result_document; + use crate::webdriver_bidi_command::{ CorrelatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseEnvelopeError, }; use crate::webdriver_bidi_response_document::BoundedWebDriverBiDiResponseDocument; use crate::webdriver_bidi_response_envelope::WebDriverBiDiResponseEnvelopeParseError; +use crate::webdriver_bidi_result::{ + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, +}; -/// Fail-closed errors while parsing and correlating one bounded WebDriver BiDi response document. +/// Fail-closed errors while parsing, correlating, and admitting one bounded WebDriver BiDi +/// `locateNodes` response document. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebDriverBiDiLocateNodesResponseDocumentError { /// The bounded document failed complete WebDriver BiDi response-envelope parsing. Parse(WebDriverBiDiResponseEnvelopeParseError), - /// The parsed envelope failed exact command correlation. + /// The parsed envelope failed exact command correlation or success-only conversion. Envelope(WebDriverBiDiLocateNodesResponseEnvelopeError), + /// The correlated success result omitted its required `nodes` field. + MissingResultNodes, + /// The correlated success result's `nodes` field was not a JSON array. + InvalidResultNodes, + /// The correlated success result repeated the decoded `nodes` field. + DuplicateResultNodes, + /// One `nodes` array item was not a JSON object. + InvalidResultNode, + /// One node object repeated decoded `type` or `sharedId` authority-relevant metadata. + DuplicateResultNodeField, + /// One node object omitted its required WebDriver BiDi remote-value `type` field. + MissingResultNodeType, + /// One node object's `type` field was not a JSON string. + InvalidResultNodeType, + /// One present node `sharedId` field was not a JSON string. + InvalidResultNodeSharedId, + /// Exact command-budget or remote-node admission rejected the wire-derived node batch. + ResultAdmission(WebDriverBiDiLocateNodesResultAdmissionError), + /// A second-pass result parser invariant failed after complete envelope parsing succeeded. + ResultParserInvariant, } impl Display for WebDriverBiDiLocateNodesResponseDocumentError { @@ -28,6 +54,32 @@ impl Display for WebDriverBiDiLocateNodesResponseDocumentError { formatter, "WebDriver BiDi response document rejected command correlation: {error}" ), + Self::MissingResultNodes => { + formatter.write_str("WebDriver BiDi locateNodes result is missing its nodes field") + } + Self::InvalidResultNodes => formatter + .write_str("WebDriver BiDi locateNodes result nodes field is not a JSON array"), + Self::DuplicateResultNodes => formatter.write_str( + "WebDriver BiDi locateNodes result contains duplicate decoded nodes fields", + ), + Self::InvalidResultNode => formatter + .write_str("WebDriver BiDi locateNodes result contains a non-object node item"), + Self::DuplicateResultNodeField => formatter.write_str( + "WebDriver BiDi locateNodes node contains duplicate authority-relevant fields", + ), + Self::MissingResultNodeType => formatter + .write_str("WebDriver BiDi locateNodes node is missing its remote-value type"), + Self::InvalidResultNodeType => formatter + .write_str("WebDriver BiDi locateNodes node type is not a JSON string"), + Self::InvalidResultNodeSharedId => formatter + .write_str("WebDriver BiDi locateNodes node sharedId is not a JSON string"), + Self::ResultAdmission(error) => write!( + formatter, + "WebDriver BiDi locateNodes wire result rejected node admission: {error}" + ), + Self::ResultParserInvariant => formatter.write_str( + "WebDriver BiDi locateNodes result parser invariant failed after envelope validation", + ), } } } @@ -37,6 +89,16 @@ impl Error for WebDriverBiDiLocateNodesResponseDocumentError { match self { Self::Parse(error) => Some(error), Self::Envelope(error) => Some(error), + Self::ResultAdmission(error) => Some(error), + Self::MissingResultNodes + | Self::InvalidResultNodes + | Self::DuplicateResultNodes + | Self::InvalidResultNode + | Self::DuplicateResultNodeField + | Self::MissingResultNodeType + | Self::InvalidResultNodeType + | Self::InvalidResultNodeSharedId + | Self::ResultParserInvariant => None, } } } @@ -63,4 +125,42 @@ impl WebDriverBiDiLocateNodesCommand { self.correlate_response_envelope(parsed.kind(), parsed.response_id()) .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope) } + + /// Consume one bounded raw `locateNodes` response through exact wire-derived node admission. + /// + /// The same bounded document first passes the complete response-envelope parser, exact command + /// correlation, and success-only conversion. Only then does the result parser derive the exact + /// `result.nodes` array from that already-validated wire document. Decoded duplicate `nodes`, + /// `type`, or `sharedId` fields fail closed, JSON-escaped protocol metadata is decoded before + /// admission, and the existing correlated-result boundary enforces the command's exact + /// `maxNodeCount` before normalizing node references. Callers cannot supply replacement node + /// metadata to this method. + /// + /// Success remains untrusted transport evidence. It does not authenticate Chromium, + /// ChromeDriver, WebSocket/TLS provenance, or an adapter process; prove current + /// session/context/origin/document authority; mint OriginWeave node handles; authorize policy + /// or typed input; execute browser I/O; or prove a post-condition. + pub fn admit_response_document_nodes( + self, + document: BoundedWebDriverBiDiResponseDocument, + ) -> Result + { + let parsed = document + .parse_command_response() + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; + let correlated = self + .correlate_response_envelope(parsed.kind(), parsed.response_id()) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; + let validated = correlated + .into_validated_success() + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; + let wire_nodes = locate_nodes_result_document::parse_wire_locate_nodes_result(parsed.as_str())?; + let admission_parts = wire_nodes + .iter() + .map(locate_nodes_result_document::WireLocateNodesNode::as_admission_parts) + .collect::>(); + validated + .admit_result_nodes(&admission_parts) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission) + } } diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs new file mode 100644 index 000000000..b5c46b3ba --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -0,0 +1,455 @@ +use super::WebDriverBiDiLocateNodesResponseDocumentError; + +pub(super) struct WireLocateNodesNode { + remote_type: String, + shared_id: Option, +} + +impl WireLocateNodesNode { + pub(super) fn as_admission_parts(&self) -> (&str, Option<&str>) { + (self.remote_type.as_str(), self.shared_id.as_deref()) + } +} + +pub(super) fn parse_wire_locate_nodes_result( + input: &str, +) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + ResultParser::new(input).parse() +} + +struct ResultParser<'input> { + input: &'input [u8], + position: usize, +} + +impl<'input> ResultParser<'input> { + const fn new(input: &'input str) -> Self { + Self { + input: input.as_bytes(), + position: 0, + } + } + + fn parse( + mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + self.skip_whitespace(); + self.expect_byte(b'{')?; + self.skip_whitespace(); + + loop { + if self.peek_byte() == Some(b'}') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + if field_name == "result" { + return self.parse_result_object(); + } + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn parse_result_object( + &mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(b'{') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes); + } + self.position += 1; + self.skip_whitespace(); + let mut nodes = None; + + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Err(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes); + } + + loop { + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + if field_name == "nodes" { + if nodes.is_some() { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes); + } + nodes = Some(self.parse_nodes_array()?); + } else { + self.skip_value()?; + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + + nodes.ok_or(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes) + } + + fn parse_nodes_array( + &mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(b'[') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes); + } + self.position += 1; + self.skip_whitespace(); + let mut nodes = Vec::new(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(nodes); + } + + loop { + nodes.push(self.parse_node()?); + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(nodes); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn parse_node( + &mut self, + ) -> Result { + if self.peek_byte() != Some(b'{') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode); + } + self.position += 1; + self.skip_whitespace(); + let mut remote_type = None; + let mut shared_id = None; + let mut shared_id_seen = false; + + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Err(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType); + } + + loop { + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + match field_name.as_str() { + "type" => { + if remote_type.is_some() { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ); + } + if self.peek_byte() != Some(b'"') { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + ); + } + remote_type = Some(self.parse_string()?); + } + "sharedId" => { + if shared_id_seen { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ); + } + shared_id_seen = true; + if self.peek_byte() != Some(b'"') { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + ); + } + shared_id = Some(self.parse_string()?); + } + _ => self.skip_value()?, + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + + Ok(WireLocateNodesNode { + remote_type: remote_type + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType)?, + shared_id, + }) + } + + fn skip_value(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + match self.peek_byte() { + Some(b'{') => self.skip_object(), + Some(b'[') => self.skip_array(), + Some(b'"') => { + let _value = self.parse_string()?; + Ok(()) + } + Some(b'-' | b'0'..=b'9') => self.skip_number(), + Some(b't') => self.skip_literal(b"true"), + Some(b'f') => self.skip_literal(b"false"), + Some(b'n') => self.skip_literal(b"null"), + _ => Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant), + } + } + + fn skip_object(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + self.expect_byte(b'{')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Ok(()); + } + loop { + let _field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + return Ok(()); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn skip_array(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + self.expect_byte(b'[')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(()); + } + loop { + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(()); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn skip_number(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let start = self.position; + while let Some(byte) = self.peek_byte() { + if matches!(byte, b',' | b']' | b'}' | b' ' | b'\t' | b'\r' | b'\n') { + break; + } + self.position += 1; + } + if self.position == start { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + Ok(()) + } + + fn skip_literal( + &mut self, + literal: &[u8], + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + for expected in literal { + self.expect_byte(*expected)?; + } + Ok(()) + } + + fn parse_string(&mut self) -> Result { + self.expect_byte(b'"')?; + let mut decoded = Vec::new(); + loop { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + match byte { + b'"' => { + self.position += 1; + return String::from_utf8(decoded).map_err(|_error| { + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant + }); + } + b'\\' => { + self.position += 1; + self.parse_escape(&mut decoded)?; + } + 0x00..=0x1f => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + _ => { + decoded.push(byte); + self.position += 1; + } + } + } + } + + fn parse_escape( + &mut self, + decoded: &mut Vec, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let escaped = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + self.position += 1; + match escaped { + b'"' => decoded.push(b'"'), + b'\\' => decoded.push(b'\\'), + b'/' => decoded.push(b'/'), + b'b' => decoded.push(0x08), + b'f' => decoded.push(0x0c), + b'n' => decoded.push(b'\n'), + b'r' => decoded.push(b'\r'), + b't' => decoded.push(b'\t'), + b'u' => self.parse_unicode_escape(decoded)?, + _ => return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant), + } + Ok(()) + } + + fn parse_unicode_escape( + &mut self, + decoded: &mut Vec, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let first = self.parse_hex_quad()?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + self.expect_byte(b'\\')?; + self.expect_byte(b'u')?; + let second = self.parse_hex_quad()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)) + } else { + if (0xdc00..=0xdfff).contains(&first) { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + u32::from(first) + }; + let character = char::from_u32(scalar) + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + let mut encoded = [0_u8; 4]; + decoded.extend_from_slice(character.encode_utf8(&mut encoded).as_bytes()); + Ok(()) + } + + fn parse_hex_quad(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + self.position += 1; + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a' + 10), + b'A'..=b'F' => u16::from(byte - b'A' + 10), + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + }; + value = (value << 4) | digit; + } + Ok(value) + } + + fn expect_byte( + &mut self, + expected: u8, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(expected) { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + self.position += 1; + Ok(()) + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) { + self.position += 1; + } + } + + fn peek_byte(&self) -> Option { + self.input.get(self.position).copied() + } +} From 2f12879694638b8d503be7d0b392a48dff5d880b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:42:42 -0700 Subject: [PATCH 135/229] style(core): apply canonical response document formatting --- .../src/webdriver_bidi_response_document_correlation.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 570ec9ddc..71092b616 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -143,8 +143,10 @@ impl WebDriverBiDiLocateNodesCommand { pub fn admit_response_document_nodes( self, document: BoundedWebDriverBiDiResponseDocument, - ) -> Result - { + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesResponseDocumentError, + > { let parsed = document .parse_command_response() .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; @@ -154,7 +156,8 @@ impl WebDriverBiDiLocateNodesCommand { let validated = correlated .into_validated_success() .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; - let wire_nodes = locate_nodes_result_document::parse_wire_locate_nodes_result(parsed.as_str())?; + let wire_nodes = + locate_nodes_result_document::parse_wire_locate_nodes_result(parsed.as_str())?; let admission_parts = wire_nodes .iter() .map(locate_nodes_result_document::WireLocateNodesNode::as_admission_parts) From c8d6d9a39601cb4ad6aaa0e0e731b9c328cb1f9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:43:25 -0700 Subject: [PATCH 136/229] style(core): apply canonical wire node parser formatting --- .../locate_nodes_result_document.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs index b5c46b3ba..5e61b408c 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -91,7 +91,9 @@ impl<'input> ResultParser<'input> { self.skip_whitespace(); if field_name == "nodes" { if nodes.is_some() { - return Err(WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes); + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + ); } nodes = Some(self.parse_nodes_array()?); } else { From e7b81bc6d5a20d7b6503c9cd397984197b765780 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:13:01 -0700 Subject: [PATCH 137/229] test(core): cover locateNodes document failure boundaries --- ...webdriver_bidi_locate_nodes_wire_result.rs | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index 349d7912f..9262ea8a2 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -2,7 +2,7 @@ use std::error::Error; use originweave_core::{ BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, }; fn locate_nodes_command( @@ -123,3 +123,65 @@ fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result assert_eq!(admitted.nodes()[0].shared_id(), "node-α"); Ok(()) } + +#[test] +fn wire_result_boundary_preserves_parse_correlation_and_success_only_failures() +-> Result<(), Box> { + let malformed = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{},}"#, + )?; + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(malformed), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Parse(_)) + )); + + let mismatched = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[]}}"#, + )?; + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(mismatched), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope(_)) + )); + + let error_response = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":42,"error":"invalid argument","message":"bad request"}"#, + )?; + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(error_response), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope(_)) + )); + Ok(()) +} + +#[test] +fn wire_result_document_error_display_and_sources_cover_result_failure_variants() +-> Result<(), Box> { + let source_free = [ + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ]; + for error in source_free { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + + let over_budget = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b"}]}}"#, + )?; + let result = locate_nodes_command(42, 1)?.admit_response_document_nodes(over_budget); + match result { + Err(error @ WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) => { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); + } + _ => panic!("over-budget wire result must preserve result-admission error evidence"), + } + Ok(()) +} From 16591aba4627520984a8f1125f402f4137469e5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:13:49 -0700 Subject: [PATCH 138/229] test(core): keep failure-evidence regressions clippy-clean --- .../webdriver_bidi_locate_nodes_wire_result.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index 9262ea8a2..db9504f6c 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -176,12 +176,15 @@ fn wire_result_document_error_display_and_sources_cover_result_failure_variants( r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b"}]}}"#, )?; let result = locate_nodes_command(42, 1)?.admit_response_document_nodes(over_budget); - match result { - Err(error @ WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) => { - assert!(!error.to_string().is_empty()); - assert!(error.source().is_some()); + let error = match result { + Err(error @ WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) => error, + _ => { + return Err( + "over-budget wire result must preserve result-admission error evidence".into(), + ); } - _ => panic!("over-budget wire result must preserve result-admission error evidence"), - } + }; + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); Ok(()) } From 858bcf4f1f9f1071c3d19c762d8895d38082fd6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:15:14 -0700 Subject: [PATCH 139/229] test(core): cover locateNodes second-pass parser invariants --- .../locate_nodes_result_document.rs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs index 5e61b408c..d03542926 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -455,3 +455,182 @@ impl<'input> ResultParser<'input> { self.input.get(self.position).copied() } } + +#[cfg(test)] +mod tests { + use super::*; + + const INVARIANT: WebDriverBiDiLocateNodesResponseDocumentError = + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant; + + #[test] + fn second_pass_parser_covers_valid_skipped_value_and_escape_shapes() { + let raw = concat!( + " \n{\t\"metadata\": [true,false,null,{\"a\":1,\"b\":2}],", + "\"result\":{", + "\"emptyObject\":{},\"emptyArray\":[],", + "\"object\":{\"first\":1,\"second\":2},", + "\"array\":[true,false,null],", + "\"escaped\":\"\\\"\\\\\\/\\b\\f\\n\\r\\t\",", + "\"number\":-1.25e+2,\"truth\":true,\"falsehood\":false,\"nothing\":null,", + "\"nodes\":[{", + "\"ignored\":{\"nested\":[1,2]},", + "\"type\":\"no\\u0064e\",", + "\"sharedId\":\"node-\\u0041-\\u00E9-\\u263A-\\uD83D\\uDE00-\\u00af-\\u00AF\"", + "}]}}" + ); + let nodes = parse_wire_locate_nodes_result(raw) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].remote_type, "node"); + assert_eq!(nodes[0].shared_id.as_deref(), Some("node-A-é-☺-😀-¯-¯")); + } + + #[test] + fn second_pass_parser_rejects_structural_and_typed_result_faults() { + let cases = [ + ("", INVARIANT), + ("{}", INVARIANT), + (r#"{"metadata":0}"#, INVARIANT), + (r#"{"metadata":0]"#, INVARIANT), + (r#"{"metadata" 0,"result":{"nodes":[]}}"#, INVARIANT), + (r#"{metadata:0,"result":{"nodes":[]}}"#, INVARIANT), + ( + r#"{"result":[]}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + ), + ( + r#"{"result":{}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ), + ( + r#"{"result":{"other":0}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ), + ( + r#"{"result":{"nodes":0}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + ), + ( + r#"{"result":{"nodes":[0]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode, + ), + ( + r#"{"result":{"nodes":[{}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"sharedId":"node-a"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"type":0}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":0}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + ), + ( + r#"{"result":{"nodes":[],"nodes":[]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + ), + ( + r#"{"result":{"nodes":[{"type":"node","type":"node"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":"a","sharedId":"b"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ), + (r#"{"result":{"nodes":[] "other":0}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type":"node"} 0]}}"#, INVARIANT), + ( + r#"{"result":{"nodes":[{"type":"node" "sharedId":"node-a"}]}}"#, + INVARIANT, + ), + ]; + + for (raw, expected) in cases { + assert_eq!(parse_wire_locate_nodes_result(raw), Err(expected)); + } + } + + #[test] + fn skip_helpers_cover_empty_nonempty_and_malformed_containers() { + let mut empty_object = ResultParser::new("{}"); + assert_eq!(empty_object.skip_object(), Ok(())); + + let mut object = ResultParser::new(r#"{"a":0,"b":1}"#); + assert_eq!(object.skip_object(), Ok(())); + + let mut malformed_object = ResultParser::new(r#"{"a":0 "b":1}"#); + assert_eq!(malformed_object.skip_object(), Err(INVARIANT)); + + let mut empty_array = ResultParser::new("[]"); + assert_eq!(empty_array.skip_array(), Ok(())); + + let mut array = ResultParser::new("[0,1]"); + assert_eq!(array.skip_array(), Ok(())); + + let mut malformed_array = ResultParser::new("[0 1]"); + assert_eq!(malformed_array.skip_array(), Err(INVARIANT)); + + let mut unknown = ResultParser::new("?"); + assert_eq!(unknown.skip_value(), Err(INVARIANT)); + + let mut empty_number = ResultParser::new(""); + assert_eq!(empty_number.skip_number(), Err(INVARIANT)); + + let mut terminal_number = ResultParser::new("123"); + assert_eq!(terminal_number.skip_number(), Ok(())); + assert_eq!(terminal_number.peek_byte(), None); + + let mut truncated_literal = ResultParser::new("tru"); + assert_eq!(truncated_literal.skip_literal(b"true"), Err(INVARIANT)); + } + + #[test] + fn string_decoder_rejects_all_second_pass_escape_invariants() { + let mut missing_open_quote = ResultParser::new("plain"); + assert_eq!(missing_open_quote.parse_string(), Err(INVARIANT)); + + let mut unterminated = ResultParser::new("\"plain"); + assert_eq!(unterminated.parse_string(), Err(INVARIANT)); + + let control_bytes = [b'\"', 0x01, b'\"']; + let mut control = ResultParser { + input: &control_bytes, + position: 0, + }; + assert_eq!(control.parse_string(), Err(INVARIANT)); + + let invalid_utf8_bytes = [b'\"', 0xff, b'\"']; + let mut invalid_utf8 = ResultParser { + input: &invalid_utf8_bytes, + position: 0, + }; + assert_eq!(invalid_utf8.parse_string(), Err(INVARIANT)); + + let mut missing_escape = ResultParser::new("\"\\"); + assert_eq!(missing_escape.parse_string(), Err(INVARIANT)); + + let mut invalid_escape = ResultParser::new(r#""\x""#); + assert_eq!(invalid_escape.parse_string(), Err(INVARIANT)); + + for raw in [ + r#""\uD83D""#, + r#""\uD83D\x0000""#, + r#""\uD83D\u0041""#, + r#""\uDE00""#, + r#""\u12""#, + r#""\u00G0""#, + ] { + let mut parser = ResultParser::new(raw); + assert_eq!(parser.parse_string(), Err(INVARIANT)); + } + } +} From da7ffe9334f2eb957bc2b2fb3b53e72eddd3837e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:09:07 -0700 Subject: [PATCH 140/229] test(core): avoid node debug equality requirement --- .../locate_nodes_result_document.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs index d03542926..622ab15b8 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -555,7 +555,10 @@ mod tests { ]; for (raw, expected) in cases { - assert_eq!(parse_wire_locate_nodes_result(raw), Err(expected)); + assert!(matches!( + parse_wire_locate_nodes_result(raw), + Err(error) if error == expected + )); } } From 1b0f6ada0f789d9cd7cba6eebc154f0ed5272195 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:09:41 -0700 Subject: [PATCH 141/229] style(core): apply canonical wire-result formatting --- .../tests/webdriver_bidi_locate_nodes_wire_result.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index db9504f6c..fcc1ba946 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -127,9 +127,8 @@ fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result #[test] fn wire_result_boundary_preserves_parse_correlation_and_success_only_failures() -> Result<(), Box> { - let malformed = BoundedWebDriverBiDiResponseDocument::new( - r#"{"type":"success","id":42,"result":{},}"#, - )?; + let malformed = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":42,"result":{},}"#)?; assert!(matches!( locate_nodes_command(42, 1)?.admit_response_document_nodes(malformed), Err(WebDriverBiDiLocateNodesResponseDocumentError::Parse(_)) From cbbc4d6661d0f78ef85d2dc991232671a1c0a0f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:19:40 -0700 Subject: [PATCH 142/229] test(core): cover second-pass parser failure propagation --- .../locate_nodes_result_document.rs | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs index 622ab15b8..3ee6af71b 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -400,9 +400,6 @@ impl<'input> ResultParser<'input> { } 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)) } else { - if (0xdc00..=0xdfff).contains(&first) { - return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); - } u32::from(first) }; let character = char::from_u32(scalar) @@ -552,6 +549,21 @@ mod tests { r#"{"result":{"nodes":[{"type":"node" "sharedId":"node-a"}]}}"#, INVARIANT, ), + (r#"{"metadata":?,"result":{"nodes":[]}}"#, INVARIANT), + (r#"{"result":{?}}"#, INVARIANT), + (r#"{"result":{"nodes" []}}"#, INVARIANT), + (r#"{"result":{"other":?,"nodes":[]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{?}]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type" "node"}]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type":"\x"}]}}"#, INVARIANT), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":"\x"}]}}"#, + INVARIANT, + ), + ( + r#"{"result":{"nodes":[{"ignored":?,"type":"node"}]}}"#, + INVARIANT, + ), ]; for (raw, expected) in cases { @@ -592,6 +604,27 @@ mod tests { assert_eq!(terminal_number.skip_number(), Ok(())); assert_eq!(terminal_number.peek_byte(), None); + let mut malformed_string_value = ResultParser::new(r#""\x""#); + assert_eq!(malformed_string_value.skip_value(), Err(INVARIANT)); + + let mut wrong_object_opener = ResultParser::new("[]"); + assert_eq!(wrong_object_opener.skip_object(), Err(INVARIANT)); + + let mut malformed_object_key = ResultParser::new("{?}"); + assert_eq!(malformed_object_key.skip_object(), Err(INVARIANT)); + + let mut missing_object_colon = ResultParser::new(r#"{"a" 0}"#); + assert_eq!(missing_object_colon.skip_object(), Err(INVARIANT)); + + let mut malformed_object_value = ResultParser::new(r#"{"a":?}"#); + assert_eq!(malformed_object_value.skip_object(), Err(INVARIANT)); + + let mut wrong_array_opener = ResultParser::new("{}"); + assert_eq!(wrong_array_opener.skip_array(), Err(INVARIANT)); + + let mut malformed_array_value = ResultParser::new("[?]"); + assert_eq!(malformed_array_value.skip_array(), Err(INVARIANT)); + let mut truncated_literal = ResultParser::new("tru"); assert_eq!(truncated_literal.skip_literal(b"true"), Err(INVARIANT)); } @@ -628,8 +661,10 @@ mod tests { r#""\uD83D""#, r#""\uD83D\x0000""#, r#""\uD83D\u0041""#, + "\"\\uD83D\\u12", r#""\uDE00""#, r#""\u12""#, + "\"\\u12", r#""\u00G0""#, ] { let mut parser = ResultParser::new(raw); From 755ba0682c0242005a1d668a700a975195160310 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:17:16 -0700 Subject: [PATCH 143/229] fix(core): keep second-pass string decoding utf8-safe --- .../locate_nodes_result_document.rs | 62 +++++++------------ 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs index 3ee6af71b..242b2a081 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -18,16 +18,13 @@ pub(super) fn parse_wire_locate_nodes_result( } struct ResultParser<'input> { - input: &'input [u8], + input: &'input str, position: usize, } impl<'input> ResultParser<'input> { const fn new(input: &'input str) -> Self { - Self { - input: input.as_bytes(), - position: 0, - } + Self { input, position: 0 } } fn parse( @@ -334,21 +331,23 @@ impl<'input> ResultParser<'input> { fn parse_string(&mut self) -> Result { self.expect_byte(b'"')?; - let mut decoded = Vec::new(); + let mut decoded = String::new(); + let mut literal_start = self.position; loop { let byte = self .peek_byte() .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; match byte { b'"' => { + decoded.push_str(&self.input[literal_start..self.position]); self.position += 1; - return String::from_utf8(decoded).map_err(|_error| { - WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant - }); + return Ok(decoded); } b'\\' => { + decoded.push_str(&self.input[literal_start..self.position]); self.position += 1; self.parse_escape(&mut decoded)?; + literal_start = self.position; } 0x00..=0x1f => { return Err( @@ -356,7 +355,6 @@ impl<'input> ResultParser<'input> { ); } _ => { - decoded.push(byte); self.position += 1; } } @@ -365,21 +363,21 @@ impl<'input> ResultParser<'input> { fn parse_escape( &mut self, - decoded: &mut Vec, + decoded: &mut String, ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { let escaped = self .peek_byte() .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; self.position += 1; match escaped { - b'"' => decoded.push(b'"'), - b'\\' => decoded.push(b'\\'), - b'/' => decoded.push(b'/'), - b'b' => decoded.push(0x08), - b'f' => decoded.push(0x0c), - b'n' => decoded.push(b'\n'), - b'r' => decoded.push(b'\r'), - b't' => decoded.push(b'\t'), + b'"' => decoded.push('"'), + b'\\' => decoded.push('\\'), + b'/' => decoded.push('/'), + b'b' => decoded.push('\u{0008}'), + b'f' => decoded.push('\u{000c}'), + b'n' => decoded.push('\n'), + b'r' => decoded.push('\r'), + b't' => decoded.push('\t'), b'u' => self.parse_unicode_escape(decoded)?, _ => return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant), } @@ -388,7 +386,7 @@ impl<'input> ResultParser<'input> { fn parse_unicode_escape( &mut self, - decoded: &mut Vec, + decoded: &mut String, ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { let first = self.parse_hex_quad()?; let scalar = if (0xd800..=0xdbff).contains(&first) { @@ -404,8 +402,7 @@ impl<'input> ResultParser<'input> { }; let character = char::from_u32(scalar) .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; - let mut encoded = [0_u8; 4]; - decoded.extend_from_slice(character.encode_utf8(&mut encoded).as_bytes()); + decoded.push(character); Ok(()) } @@ -449,7 +446,7 @@ impl<'input> ResultParser<'input> { } fn peek_byte(&self) -> Option { - self.input.get(self.position).copied() + self.input.as_bytes().get(self.position).copied() } } @@ -469,6 +466,7 @@ mod tests { "\"object\":{\"first\":1,\"second\":2},", "\"array\":[true,false,null],", "\"escaped\":\"\\\"\\\\\\/\\b\\f\\n\\r\\t\",", + "\"utf8\":\"é\",", "\"number\":-1.25e+2,\"truth\":true,\"falsehood\":false,\"nothing\":null,", "\"nodes\":[{", "\"ignored\":{\"nested\":[1,2]},", @@ -567,10 +565,7 @@ mod tests { ]; for (raw, expected) in cases { - assert!(matches!( - parse_wire_locate_nodes_result(raw), - Err(error) if error == expected - )); + assert_eq!(parse_wire_locate_nodes_result(raw).err(), Some(expected)); } } @@ -637,20 +632,9 @@ mod tests { let mut unterminated = ResultParser::new("\"plain"); assert_eq!(unterminated.parse_string(), Err(INVARIANT)); - let control_bytes = [b'\"', 0x01, b'\"']; - let mut control = ResultParser { - input: &control_bytes, - position: 0, - }; + let mut control = ResultParser::new("\"\u{0001}\""); assert_eq!(control.parse_string(), Err(INVARIANT)); - let invalid_utf8_bytes = [b'\"', 0xff, b'\"']; - let mut invalid_utf8 = ResultParser { - input: &invalid_utf8_bytes, - position: 0, - }; - assert_eq!(invalid_utf8.parse_string(), Err(INVARIANT)); - let mut missing_escape = ResultParser::new("\"\\"); assert_eq!(missing_escape.parse_string(), Err(INVARIANT)); From 70d85f97a30d5f661fe47fbf19bc4589be11c39a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:12:24 -0700 Subject: [PATCH 144/229] test(core): cover wire node admission tuple --- ...iver_bidi_response_document_correlation.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 71092b616..c121cb434 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -167,3 +167,26 @@ impl WebDriverBiDiLocateNodesCommand { .map_err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission) } } + +#[cfg(test)] +mod tests { + use super::locate_nodes_result_document::{ + parse_wire_locate_nodes_result, WireLocateNodesNode, + }; + + #[test] + fn wire_node_admission_parts_preserve_wire_type_and_shared_id() { + let parsed = parse_wire_locate_nodes_result( + r#"{"result":{"nodes":[{"type":"node","sharedId":"node-17"}]}}"#, + ); + assert!(parsed.is_ok()); + + if let Ok(nodes) = parsed { + let admission_parts = nodes + .iter() + .map(WireLocateNodesNode::as_admission_parts) + .collect::>(); + assert_eq!(admission_parts, vec![("node", Some("node-17"))]); + } + } +} From 4089a727abe4c74057cf5dd1b6ccb55a0d7c7303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:20:41 -0700 Subject: [PATCH 145/229] test(core): cover skipped wire result shapes --- ...iver_bidi_response_document_correlation.rs | 23 ------------------- ...webdriver_bidi_locate_nodes_wire_result.rs | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) 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 c121cb434..71092b616 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -167,26 +167,3 @@ impl WebDriverBiDiLocateNodesCommand { .map_err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission) } } - -#[cfg(test)] -mod tests { - use super::locate_nodes_result_document::{ - parse_wire_locate_nodes_result, WireLocateNodesNode, - }; - - #[test] - fn wire_node_admission_parts_preserve_wire_type_and_shared_id() { - let parsed = parse_wire_locate_nodes_result( - r#"{"result":{"nodes":[{"type":"node","sharedId":"node-17"}]}}"#, - ); - assert!(parsed.is_ok()); - - if let Ok(nodes) = parsed { - let admission_parts = nodes - .iter() - .map(WireLocateNodesNode::as_admission_parts) - .collect::>(); - assert_eq!(admission_parts, vec![("node", Some("node-17"))]); - } - } -} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index fcc1ba946..4648e1a8d 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -22,7 +22,7 @@ fn locate_nodes_command( fn bounded_locate_nodes_document_admits_exact_wire_nodes_without_caller_selected_payload() -> Result<(), Box> { let document = BoundedWebDriverBiDiResponseDocument::new( - r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b","value":{"nodeType":1}}]}}"#, + r#"{"type":"success","id":42,"result":{"ignored":[true,false,null],"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b","value":{"nodeType":1}}]}}"#, )?; let admitted = locate_nodes_command(42, 2)?.admit_response_document_nodes(document)?; From 14a4a39b87bb655bda2398a03cca436a7ed213a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:12:31 -0700 Subject: [PATCH 146/229] test(core): cover wire node admission metadata --- ...iver_bidi_response_document_correlation.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 71092b616..339ad9aff 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -167,3 +167,25 @@ impl WebDriverBiDiLocateNodesCommand { .map_err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission) } } + +#[cfg(test)] +mod tests { + use super::locate_nodes_result_document::parse_wire_locate_nodes_result; + + #[test] + fn wire_node_admission_parts_preserve_wire_derived_metadata() { + let nodes = parse_wire_locate_nodes_result(concat!( + "{\"result\":{\"nodes\":[", + "{\"type\":\"node\",\"sharedId\":\"shared-1\"},", + "{\"type\":\"window\"}", + "]}}" + )) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].as_admission_parts(), ("node", Some("shared-1"))); + assert_eq!(nodes[1].as_admission_parts(), ("window", None)); + } +} From a2fe264cf627536e5502ad8a31f45080b471785a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:09:02 -0700 Subject: [PATCH 147/229] test(core): prioritize locateNodes result budget --- .../webdriver_bidi_locate_nodes_wire_result.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index 4648e1a8d..ce5d7bcd3 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -50,6 +50,19 @@ fn wire_result_preserves_exact_command_node_budget() -> Result<(), Box Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":1,"sharedId":"malformed-overflow-item"}]}}"#, + )?; + + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(document), + Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) + )); + Ok(()) +} + #[test] fn wire_result_rejects_missing_shared_id() -> Result<(), Box> { let document = BoundedWebDriverBiDiResponseDocument::new( From 3083e4eb8490c23e5f0c31973b89c26b11712c8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:14:38 -0700 Subject: [PATCH 148/229] fix(core): enforce locateNodes budget before node decoding --- .../locate_nodes_result_document.rs | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs index 242b2a081..409d0f6f8 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -9,22 +9,51 @@ impl WireLocateNodesNode { pub(super) fn as_admission_parts(&self) -> (&str, Option<&str>) { (self.remote_type.as_str(), self.shared_id.as_deref()) } + + fn overflow_count_marker() -> Self { + Self { + remote_type: String::new(), + shared_id: None, + } + } } +#[cfg(test)] pub(super) fn parse_wire_locate_nodes_result( input: &str, ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { ResultParser::new(input).parse() } +pub(super) fn parse_wire_locate_nodes_result_bounded( + input: &str, + max_node_count: u16, +) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + ResultParser::with_node_budget(input, usize::from(max_node_count)).parse() +} + struct ResultParser<'input> { input: &'input str, position: usize, + max_node_count: Option, } impl<'input> ResultParser<'input> { + #[cfg(test)] const fn new(input: &'input str) -> Self { - Self { input, position: 0 } + Self { + input, + position: 0, + max_node_count: None, + } + } + + const fn with_node_budget(input: &'input str, max_node_count: usize) -> Self { + Self { + input, + position: 0, + max_node_count: Some(max_node_count), + } } fn parse( @@ -126,13 +155,25 @@ impl<'input> ResultParser<'input> { self.position += 1; self.skip_whitespace(); let mut nodes = Vec::new(); + let mut over_budget = false; if self.peek_byte() == Some(b']') { self.position += 1; return Ok(nodes); } loop { - nodes.push(self.parse_node()?); + let at_node_budget = self + .max_node_count + .is_some_and(|max_node_count| nodes.len() >= max_node_count); + if over_budget || at_node_budget { + self.skip_value()?; + if !over_budget { + nodes.push(WireLocateNodesNode::overflow_count_marker()); + over_budget = true; + } + } else { + nodes.push(self.parse_node()?); + } self.skip_whitespace(); match self.peek_byte() { Some(b',') => { @@ -569,6 +610,21 @@ mod tests { } } + #[test] + fn bounded_parser_uses_one_count_marker_and_skips_overflow_node_shapes() { + let nodes = parse_wire_locate_nodes_result_bounded( + r#"{"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":1},{"type":2}]}}"#, + 1, + ) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].remote_type, "node"); + assert_eq!(nodes[1].as_admission_parts(), ("", None)); + } + #[test] fn skip_helpers_cover_empty_nonempty_and_malformed_containers() { let mut empty_object = ResultParser::new("{}"); From 6c3b0bdf427601c82ee864e43e96b2b105c0d718 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:15:21 -0700 Subject: [PATCH 149/229] fix(core): bind wire parsing to exact result budget --- ...iver_bidi_response_document_correlation.rs | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) 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 339ad9aff..703049543 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -27,15 +27,15 @@ pub enum WebDriverBiDiLocateNodesResponseDocumentError { InvalidResultNodes, /// The correlated success result repeated the decoded `nodes` field. DuplicateResultNodes, - /// One `nodes` array item was not a JSON object. + /// One in-budget `nodes` array item was not a JSON object. InvalidResultNode, - /// One node object repeated decoded `type` or `sharedId` authority-relevant metadata. + /// One in-budget node object repeated decoded `type` or `sharedId` authority-relevant metadata. DuplicateResultNodeField, - /// One node object omitted its required WebDriver BiDi remote-value `type` field. + /// One in-budget node object omitted its required WebDriver BiDi remote-value `type` field. MissingResultNodeType, - /// One node object's `type` field was not a JSON string. + /// One in-budget node object's `type` field was not a JSON string. InvalidResultNodeType, - /// One present node `sharedId` field was not a JSON string. + /// One present in-budget node `sharedId` field was not a JSON string. InvalidResultNodeSharedId, /// Exact command-budget or remote-node admission rejected the wire-derived node batch. ResultAdmission(WebDriverBiDiLocateNodesResultAdmissionError), @@ -130,11 +130,12 @@ impl WebDriverBiDiLocateNodesCommand { /// /// The same bounded document first passes the complete response-envelope parser, exact command /// correlation, and success-only conversion. Only then does the result parser derive the exact - /// `result.nodes` array from that already-validated wire document. Decoded duplicate `nodes`, - /// `type`, or `sharedId` fields fail closed, JSON-escaped protocol metadata is decoded before - /// admission, and the existing correlated-result boundary enforces the command's exact - /// `maxNodeCount` before normalizing node references. Callers cannot supply replacement node - /// metadata to this method. + /// `result.nodes` array from that already-validated wire document. The command's exact + /// `maxNodeCount` is carried into this parser so overflow items are consumed only as generic JSON + /// and produce the existing result-budget failure before authority-relevant node metadata is + /// decoded or normalized. Decoded duplicate `nodes`, and duplicate or malformed in-budget + /// `type`/`sharedId` fields, fail closed. JSON-escaped protocol metadata is decoded before + /// admission, and callers cannot supply replacement node metadata to this method. /// /// Success remains untrusted transport evidence. It does not authenticate Chromium, /// ChromeDriver, WebSocket/TLS provenance, or an adapter process; prove current @@ -156,8 +157,10 @@ impl WebDriverBiDiLocateNodesCommand { let validated = correlated .into_validated_success() .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; - let wire_nodes = - locate_nodes_result_document::parse_wire_locate_nodes_result(parsed.as_str())?; + let wire_nodes = locate_nodes_result_document::parse_wire_locate_nodes_result_bounded( + parsed.as_str(), + validated.max_node_count(), + )?; let admission_parts = wire_nodes .iter() .map(locate_nodes_result_document::WireLocateNodesNode::as_admission_parts) From 850dbf16cfe02021d8460b9f8dbb5a1d3f0e7e41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:26:15 -0700 Subject: [PATCH 150/229] test(core): cover bounded overflow parser invariant --- ...iver_bidi_response_document_correlation.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 703049543..7ee7c78ac 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -173,7 +173,10 @@ impl WebDriverBiDiLocateNodesCommand { #[cfg(test)] mod tests { - use super::locate_nodes_result_document::parse_wire_locate_nodes_result; + use super::locate_nodes_result_document::{ + parse_wire_locate_nodes_result, parse_wire_locate_nodes_result_bounded, + }; + use super::WebDriverBiDiLocateNodesResponseDocumentError; #[test] fn wire_node_admission_parts_preserve_wire_derived_metadata() { @@ -191,4 +194,21 @@ mod tests { assert_eq!(nodes[0].as_admission_parts(), ("node", Some("shared-1"))); assert_eq!(nodes[1].as_admission_parts(), ("window", None)); } + + #[test] + fn bounded_overflow_parser_preserves_invalid_generic_json_invariant() { + let result = parse_wire_locate_nodes_result_bounded( + concat!( + "{\"result\":{\"nodes\":[", + "{\"type\":\"node\",\"sharedId\":\"shared-1\"},", + "?]}}" + ), + 1, + ); + + assert!(matches!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant) + )); + } } From e373a73b22e24ebbf4755d2b7c63989fda18de01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:09:17 -0700 Subject: [PATCH 151/229] test(core): close bounded overflow coverage invariant --- .../webdriver_bidi_response_document_correlation.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 7ee7c78ac..84635fa18 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -173,10 +173,10 @@ impl WebDriverBiDiLocateNodesCommand { #[cfg(test)] mod tests { + use super::WebDriverBiDiLocateNodesResponseDocumentError; use super::locate_nodes_result_document::{ parse_wire_locate_nodes_result, parse_wire_locate_nodes_result_bounded, }; - use super::WebDriverBiDiLocateNodesResponseDocumentError; #[test] fn wire_node_admission_parts_preserve_wire_derived_metadata() { @@ -206,9 +206,9 @@ mod tests { 1, ); - assert!(matches!( - result, - Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant) - )); + assert_eq!( + result.err(), + Some(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant) + ); } } From dfc522f2ac3c2cc71108b6ea0fb7948b75c6cf12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:31:29 -0700 Subject: [PATCH 152/229] test(core): require bounded BiDi byte admission --- ...webdriver_bidi_response_document_budget.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs index 7909c4f28..8d440272e 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -14,6 +14,28 @@ fn bounded_response_document_retains_exact_wire_text() -> Result<(), Box Result<(), Box> { + let raw = b" \r\n{\"id\":42,\"type\":\"success\",\"result\":{}}\t"; + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(raw)?; + + assert_eq!(document.as_str(), std::str::from_utf8(raw)?); + + let invalid_utf8 = [b'{', 0xff, b'}']; + assert_eq!( + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(&invalid_utf8), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8) + ); + + let oversized_invalid_utf8 = vec![0xff; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + assert_eq!( + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(&oversized_invalid_utf8), + Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) + ); + Ok(()) +} + #[test] fn empty_or_json_whitespace_only_response_document_fails_closed() { for raw in ["", " ", "\t\r\n"] { @@ -67,6 +89,7 @@ fn response_document_errors_are_deterministic_and_source_free() { for error in [ WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument, WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary, ] { assert!(!error.to_string().is_empty()); From 42c0443569ba32c8c6ea4fcb712b45e8eec444a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:33:54 -0700 Subject: [PATCH 153/229] feat(core): admit bounded BiDi response bytes --- .../src/webdriver_bidi_response_document.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/originweave-core/src/webdriver_bidi_response_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document.rs index 4eacf84cb..3bb7a42c4 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document.rs @@ -15,6 +15,8 @@ pub enum WebDriverBiDiResponseDocumentAdmissionError { EmptyDocument, /// The raw response exceeds the OriginWeave pre-parser byte budget. DocumentTooLarge, + /// The raw response is not valid UTF-8. + InvalidUtf8, /// The first and last non-whitespace bytes do not delimit a JSON object. InvalidObjectBoundary, } @@ -27,6 +29,9 @@ impl fmt::Display for WebDriverBiDiResponseDocumentAdmissionError { formatter, "WebDriver BiDi response document exceeds {MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES} bytes" ), + Self::InvalidUtf8 => { + formatter.write_str("WebDriver BiDi response document is not valid UTF-8") + } Self::InvalidObjectBoundary => formatter.write_str( "WebDriver BiDi response document must have a top-level JSON object boundary", ), @@ -68,6 +73,23 @@ impl BoundedWebDriverBiDiResponseDocument { }) } + /// Admits raw transport bytes after bounding them and validating UTF-8. + /// + /// The byte budget is checked before UTF-8 validation or owned-text + /// allocation. This keeps hostile transport payloads outside the parser + /// boundary until both the resource and text-encoding contracts hold. + pub fn from_utf8_bytes( + raw: &[u8], + ) -> Result { + if raw.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES { + return Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge); + } + + let raw = std::str::from_utf8(raw) + .map_err(|_| WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8)?; + Self::new(raw) + } + /// Returns the exact admitted response text, including surrounding JSON whitespace. #[must_use] pub fn as_str(&self) -> &str { From c80ffdb05c923971d182242255a8f1f8b8cc1286 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:57:08 -0700 Subject: [PATCH 154/229] test(core): require typed BiDi error-code evidence --- ...river_bidi_response_error_code_evidence.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs new file mode 100644 index 000000000..e2761843a --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs @@ -0,0 +1,38 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode, +}; + +#[test] +fn parsed_error_envelope_retains_typed_error_code() -> Result<(), Box> { + for (raw_code, expected) in [ + ("invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + ( + "no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + "unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ] { + let raw = format!( + "{{\"type\":\"error\",\"id\":7,\"error\":\"{raw_code}\",\"message\":\"remote failure\"}}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response()?; + assert_eq!(parsed.error_code(), Some(expected)); + } + Ok(()) +} + +#[test] +fn parsed_success_envelope_has_no_error_code() -> Result<(), Box> { + let parsed = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"success\",\"id\":7,\"result\":{}}", + )? + .parse_command_response()?; + + assert_eq!(parsed.error_code(), None); + Ok(()) +} From f331b32656a2d39e6ddcf49f83326583248a4298 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:01:58 -0700 Subject: [PATCH 155/229] style(core): canonicalize error-code RED --- .../tests/webdriver_bidi_response_error_code_evidence.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs index e2761843a..6c3642dad 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs @@ -1,8 +1,6 @@ use std::error::Error; -use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode, -}; +use originweave_core::{BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode}; #[test] fn parsed_error_envelope_retains_typed_error_code() -> Result<(), Box> { From e9f4dcc3d17a23df93f96ce5f56b2daea34af8c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:07:16 -0700 Subject: [PATCH 156/229] fix(core): retain typed BiDi error-code evidence --- crates/originweave-core/src/lib.rs | 1 + .../src/webdriver_bidi_error_code.rs | 187 ++++++++++++++---- .../src/webdriver_bidi_response_envelope.rs | 47 +++-- 3 files changed, 187 insertions(+), 48 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 8571f9d87..c5a825a9e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -74,6 +74,7 @@ pub use webdriver_bidi_command::{ WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; +pub use webdriver_bidi_error_code::WebDriverBiDiErrorCode; pub use webdriver_bidi_response_document::{ BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiResponseDocumentAdmissionError, diff --git a/crates/originweave-core/src/webdriver_bidi_error_code.rs b/crates/originweave-core/src/webdriver_bidi_error_code.rs index ffcea6a1b..d359fd048 100644 --- a/crates/originweave-core/src/webdriver_bidi_error_code.rs +++ b/crates/originweave-core/src/webdriver_bidi_error_code.rs @@ -1,38 +1,155 @@ -/// Returns whether `value` is one of the error codes admitted by the current WebDriver BiDi specification. -pub(crate) fn is_webdriver_bidi_error_code(value: &[u8]) -> bool { - const ERROR_CODES: &[&[u8]] = &[ - b"invalid argument", - b"invalid selector", - b"invalid session id", - b"invalid web extension", - b"move target out of bounds", - b"no such alert", - b"no such client window", - b"no such network collector", - b"no such element", - b"no such frame", - b"no such handle", - b"no such history entry", - b"no such intercept", - b"no such network data", - b"no such node", - b"no such request", - b"no such screencast", - b"no such script", - b"no such storage partition", - b"no such user context", - b"no such web extension", - b"session not created", - b"unable to capture screen", - b"unable to close browser", - b"unable to set cookie", - b"unable to set file input", - b"unavailable network data", - b"underspecified storage partition", - b"unknown command", - b"unknown error", - b"unsupported operation", +/// Typed current WebDriver BiDi protocol error code retained from one validated error response. +/// +/// This vocabulary is deliberately closed over the protocol error codes reviewed by OriginWeave. +/// Unknown wire text remains fail-closed and cannot become typed protocol evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiErrorCode { + /// The command or one of its arguments is invalid. + InvalidArgument, + /// A selector argument is invalid. + InvalidSelector, + /// The referenced browser session does not exist. + InvalidSessionId, + /// The referenced web extension is invalid. + InvalidWebExtension, + /// A requested pointer move target is outside the allowed bounds. + MoveTargetOutOfBounds, + /// The referenced user prompt does not exist. + NoSuchAlert, + /// The referenced client window does not exist. + NoSuchClientWindow, + /// The referenced network collector does not exist. + NoSuchNetworkCollector, + /// The referenced element does not exist. + NoSuchElement, + /// The referenced frame does not exist. + NoSuchFrame, + /// The referenced handle does not exist. + NoSuchHandle, + /// The referenced history entry does not exist. + NoSuchHistoryEntry, + /// The referenced network intercept does not exist. + NoSuchIntercept, + /// The requested network data does not exist. + NoSuchNetworkData, + /// The referenced node does not exist. + NoSuchNode, + /// The referenced network request does not exist. + NoSuchRequest, + /// The referenced screencast does not exist. + NoSuchScreencast, + /// The referenced script does not exist. + NoSuchScript, + /// The referenced storage partition does not exist. + NoSuchStoragePartition, + /// The referenced user context does not exist. + NoSuchUserContext, + /// The referenced web extension does not exist. + NoSuchWebExtension, + /// A browser session could not be created. + SessionNotCreated, + /// The browser could not capture the requested screen image. + UnableToCaptureScreen, + /// The browser could not close as requested. + UnableToCloseBrowser, + /// The browser could not set the requested cookie. + UnableToSetCookie, + /// The browser could not set the requested file input. + UnableToSetFileInput, + /// Requested network data is temporarily unavailable. + UnavailableNetworkData, + /// The supplied storage-partition descriptor is underspecified. + UnderspecifiedStoragePartition, + /// The command is unknown to the remote end. + UnknownCommand, + /// The remote end reported an otherwise unclassified protocol error. + UnknownError, + /// The requested operation is unsupported by the remote end. + UnsupportedOperation, +} + +/// Parse one exact decoded WebDriver BiDi `ErrorCode` value into typed protocol evidence. +pub(crate) fn parse_webdriver_bidi_error_code(value: &[u8]) -> Option { + const ERROR_CODES: &[(&[u8], WebDriverBiDiErrorCode)] = &[ + (b"invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + (b"invalid selector", WebDriverBiDiErrorCode::InvalidSelector), + (b"invalid session id", WebDriverBiDiErrorCode::InvalidSessionId), + (b"invalid web extension", WebDriverBiDiErrorCode::InvalidWebExtension), + ( + b"move target out of bounds", + WebDriverBiDiErrorCode::MoveTargetOutOfBounds, + ), + (b"no such alert", WebDriverBiDiErrorCode::NoSuchAlert), + ( + b"no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + b"no such network collector", + WebDriverBiDiErrorCode::NoSuchNetworkCollector, + ), + (b"no such element", WebDriverBiDiErrorCode::NoSuchElement), + (b"no such frame", WebDriverBiDiErrorCode::NoSuchFrame), + (b"no such handle", WebDriverBiDiErrorCode::NoSuchHandle), + ( + b"no such history entry", + WebDriverBiDiErrorCode::NoSuchHistoryEntry, + ), + (b"no such intercept", WebDriverBiDiErrorCode::NoSuchIntercept), + ( + b"no such network data", + WebDriverBiDiErrorCode::NoSuchNetworkData, + ), + (b"no such node", WebDriverBiDiErrorCode::NoSuchNode), + (b"no such request", WebDriverBiDiErrorCode::NoSuchRequest), + (b"no such screencast", WebDriverBiDiErrorCode::NoSuchScreencast), + (b"no such script", WebDriverBiDiErrorCode::NoSuchScript), + ( + b"no such storage partition", + WebDriverBiDiErrorCode::NoSuchStoragePartition, + ), + ( + b"no such user context", + WebDriverBiDiErrorCode::NoSuchUserContext, + ), + ( + b"no such web extension", + WebDriverBiDiErrorCode::NoSuchWebExtension, + ), + ( + b"session not created", + WebDriverBiDiErrorCode::SessionNotCreated, + ), + ( + b"unable to capture screen", + WebDriverBiDiErrorCode::UnableToCaptureScreen, + ), + ( + b"unable to close browser", + WebDriverBiDiErrorCode::UnableToCloseBrowser, + ), + (b"unable to set cookie", WebDriverBiDiErrorCode::UnableToSetCookie), + ( + b"unable to set file input", + WebDriverBiDiErrorCode::UnableToSetFileInput, + ), + ( + b"unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ( + b"underspecified storage partition", + WebDriverBiDiErrorCode::UnderspecifiedStoragePartition, + ), + (b"unknown command", WebDriverBiDiErrorCode::UnknownCommand), + (b"unknown error", WebDriverBiDiErrorCode::UnknownError), + ( + b"unsupported operation", + WebDriverBiDiErrorCode::UnsupportedOperation, + ), ]; - ERROR_CODES.contains(&value) + ERROR_CODES + .iter() + .find_map(|(raw, code)| (*raw == value).then_some(*code)) } diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs index 29e3e6728..cf22c762c 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -2,7 +2,8 @@ use std::{error::Error, fmt}; use crate::{ BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, - WebDriverBiDiCommandResponseKind, webdriver_bidi_error_code::is_webdriver_bidi_error_code, + WebDriverBiDiCommandResponseKind, + webdriver_bidi_error_code::{WebDriverBiDiErrorCode, parse_webdriver_bidi_error_code}, }; /// Maximum accepted JSON container nesting depth for one WebDriver BiDi response document. @@ -77,14 +78,16 @@ impl Error for WebDriverBiDiResponseEnvelopeParseError {} /// Typed evidence that one bounded raw document is a syntactically valid WebDriver BiDi command /// response envelope. /// -/// The value retains the exact admitted wire text and exposes only the command-response kind and -/// parsed response identifier needed by later correlation. Parsing does not authenticate a browser -/// or transport and does not grant browser, node, policy, or Agent authority. +/// The value retains the exact admitted wire text, command-response kind, parsed response +/// identifier, and the typed protocol error code for an error response. Parsing does not +/// authenticate a browser or transport and does not grant browser, node, policy, or Agent +/// authority. #[derive(Debug, PartialEq, Eq)] pub struct ParsedWebDriverBiDiCommandResponseEnvelope { document: BoundedWebDriverBiDiResponseDocument, kind: WebDriverBiDiCommandResponseKind, response_id: Option, + error_code: Option, } impl ParsedWebDriverBiDiCommandResponseEnvelope { @@ -101,6 +104,16 @@ impl ParsedWebDriverBiDiCommandResponseEnvelope { self.response_id } + /// Returns the typed protocol error code, or `None` for a success response. + /// + /// The value is derived from the same decoded top-level `error` field that passed complete + /// envelope validation; callers therefore do not need to reparse untrusted wire text merely to + /// classify a recoverable protocol failure. + #[must_use] + pub const fn error_code(&self) -> Option { + self.error_code + } + /// Returns the exact bounded wire text from which this envelope evidence was parsed. #[must_use] pub fn as_str(&self) -> &str { @@ -112,8 +125,9 @@ impl BoundedWebDriverBiDiResponseDocument { /// Parses this already-bounded raw document into typed command-response envelope evidence. /// /// Complete JSON syntax, decoded top-level field uniqueness, response-kind requirements, - /// protocol-range response identifiers, and explicit parser resource budgets are enforced - /// before the value can be used by a later correlation boundary. + /// protocol-range response identifiers, typed current error-code classification, and explicit + /// parser resource budgets are enforced before the value can be used by a later correlation + /// boundary. pub fn parse_command_response( self, ) -> Result @@ -123,6 +137,7 @@ impl BoundedWebDriverBiDiResponseDocument { document: self, kind: parsed.kind, response_id: parsed.response_id, + error_code: parsed.error_code, }) } } @@ -140,6 +155,7 @@ enum ParsedJsonValue { struct ParsedEnvelopeFields { kind: WebDriverBiDiCommandResponseKind, response_id: Option, + error_code: Option, } struct ResponseEnvelopeParser<'input> { @@ -218,9 +234,14 @@ impl<'input> ResponseEnvelopeParser<'input> { let kind = Self::parse_response_type(response_type)?; let response_id = Self::parse_response_id(response_id, kind)?; - Self::validate_required_payload(kind, result, error_code, message, stacktrace)?; + let error_code = + Self::validate_required_payload(kind, result, error_code, message, stacktrace)?; - Ok(ParsedEnvelopeFields { kind, response_id }) + Ok(ParsedEnvelopeFields { + kind, + response_id, + error_code, + }) } fn parse_response_type( @@ -268,7 +289,7 @@ impl<'input> ResponseEnvelopeParser<'input> { error_code: Option, message: Option, stacktrace: Option, - ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + ) -> Result, WebDriverBiDiResponseEnvelopeParseError> { match kind { WebDriverBiDiCommandResponseKind::Success => { let result = result @@ -278,6 +299,7 @@ impl<'input> ResponseEnvelopeParser<'input> { WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, ); } + Ok(None) } WebDriverBiDiCommandResponseKind::Error => { let error_code = error_code @@ -294,9 +316,8 @@ impl<'input> ResponseEnvelopeParser<'input> { WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, ); } - if !is_webdriver_bidi_error_code(&error_code) { - return Err(WebDriverBiDiResponseEnvelopeParseError::UnexpectedErrorCode); - } + let error_code = parse_webdriver_bidi_error_code(&error_code) + .ok_or(WebDriverBiDiResponseEnvelopeParseError::UnexpectedErrorCode)?; if let Some(stacktrace) = stacktrace && !matches!(stacktrace, ParsedJsonValue::String(_)) { @@ -304,9 +325,9 @@ impl<'input> ResponseEnvelopeParser<'input> { WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, ); } + Ok(Some(error_code)) } } - Ok(()) } fn parse_value( From 358c6e27ea3d5c3da6476a6251ea5c89bd5c5480 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:11:36 -0700 Subject: [PATCH 157/229] test(core): prove every typed BiDi error mapping --- .../webdriver_bidi_response_error_code.rs | 126 ++++++++++++------ 1 file changed, 86 insertions(+), 40 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs index faed05b61..e90c8d732 100644 --- a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs @@ -1,51 +1,97 @@ use std::error::Error; -use originweave_core::BoundedWebDriverBiDiResponseDocument; +use originweave_core::{BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode}; -const CURRENT_WEBDRIVER_BIDI_ERROR_CODES: &[&str] = &[ - "invalid argument", - "invalid selector", - "invalid session id", - "invalid web extension", - "move target out of bounds", - "no such alert", - "no such client window", - "no such network collector", - "no such element", - "no such frame", - "no such handle", - "no such history entry", - "no such intercept", - "no such network data", - "no such node", - "no such request", - "no such screencast", - "no such script", - "no such storage partition", - "no such user context", - "no such web extension", - "session not created", - "unable to capture screen", - "unable to close browser", - "unable to set cookie", - "unable to set file input", - "unavailable network data", - "underspecified storage partition", - "unknown command", - "unknown error", - "unsupported operation", +const CURRENT_WEBDRIVER_BIDI_ERROR_CODES: &[(&str, WebDriverBiDiErrorCode)] = &[ + ("invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + ("invalid selector", WebDriverBiDiErrorCode::InvalidSelector), + ("invalid session id", WebDriverBiDiErrorCode::InvalidSessionId), + ("invalid web extension", WebDriverBiDiErrorCode::InvalidWebExtension), + ( + "move target out of bounds", + WebDriverBiDiErrorCode::MoveTargetOutOfBounds, + ), + ("no such alert", WebDriverBiDiErrorCode::NoSuchAlert), + ( + "no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + "no such network collector", + WebDriverBiDiErrorCode::NoSuchNetworkCollector, + ), + ("no such element", WebDriverBiDiErrorCode::NoSuchElement), + ("no such frame", WebDriverBiDiErrorCode::NoSuchFrame), + ("no such handle", WebDriverBiDiErrorCode::NoSuchHandle), + ( + "no such history entry", + WebDriverBiDiErrorCode::NoSuchHistoryEntry, + ), + ("no such intercept", WebDriverBiDiErrorCode::NoSuchIntercept), + ( + "no such network data", + WebDriverBiDiErrorCode::NoSuchNetworkData, + ), + ("no such node", WebDriverBiDiErrorCode::NoSuchNode), + ("no such request", WebDriverBiDiErrorCode::NoSuchRequest), + ("no such screencast", WebDriverBiDiErrorCode::NoSuchScreencast), + ("no such script", WebDriverBiDiErrorCode::NoSuchScript), + ( + "no such storage partition", + WebDriverBiDiErrorCode::NoSuchStoragePartition, + ), + ( + "no such user context", + WebDriverBiDiErrorCode::NoSuchUserContext, + ), + ( + "no such web extension", + WebDriverBiDiErrorCode::NoSuchWebExtension, + ), + ( + "session not created", + WebDriverBiDiErrorCode::SessionNotCreated, + ), + ( + "unable to capture screen", + WebDriverBiDiErrorCode::UnableToCaptureScreen, + ), + ( + "unable to close browser", + WebDriverBiDiErrorCode::UnableToCloseBrowser, + ), + ("unable to set cookie", WebDriverBiDiErrorCode::UnableToSetCookie), + ( + "unable to set file input", + WebDriverBiDiErrorCode::UnableToSetFileInput, + ), + ( + "unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ( + "underspecified storage partition", + WebDriverBiDiErrorCode::UnderspecifiedStoragePartition, + ), + ("unknown command", WebDriverBiDiErrorCode::UnknownCommand), + ("unknown error", WebDriverBiDiErrorCode::UnknownError), + ( + "unsupported operation", + WebDriverBiDiErrorCode::UnsupportedOperation, + ), ]; #[test] -fn parser_accepts_current_webdriver_bidi_error_code_vocabulary() -> Result<(), Box> { - for error_code in CURRENT_WEBDRIVER_BIDI_ERROR_CODES { +fn parser_retains_every_current_webdriver_bidi_error_code() -> Result<(), Box> { + for &(raw_code, expected) in CURRENT_WEBDRIVER_BIDI_ERROR_CODES { let raw = format!( - "{{\"type\":\"error\",\"id\":7,\"error\":\"{error_code}\",\"message\":\"browser rejected command\"}}" + "{{\"type\":\"error\",\"id\":7,\"error\":\"{raw_code}\",\"message\":\"browser rejected command\"}}" ); - let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response(); - assert!( - parsed.is_ok(), - "current WebDriver BiDi error code must remain admissible: {error_code}" + let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response()?; + assert_eq!( + parsed.error_code(), + Some(expected), + "current WebDriver BiDi error code must retain its typed mapping: {raw_code}" ); } Ok(()) From 67403395de36cbb4368b0b370756f5990549058b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:12:23 -0700 Subject: [PATCH 158/229] fix(core): restore canonical Rust formatting --- .../src/webdriver_bidi_error_code.rs | 25 +++++++++++++++---- .../webdriver_bidi_response_error_code.rs | 20 ++++++++++++--- ...river_bidi_response_error_code_evidence.rs | 7 +++--- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_error_code.rs b/crates/originweave-core/src/webdriver_bidi_error_code.rs index d359fd048..bd336cf2d 100644 --- a/crates/originweave-core/src/webdriver_bidi_error_code.rs +++ b/crates/originweave-core/src/webdriver_bidi_error_code.rs @@ -73,8 +73,14 @@ pub(crate) fn parse_webdriver_bidi_error_code(value: &[u8]) -> Option Option Option Result<(), Box #[test] fn parsed_success_envelope_has_no_error_code() -> Result<(), Box> { - let parsed = BoundedWebDriverBiDiResponseDocument::new( - "{\"type\":\"success\",\"id\":7,\"result\":{}}", - )? - .parse_command_response()?; + let parsed = + BoundedWebDriverBiDiResponseDocument::new("{\"type\":\"success\",\"id\":7,\"result\":{}}")? + .parse_command_response()?; assert_eq!(parsed.error_code(), None); Ok(()) From a4510546ce7ce8335325af836c2e844137241a87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:07:24 -0700 Subject: [PATCH 159/229] test(core): require wire-to-authority node binding --- .../webdriver_bidi_wire_authority_binding.rs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs new file mode 100644 index 000000000..3e7b39518 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs @@ -0,0 +1,118 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, + BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserRegistryError, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, +}; + +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 locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 42, + "context-a", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, + external_context: &str, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, external_context)?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + )) +} + +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 successful_wire_document() -> Result> { + Ok(BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"}]}}"#, + )?) +} + +#[test] +fn wire_response_binds_nodes_to_exact_current_authority_without_caller_selected_intermediate_result( +) -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + + let handles = locate_nodes_command()?.bind_response_document_nodes( + successful_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + Ok(()) +} + +#[test] +fn wire_response_binding_preserves_current_context_authority_failure() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-b")?; + + let error = locate_nodes_command()?.bind_response_document_nodes( + successful_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert_eq!( + error, + Err(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(()) +} From bcc719fa0992e9cd1b896416b85513ef5d41545c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:10:20 -0700 Subject: [PATCH 160/229] test(core): format wire authority regression --- .../tests/webdriver_bidi_wire_authority_binding.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 3e7b39518..e0e57b7e7 100644 --- a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs +++ b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs @@ -71,8 +71,8 @@ fn successful_wire_document() -> Result Result<(), Box> { +fn wire_response_binds_nodes_to_exact_current_authority_without_caller_selected_intermediate_result() +-> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-a")?; @@ -91,7 +91,8 @@ fn wire_response_binds_nodes_to_exact_current_authority_without_caller_selected_ } #[test] -fn wire_response_binding_preserves_current_context_authority_failure() -> Result<(), Box> { +fn wire_response_binding_preserves_current_context_authority_failure() -> Result<(), Box> +{ let mut registry = BrowserAuthorityRegistry::new(); let origin = controlled_origin()?; let target = current_target(&mut registry, &origin, "context-b")?; @@ -111,7 +112,9 @@ fn wire_response_binding_preserves_current_context_authority_failure() -> Result ), )) ); - let error = error.err().ok_or("expected exact current context failure")?; + let error = error + .err() + .ok_or("expected exact current context failure")?; assert!(error.source().is_some()); assert!(!error.to_string().is_empty()); Ok(()) From fda7ea80a3275ca9411ee836f559dddb2d367bbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:19:12 -0700 Subject: [PATCH 161/229] feat(core): bind wire locateNodes results to current authority --- ...iver_bidi_response_document_correlation.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 84635fa18..3b79bd319 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -12,6 +12,10 @@ use crate::webdriver_bidi_response_envelope::WebDriverBiDiResponseEnvelopeParseE use crate::webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, + ValidatedBrowserProtocolUse, WebDriverBiDiLocateNodesAdmissionError, +}; /// Fail-closed errors while parsing, correlating, and admitting one bounded WebDriver BiDi /// `locateNodes` response document. @@ -39,6 +43,8 @@ pub enum WebDriverBiDiLocateNodesResponseDocumentError { InvalidResultNodeSharedId, /// Exact command-budget or remote-node admission rejected the wire-derived node batch. ResultAdmission(WebDriverBiDiLocateNodesResultAdmissionError), + /// Exact current browser authority rejected the wire-derived node batch. + NodeBinding(WebDriverBiDiLocateNodesAdmissionError), /// A second-pass result parser invariant failed after complete envelope parsing succeeded. ResultParserInvariant, } @@ -77,6 +83,10 @@ impl Display for WebDriverBiDiLocateNodesResponseDocumentError { formatter, "WebDriver BiDi locateNodes wire result rejected node admission: {error}" ), + Self::NodeBinding(error) => write!( + formatter, + "WebDriver BiDi locateNodes wire result rejected current browser authority: {error}" + ), Self::ResultParserInvariant => formatter.write_str( "WebDriver BiDi locateNodes result parser invariant failed after envelope validation", ), @@ -90,6 +100,7 @@ impl Error for WebDriverBiDiLocateNodesResponseDocumentError { Self::Parse(error) => Some(error), Self::Envelope(error) => Some(error), Self::ResultAdmission(error) => Some(error), + Self::NodeBinding(error) => Some(error), Self::MissingResultNodes | Self::InvalidResultNodes | Self::DuplicateResultNodes @@ -169,6 +180,30 @@ impl WebDriverBiDiLocateNodesCommand { .admit_result_nodes(&admission_parts) .map_err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission) } + + /// Consume one bounded raw `locateNodes` response through wire admission and current authority. + /// + /// This is the direct composition boundary from the exact parsed wire document to current + /// OriginWeave node authority. The caller cannot replace the response kind, response id, result + /// nodes, browsing-context identifier, or command result budget between wire parsing and node + /// binding. After wire-derived admission succeeds, the supplied WebDriver BiDi + /// `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. + pub fn bind_response_document_nodes( + self, + document: BoundedWebDriverBiDiResponseDocument, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + self.admit_response_document_nodes(document)? + .bind_current_nodes(validated, authority_registry, target) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding) + } } #[cfg(test)] From b0c29ac48b66f81c0e59ce4abc625c04d1ddfd2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 17:29:47 -0700 Subject: [PATCH 162/229] test(core): cover BiDi bind admission failure --- .../webdriver_bidi_wire_authority_binding.rs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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 e0e57b7e7..70802e89c 100644 --- a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs +++ b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs @@ -6,7 +6,8 @@ use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -70,6 +71,12 @@ fn successful_wire_document() -> Result Result> { + Ok(BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":43,"result":{"nodes":[{"type":"node","sharedId":"node-a"}]}}"#, + )?) +} + #[test] fn wire_response_binds_nodes_to_exact_current_authority_without_caller_selected_intermediate_result() -> Result<(), Box> { @@ -90,6 +97,34 @@ fn wire_response_binds_nodes_to_exact_current_authority_without_caller_selected_ Ok(()) } +#[test] +fn wire_response_binding_preserves_wire_correlation_failure_before_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + + let error = locate_nodes_command()?.bind_response_document_nodes( + mismatched_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 43, + }, + ), + )) + ); + Ok(()) +} + #[test] fn wire_response_binding_preserves_current_context_authority_failure() -> Result<(), Box> { From 257d9a2cb7a978b04ee9edb686cf47ba5ec05232 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:29:50 -0700 Subject: [PATCH 163/229] test(core): require typed BiDi protocol errors --- ...driver_bidi_protocol_error_preservation.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs new file mode 100644 index 000000000..6393f1f71 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs @@ -0,0 +1,23 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, +}; + +#[test] +fn correlated_wire_error_remains_typed_through_node_admission() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + let command = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":42,"error":"unavailable network data","message":"retry later"}"#, + )?; + + assert_eq!( + command.admit_response_document_nodes(document), + Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::UnavailableNetworkData, + )) + ); + Ok(()) +} From ef4f55492a9cbafe21a34160ecc8d76e8e0f7dd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:31:07 -0700 Subject: [PATCH 164/229] style(core): apply canonical rustfmt to BiDi RED --- .../tests/webdriver_bidi_protocol_error_preservation.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs index 6393f1f71..7b670d390 100644 --- a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs @@ -15,9 +15,11 @@ fn correlated_wire_error_remains_typed_through_node_admission() -> Result<(), Bo assert_eq!( command.admit_response_document_nodes(document), - Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( - WebDriverBiDiErrorCode::UnavailableNetworkData, - )) + Err( + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::UnavailableNetworkData, + ) + ) ); Ok(()) } From 544159479dd87e6d6ea6e05037d4d7313187b9dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:34:55 -0700 Subject: [PATCH 165/229] fix(core): preserve correlated BiDi protocol errors --- ...iver_bidi_response_document_correlation.rs | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) 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 3b79bd319..1f4488812 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -14,17 +14,19 @@ use crate::webdriver_bidi_result::{ }; use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, - ValidatedBrowserProtocolUse, WebDriverBiDiLocateNodesAdmissionError, + ValidatedBrowserProtocolUse, WebDriverBiDiErrorCode, WebDriverBiDiLocateNodesAdmissionError, }; -/// Fail-closed errors while parsing, correlating, and admitting one bounded WebDriver BiDi -/// `locateNodes` response document. +/// Fail-closed errors while parsing, correlating, classifying, and admitting one bounded +/// WebDriver BiDi `locateNodes` response document. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebDriverBiDiLocateNodesResponseDocumentError { /// The bounded document failed complete WebDriver BiDi response-envelope parsing. Parse(WebDriverBiDiResponseEnvelopeParseError), /// The parsed envelope failed exact command correlation or success-only conversion. Envelope(WebDriverBiDiLocateNodesResponseEnvelopeError), + /// The exactly correlated remote end returned a typed WebDriver BiDi protocol error. + ProtocolError(WebDriverBiDiErrorCode), /// The correlated success result omitted its required `nodes` field. MissingResultNodes, /// The correlated success result's `nodes` field was not a JSON array. @@ -60,6 +62,9 @@ impl Display for WebDriverBiDiLocateNodesResponseDocumentError { formatter, "WebDriver BiDi response document rejected command correlation: {error}" ), + Self::ProtocolError(_) => formatter.write_str( + "WebDriver BiDi response document contains a correlated typed protocol error", + ), Self::MissingResultNodes => { formatter.write_str("WebDriver BiDi locateNodes result is missing its nodes field") } @@ -101,7 +106,8 @@ impl Error for WebDriverBiDiLocateNodesResponseDocumentError { Self::Envelope(error) => Some(error), Self::ResultAdmission(error) => Some(error), Self::NodeBinding(error) => Some(error), - Self::MissingResultNodes + Self::ProtocolError(_) + | Self::MissingResultNodes | Self::InvalidResultNodes | Self::DuplicateResultNodes | Self::InvalidResultNode @@ -139,14 +145,17 @@ impl WebDriverBiDiLocateNodesCommand { /// Consume one bounded raw `locateNodes` response through exact wire-derived node admission. /// - /// The same bounded document first passes the complete response-envelope parser, exact command - /// correlation, and success-only conversion. Only then does the result parser derive the exact - /// `result.nodes` array from that already-validated wire document. The command's exact - /// `maxNodeCount` is carried into this parser so overflow items are consumed only as generic JSON - /// and produce the existing result-budget failure before authority-relevant node metadata is - /// decoded or normalized. Decoded duplicate `nodes`, and duplicate or malformed in-budget - /// `type`/`sharedId` fields, fail closed. JSON-escaped protocol metadata is decoded before - /// admission, and callers cannot supply replacement node metadata to this method. + /// The same bounded document first passes the complete response-envelope parser and exact + /// command correlation. An exactly correlated protocol error retains its reviewed typed error + /// code and stops before success-only result admission; unknown error-code text still fails + /// closed during complete envelope parsing. Only a correlated success proceeds to the result + /// parser, which derives the exact `result.nodes` array from that already-validated wire + /// document. The command's exact `maxNodeCount` is carried into this parser so overflow items are + /// consumed only as generic JSON and produce the existing result-budget failure before + /// authority-relevant node metadata is decoded or normalized. Decoded duplicate `nodes`, and + /// duplicate or malformed in-budget `type`/`sharedId` fields, fail closed. JSON-escaped protocol + /// metadata is decoded before admission, and callers cannot supply replacement node metadata to + /// this method. /// /// Success remains untrusted transport evidence. It does not authenticate Chromium, /// ChromeDriver, WebSocket/TLS provenance, or an adapter process; prove current @@ -165,6 +174,11 @@ impl WebDriverBiDiLocateNodesCommand { let correlated = self .correlate_response_envelope(parsed.kind(), parsed.response_id()) .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; + if let Some(error_code) = parsed.error_code() { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + error_code, + )); + } let validated = correlated .into_validated_success() .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; From cdf0d8fcd777c7ac91fdd47d6c82611328a8a8f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:36:45 -0700 Subject: [PATCH 166/229] style(core): apply canonical rustfmt to BiDi error preservation --- .../src/webdriver_bidi_response_document_correlation.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 1f4488812..3dfab229e 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -175,9 +175,7 @@ impl WebDriverBiDiLocateNodesCommand { .correlate_response_envelope(parsed.kind(), parsed.response_id()) .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; if let Some(error_code) = parsed.error_code() { - return Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( - error_code, - )); + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError(error_code)); } let validated = correlated .into_validated_success() From d883f9a2fb8dbf29ca4ed7d9665ae42174859059 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:38:52 -0700 Subject: [PATCH 167/229] test(core): align BiDi wire error regression with typed protocol preservation --- .../webdriver_bidi_locate_nodes_wire_result.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index ce5d7bcd3..c61a17dff 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -1,7 +1,7 @@ use std::error::Error; use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, }; @@ -138,7 +138,7 @@ fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result } #[test] -fn wire_result_boundary_preserves_parse_correlation_and_success_only_failures() +fn wire_result_boundary_preserves_parse_correlation_and_protocol_failures() -> Result<(), Box> { let malformed = BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":42,"result":{},}"#)?; @@ -158,10 +158,12 @@ fn wire_result_boundary_preserves_parse_correlation_and_success_only_failures() let error_response = BoundedWebDriverBiDiResponseDocument::new( r#"{"type":"error","id":42,"error":"invalid argument","message":"bad request"}"#, )?; - assert!(matches!( + assert_eq!( locate_nodes_command(42, 1)?.admit_response_document_nodes(error_response), - Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope(_)) - )); + Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::InvalidArgument, + )) + ); Ok(()) } @@ -169,6 +171,9 @@ fn wire_result_boundary_preserves_parse_correlation_and_success_only_failures() fn wire_result_document_error_display_and_sources_cover_result_failure_variants() -> Result<(), Box> { let source_free = [ + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::InvalidArgument, + ), WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, From 910c519f355fc1ce31fc5f930c70d09b7dd5b052 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:41:00 -0700 Subject: [PATCH 168/229] style(core): apply canonical rustfmt to BiDi wire regression --- .../tests/webdriver_bidi_locate_nodes_wire_result.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs index c61a17dff..aba667210 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -160,9 +160,11 @@ fn wire_result_boundary_preserves_parse_correlation_and_protocol_failures() )?; assert_eq!( locate_nodes_command(42, 1)?.admit_response_document_nodes(error_response), - Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( - WebDriverBiDiErrorCode::InvalidArgument, - )) + Err( + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::InvalidArgument, + ) + ) ); Ok(()) } From 291bbacdc4c3e8dcc8662c574c732b64971280e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:49:02 -0700 Subject: [PATCH 169/229] fix(core): remove unreachable BiDi success-conversion edge --- ...iver_bidi_response_document_correlation.rs | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) 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 3dfab229e..068bdced5 100644 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -146,16 +146,17 @@ impl WebDriverBiDiLocateNodesCommand { /// Consume one bounded raw `locateNodes` response through exact wire-derived node admission. /// /// The same bounded document first passes the complete response-envelope parser and exact - /// command correlation. An exactly correlated protocol error retains its reviewed typed error - /// code and stops before success-only result admission; unknown error-code text still fails - /// closed during complete envelope parsing. Only a correlated success proceeds to the result - /// parser, which derives the exact `result.nodes` array from that already-validated wire - /// document. The command's exact `maxNodeCount` is carried into this parser so overflow items are - /// consumed only as generic JSON and produce the existing result-budget failure before - /// authority-relevant node metadata is decoded or normalized. Decoded duplicate `nodes`, and - /// duplicate or malformed in-budget `type`/`sharedId` fields, fail closed. JSON-escaped protocol - /// metadata is decoded before admission, and callers cannot supply replacement node metadata to - /// this method. + /// command-id correlation. A parsed error with JSON `null` id remains uncorrelatable and fails + /// closed before its typed error code can influence recovery. An exactly correlated protocol + /// error retains its reviewed typed error code and stops before result admission; unknown + /// error-code text still fails closed during complete envelope parsing. Only a correlated + /// success proceeds to the result parser, which derives the exact `result.nodes` array from that + /// already-validated wire document. The command's exact `maxNodeCount` is carried into this + /// parser so overflow items are consumed only as generic JSON and produce the existing + /// result-budget failure before authority-relevant node metadata is decoded or normalized. + /// Decoded duplicate `nodes`, and duplicate or malformed in-budget `type`/`sharedId` fields, + /// fail closed. JSON-escaped protocol metadata is decoded before admission, and callers cannot + /// supply replacement node metadata to this method. /// /// Success remains untrusted transport evidence. It does not authenticate Chromium, /// ChromeDriver, WebSocket/TLS provenance, or an adapter process; prove current @@ -171,15 +172,22 @@ impl WebDriverBiDiLocateNodesCommand { let parsed = document .parse_command_response() .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; - let correlated = self - .correlate_response_envelope(parsed.kind(), parsed.response_id()) - .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; + let response_id = match parsed.response_id() { + Some(response_id) => response_id, + None => { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )); + } + }; + let validated = self.correlate_response_id(response_id).map_err(|error| { + WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation(error), + ) + })?; if let Some(error_code) = parsed.error_code() { return Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError(error_code)); } - let validated = correlated - .into_validated_success() - .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope)?; let wire_nodes = locate_nodes_result_document::parse_wire_locate_nodes_result_bounded( parsed.as_str(), validated.max_node_count(), From a11b838823b185829267ea0c3cccbcaeb0793f2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:07:37 -0700 Subject: [PATCH 170/229] test(core): cover nullable BiDi error admission --- ...driver_bidi_protocol_error_preservation.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs index 7b670d390..a138cab4e 100644 --- a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs @@ -3,6 +3,7 @@ use std::error::Error; use originweave_core::{ BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiLocateNodesResponseEnvelopeError, }; #[test] @@ -23,3 +24,21 @@ fn correlated_wire_error_remains_typed_through_node_admission() -> Result<(), Bo ); Ok(()) } + +#[test] +fn nullable_wire_error_remains_uncorrelatable_before_protocol_error_admission() +-> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + let command = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":null,"error":"invalid argument","message":"bad request"}"#, + )?; + + assert_eq!( + command.admit_response_document_nodes(document), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )) + ); + Ok(()) +} From 2687529751d59d5f5be14011df82ecc176898233 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:37:00 -0700 Subject: [PATCH 171/229] test(core): require bounded BiDi WebSocket endpoint admission --- .../webdriver_bidi_websocket_endpoint.rs | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs new file mode 100644 index 000000000..6a75f81da --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -0,0 +1,168 @@ +use std::error::Error; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, WebDriverBiDiWebSocketEndpoint, + WebDriverBiDiWebSocketEndpointAdmissionError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +#[test] +fn canonical_loopback_session_endpoints_are_admitted_without_granting_authority() { + let ipv4 = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://127.0.0.1:9515/session/{SESSION_ID}" + )) + .unwrap(); + assert!(!ipv4.is_secure()); + assert_eq!(ipv4.host(), "127.0.0.1"); + assert_eq!(ipv4.port(), 9515); + assert_eq!(ipv4.session_id(), SESSION_ID); + assert_eq!( + ipv4.as_str(), + format!("ws://127.0.0.1:9515/session/{SESSION_ID}") + ); + + let localhost = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:4444/session/{SESSION_ID}" + )) + .unwrap(); + assert_eq!(localhost.host(), "localhost"); + + let ipv6 = WebDriverBiDiWebSocketEndpoint::new(&format!( + "wss://[::1]:9222/session/{SESSION_ID}" + )) + .unwrap(); + assert!(ipv6.is_secure()); + assert_eq!(ipv6.host(), "::1"); + assert_eq!(ipv6.port(), 9222); +} + +#[test] +fn remote_or_ambiguous_authorities_fail_closed() { + for endpoint in [ + format!("ws://example.com:9515/session/{SESSION_ID}"), + format!("ws://192.0.2.1:9515/session/{SESSION_ID}"), + format!("ws://[2001:db8::1]:9515/session/{SESSION_ID}"), + ] { + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost + ); + } + + for endpoint in [ + format!("ws://user@localhost:9515/session/{SESSION_ID}"), + format!("ws://localhost/session/{SESSION_ID}"), + format!("ws://::1:9515/session/{SESSION_ID}"), + format!("ws://[::1]9515/session/{SESSION_ID}"), + ] { + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority + ); + } +} + +#[test] +fn port_and_session_resource_are_canonical_and_bounded() { + for endpoint in [ + format!("ws://localhost:0/session/{SESSION_ID}"), + format!("ws://localhost:09515/session/{SESSION_ID}"), + format!("ws://localhost:65536/session/{SESSION_ID}"), + format!("ws://localhost:+9515/session/{SESSION_ID}"), + ] { + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort + ); + } + + for endpoint in [ + format!("ws://localhost:9515/other/{SESSION_ID}"), + format!("ws://localhost:9515/session/{SESSION_ID}/extra"), + "ws://localhost:9515/session/".to_owned(), + "ws://localhost:9515".to_owned(), + ] { + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource + ); + } + + for session_id in [ + "01234567-89ab-cdef-0123-456789abcdeF", + "0123456789ab-cdef-0123-456789abcdef", + "01234567-89ab-cdef-0123-456789abcdeg", + ] { + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{session_id}" + )) + .unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId + ); + } +} + +#[test] +fn endpoint_text_rejects_noncanonical_or_unbounded_inputs_before_transport_use() { + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new("").unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint + ); + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "http://localhost:9515/session/{SESSION_ID}" + )) + .unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme + ); + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://local host:9515/session/{SESSION_ID}" + )) + .unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText + ); + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{SESSION_ID}?token=secret" + )) + .unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden + ); + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{SESSION_ID}#fragment" + )) + .unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden + ); + + let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES + 1); + assert_eq!( + WebDriverBiDiWebSocketEndpoint::new(&oversized).unwrap_err(), + WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong + ); +} + +#[test] +fn endpoint_error_contract_is_deterministic_and_source_free() { + let errors = [ + WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint, + WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme, + WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority, + WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId, + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} From a1c4cb55609e03e76c8663b47727e302313cd08d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:40:16 -0700 Subject: [PATCH 172/229] style(core): canonicalize BiDi WebSocket endpoint RED --- .../webdriver_bidi_websocket_endpoint.rs | 132 ++++++++++-------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs index 6a75f81da..a5954b32d 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -9,10 +9,12 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; #[test] fn canonical_loopback_session_endpoints_are_admitted_without_granting_authority() { - let ipv4 = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://127.0.0.1:9515/session/{SESSION_ID}" - )) - .unwrap(); + let ipv4_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://127.0.0.1:9515/session/{SESSION_ID}")); + assert!(ipv4_result.is_ok(), "{ipv4_result:?}"); + let Ok(ipv4) = ipv4_result else { + return; + }; assert!(!ipv4.is_secure()); assert_eq!(ipv4.host(), "127.0.0.1"); assert_eq!(ipv4.port(), 9515); @@ -22,16 +24,20 @@ fn canonical_loopback_session_endpoints_are_admitted_without_granting_authority( format!("ws://127.0.0.1:9515/session/{SESSION_ID}") ); - let localhost = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://localhost:4444/session/{SESSION_ID}" - )) - .unwrap(); + let localhost_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://localhost:4444/session/{SESSION_ID}")); + assert!(localhost_result.is_ok(), "{localhost_result:?}"); + let Ok(localhost) = localhost_result else { + return; + }; assert_eq!(localhost.host(), "localhost"); - let ipv6 = WebDriverBiDiWebSocketEndpoint::new(&format!( - "wss://[::1]:9222/session/{SESSION_ID}" - )) - .unwrap(); + let ipv6_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("wss://[::1]:9222/session/{SESSION_ID}")); + assert!(ipv6_result.is_ok(), "{ipv6_result:?}"); + let Ok(ipv6) = ipv6_result else { + return; + }; assert!(ipv6.is_secure()); assert_eq!(ipv6.host(), "::1"); assert_eq!(ipv6.port(), 9222); @@ -44,10 +50,10 @@ fn remote_or_ambiguous_authorities_fail_closed() { format!("ws://192.0.2.1:9515/session/{SESSION_ID}"), format!("ws://[2001:db8::1]:9515/session/{SESSION_ID}"), ] { - assert_eq!( - WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost - ); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost) + )); } for endpoint in [ @@ -55,11 +61,13 @@ fn remote_or_ambiguous_authorities_fail_closed() { format!("ws://localhost/session/{SESSION_ID}"), format!("ws://::1:9515/session/{SESSION_ID}"), format!("ws://[::1]9515/session/{SESSION_ID}"), + format!("ws://[::zz]:9515/session/{SESSION_ID}"), + format!("ws://:9515/session/{SESSION_ID}"), ] { - assert_eq!( - WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority - ); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority) + )); } } @@ -71,10 +79,10 @@ fn port_and_session_resource_are_canonical_and_bounded() { format!("ws://localhost:65536/session/{SESSION_ID}"), format!("ws://localhost:+9515/session/{SESSION_ID}"), ] { - assert_eq!( - WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort - ); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort) + )); } for endpoint in [ @@ -83,67 +91,69 @@ fn port_and_session_resource_are_canonical_and_bounded() { "ws://localhost:9515/session/".to_owned(), "ws://localhost:9515".to_owned(), ] { - assert_eq!( - WebDriverBiDiWebSocketEndpoint::new(&endpoint).unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource - ); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource) + )); } for session_id in [ "01234567-89ab-cdef-0123-456789abcdeF", "0123456789ab-cdef-0123-456789abcdef", "01234567-89ab-cdef-0123-456789abcdeg", + "01234567_89ab-cdef-0123-456789abcdef", ] { - assert_eq!( + assert!(matches!( WebDriverBiDiWebSocketEndpoint::new(&format!( "ws://localhost:9515/session/{session_id}" - )) - .unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId - ); + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId) + )); } } #[test] fn endpoint_text_rejects_noncanonical_or_unbounded_inputs_before_transport_use() { - assert_eq!( - WebDriverBiDiWebSocketEndpoint::new("").unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint - ); - assert_eq!( + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(""), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint) + )); + assert!(matches!( WebDriverBiDiWebSocketEndpoint::new(&format!( "http://localhost:9515/session/{SESSION_ID}" - )) - .unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme - ); - assert_eq!( + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme) + )); + assert!(matches!( WebDriverBiDiWebSocketEndpoint::new(&format!( "ws://local host:9515/session/{SESSION_ID}" - )) - .unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText - ); - assert_eq!( + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://locálhost:9515/session/{SESSION_ID}" + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) + )); + assert!(matches!( WebDriverBiDiWebSocketEndpoint::new(&format!( "ws://localhost:9515/session/{SESSION_ID}?token=secret" - )) - .unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden - ); - assert_eq!( + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) + )); + assert!(matches!( WebDriverBiDiWebSocketEndpoint::new(&format!( "ws://localhost:9515/session/{SESSION_ID}#fragment" - )) - .unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden - ); + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) + )); let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES + 1); - assert_eq!( - WebDriverBiDiWebSocketEndpoint::new(&oversized).unwrap_err(), - WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong - ); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&oversized), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong) + )); } #[test] From dbdff70a1e8a045ee2735d86a321992e9ee295c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:41:25 -0700 Subject: [PATCH 173/229] style(core): apply canonical rustfmt to endpoint RED --- .../tests/webdriver_bidi_websocket_endpoint.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs index a5954b32d..fb47c4f3e 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -119,21 +119,15 @@ fn endpoint_text_rejects_noncanonical_or_unbounded_inputs_before_transport_use() Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint) )); assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&format!( - "http://localhost:9515/session/{SESSION_ID}" - )), + WebDriverBiDiWebSocketEndpoint::new(&format!("http://localhost:9515/session/{SESSION_ID}")), Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme) )); assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://local host:9515/session/{SESSION_ID}" - )), + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://local host:9515/session/{SESSION_ID}")), Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) )); assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://locálhost:9515/session/{SESSION_ID}" - )), + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://locálhost:9515/session/{SESSION_ID}")), Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) )); assert!(matches!( From 3d2c408aa18a5328aac283be5744d5f39633184f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:44:16 -0700 Subject: [PATCH 174/229] feat(core): admit bounded BiDi WebSocket endpoints --- crates/originweave-core/src/lib.rs | 5 + .../src/webdriver_bidi_websocket_endpoint.rs | 230 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index c5a825a9e..67753ed6c 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -37,6 +37,7 @@ mod webdriver_bidi_response_document; mod webdriver_bidi_response_document_correlation; mod webdriver_bidi_response_envelope; mod webdriver_bidi_result; +mod webdriver_bidi_websocket_endpoint; pub use browser_authority_registry::BrowserAuthorityRegistry; pub use browser_protocol::{ @@ -87,3 +88,7 @@ pub use webdriver_bidi_response_envelope::{ pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; +pub use webdriver_bidi_websocket_endpoint::{ + MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, WebDriverBiDiWebSocketEndpoint, + WebDriverBiDiWebSocketEndpointAdmissionError, +}; diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs new file mode 100644 index 000000000..3042f97cd --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -0,0 +1,230 @@ +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// Maximum admitted bytes for one WebDriver BiDi WebSocket endpoint. +/// +/// This is an OriginWeave first-Chromium-fixture safety budget, not a +/// WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES: usize = 2_048; + +/// One bounded canonical WebDriver BiDi session WebSocket endpoint. +/// +/// This value is transport metadata only. Construction does not authenticate +/// Chromium, ChromeDriver, the operating-system peer, TLS, policy, or Agent +/// authority. The first real-Chromium fixture intentionally admits only +/// loopback listener identities; the connection boundary must still verify the +/// actual peer before exposing transport I/O. +#[derive(Debug, PartialEq, Eq)] +pub struct WebDriverBiDiWebSocketEndpoint { + endpoint: String, + secure: bool, + host: String, + port: u16, + session_id: String, +} + +impl WebDriverBiDiWebSocketEndpoint { + /// Admit one bounded canonical first-fixture WebDriver BiDi endpoint. + pub fn new(value: &str) -> Result { + if value.is_empty() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint); + } + if value.len() > MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong); + } + if value.bytes().any(|byte| !byte.is_ascii_graphic()) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText); + } + if value.bytes().any(|byte| matches!(byte, b'?' | b'#')) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden); + } + + let (secure, remainder) = if let Some(remainder) = value.strip_prefix("ws://") { + (false, remainder) + } else if let Some(remainder) = value.strip_prefix("wss://") { + (true, remainder) + } else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme); + }; + + let Some(path_start) = remainder.find('/') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + }; + let authority = &remainder[..path_start]; + let resource = &remainder[path_start..]; + if authority.is_empty() || authority.contains('@') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + + let (host, port_text) = if let Some(bracketed) = authority.strip_prefix('[') { + let Some(close) = bracketed.find(']') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + let host_text = &bracketed[..close]; + let suffix = &bracketed[close + 1..]; + let Some(port_text) = suffix.strip_prefix(':') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if port_text.is_empty() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + let Ok(ip) = host_text.parse::() else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if !ip.is_loopback() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } + if ip.to_string() != host_text { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + (host_text.to_owned(), port_text) + } else { + let Some((host_text, port_text)) = authority.rsplit_once(':') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if host_text.is_empty() || port_text.is_empty() || host_text.contains(':') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + if host_text == "localhost" { + (host_text.to_owned(), port_text) + } else if let Ok(ip) = host_text.parse::() { + if ip.to_string() != host_text { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + if !ip.is_loopback() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } + (host_text.to_owned(), port_text) + } else if host_text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + }; + + let Ok(port) = port_text.parse::() else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort); + }; + if port == 0 || port.to_string() != port_text { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort); + } + + let Some(session_id) = resource.strip_prefix("/session/") else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + }; + if session_id.is_empty() || session_id.contains('/') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + } + if !is_canonical_session_uuid(session_id) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId); + } + + Ok(Self { + endpoint: value.to_owned(), + secure, + host, + port, + session_id: session_id.to_owned(), + }) + } + + /// Return the exact admitted endpoint text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.endpoint + } + + /// Return whether the endpoint uses `wss` rather than `ws`. + #[must_use] + pub const fn is_secure(&self) -> bool { + self.secure + } + + /// Return the canonical loopback listener host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + /// Return the explicit nonzero listener port. + #[must_use] + pub const fn port(&self) -> u16 { + self.port + } + + /// Return the canonical lower-case UUID session identifier. + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } +} + +fn is_canonical_session_uuid(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + for (index, byte) in bytes.iter().copied().enumerate() { + let valid = if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') + }; + if !valid { + return false; + } + } + true +} + +/// Fail-closed admission errors for WebDriver BiDi WebSocket endpoint metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketEndpointAdmissionError { + /// The endpoint text is empty. + EmptyEndpoint, + /// The endpoint text exceeds the OriginWeave safety budget. + EndpointTooLong, + /// The endpoint contains non-ASCII, whitespace, or control text. + InvalidEndpointText, + /// The endpoint does not use the exact `ws` or `wss` scheme. + InvalidScheme, + /// Query or fragment data is present and therefore not part of the admitted session resource. + QueryOrFragmentForbidden, + /// The authority is absent, credential-bearing, ambiguous, or malformed. + InvalidAuthority, + /// The authority identifies a non-loopback host. + NonLoopbackHost, + /// The port is absent, zero, out of range, or not canonically serialized. + InvalidPort, + /// The path is not exactly one `/session/` resource. + InvalidSessionResource, + /// The session id is not one canonical lower-case UUID representation. + InvalidSessionId, +} + +impl fmt::Display for WebDriverBiDiWebSocketEndpointAdmissionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::EmptyEndpoint => "WebDriver BiDi WebSocket endpoint is empty", + Self::EndpointTooLong => "WebDriver BiDi WebSocket endpoint exceeds the safety budget", + Self::InvalidEndpointText => "WebDriver BiDi WebSocket endpoint text is not canonical ASCII", + Self::InvalidScheme => "WebDriver BiDi WebSocket endpoint scheme is not ws or wss", + Self::QueryOrFragmentForbidden => { + "WebDriver BiDi WebSocket endpoint query or fragment is forbidden" + } + Self::InvalidAuthority => "WebDriver BiDi WebSocket endpoint authority is invalid", + Self::NonLoopbackHost => "WebDriver BiDi WebSocket endpoint host is not loopback", + Self::InvalidPort => "WebDriver BiDi WebSocket endpoint port is invalid", + Self::InvalidSessionResource => { + "WebDriver BiDi WebSocket endpoint session resource is invalid" + } + Self::InvalidSessionId => "WebDriver BiDi WebSocket endpoint session id is invalid", + }; + f.write_str(message) + } +} + +impl std::error::Error for WebDriverBiDiWebSocketEndpointAdmissionError {} From e0ec37e97007fc6d71b3ca6956151bc5bc81b705 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 20:45:52 -0700 Subject: [PATCH 175/229] style(core): apply canonical endpoint rustfmt --- .../originweave-core/src/webdriver_bidi_websocket_endpoint.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs index 3042f97cd..2b266c679 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -210,7 +210,9 @@ impl fmt::Display for WebDriverBiDiWebSocketEndpointAdmissionError { let message = match self { Self::EmptyEndpoint => "WebDriver BiDi WebSocket endpoint is empty", Self::EndpointTooLong => "WebDriver BiDi WebSocket endpoint exceeds the safety budget", - Self::InvalidEndpointText => "WebDriver BiDi WebSocket endpoint text is not canonical ASCII", + Self::InvalidEndpointText => { + "WebDriver BiDi WebSocket endpoint text is not canonical ASCII" + } Self::InvalidScheme => "WebDriver BiDi WebSocket endpoint scheme is not ws or wss", Self::QueryOrFragmentForbidden => { "WebDriver BiDi WebSocket endpoint query or fragment is forbidden" From bebb1e1ad1aad847caddd9080a3888b24246c834 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:06:52 -0700 Subject: [PATCH 176/229] test(core): cover malformed BiDi endpoint authorities --- .../tests/webdriver_bidi_websocket_endpoint.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs index fb47c4f3e..7dca568e5 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -71,6 +71,22 @@ fn remote_or_ambiguous_authorities_fail_closed() { } } +#[test] +fn malformed_loopback_authority_edge_cases_fail_closed() { + for endpoint in [ + format!("ws://[::1:9515/session/{SESSION_ID}"), + format!("ws://[::1]:/session/{SESSION_ID}"), + format!("ws://[0:0:0:0:0:0:0:1]:9515/session/{SESSION_ID}"), + format!("ws://localhost:/session/{SESSION_ID}"), + format!("ws://local_host:9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority) + )); + } +} + #[test] fn port_and_session_resource_are_canonical_and_bounded() { for endpoint in [ From 805412826ff7e131fcb2e433be8eefbe62778166 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:08:18 -0700 Subject: [PATCH 177/229] fix(core): remove unreachable IPv4 endpoint branch --- .../originweave-core/src/webdriver_bidi_websocket_endpoint.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs index 2b266c679..3a555493b 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -88,9 +88,6 @@ impl WebDriverBiDiWebSocketEndpoint { if host_text == "localhost" { (host_text.to_owned(), port_text) } else if let Ok(ip) = host_text.parse::() { - if ip.to_string() != host_text { - return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); - } if !ip.is_loopback() { return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); } From 42bfbed8a7268aaf60c5be3dc1f06b7df74107c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:16:19 -0700 Subject: [PATCH 178/229] test(core): cover empty BiDi endpoint authority --- .../tests/webdriver_bidi_websocket_endpoint.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs index 7dca568e5..3549d293b 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -57,6 +57,7 @@ fn remote_or_ambiguous_authorities_fail_closed() { } for endpoint in [ + format!("ws:///session/{SESSION_ID}"), format!("ws://user@localhost:9515/session/{SESSION_ID}"), format!("ws://localhost/session/{SESSION_ID}"), format!("ws://::1:9515/session/{SESSION_ID}"), @@ -185,4 +186,4 @@ fn endpoint_error_contract_is_deterministic_and_source_free() { assert!(!error.to_string().is_empty()); assert!(error.source().is_none()); } -} +} \ No newline at end of file From bc042eeab9345c000012bea2c9941b3bd1d45b71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:36:11 -0700 Subject: [PATCH 179/229] test(core): format BiDi WebSocket endpoint regressions --- .../originweave-core/tests/webdriver_bidi_websocket_endpoint.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs index 3549d293b..ecf5df6b6 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -186,4 +186,4 @@ fn endpoint_error_contract_is_deterministic_and_source_free() { assert!(!error.to_string().is_empty()); assert!(error.source().is_none()); } -} \ No newline at end of file +} From 5d7906a8bfc17ff2abbbb499d9f3be4ef77f297e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:11:54 -0700 Subject: [PATCH 180/229] test(core): require exact BiDi endpoint session correlation --- ...iver_bidi_websocket_session_correlation.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs new file mode 100644 index 000000000..7945cf629 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs @@ -0,0 +1,72 @@ +use std::error::Error; + +use originweave_core::{ + WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointCorrelationError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const OTHER_SESSION_ID: &str = "11234567-89ab-cdef-0123-456789abcdef"; + +fn endpoint() -> WebDriverBiDiWebSocketEndpoint { + let result = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://127.0.0.1:9515/session/{SESSION_ID}" + )); + assert!(result.is_ok(), "{result:?}"); + let Ok(endpoint) = result else { + unreachable!("asserted valid endpoint") + }; + endpoint +} + +#[test] +fn exact_session_identity_correlation_preserves_bounded_endpoint_metadata() { + let result = endpoint().correlate_session_id(SESSION_ID); + assert!(result.is_ok(), "{result:?}"); + let Ok(correlated) = result else { + return; + }; + + assert_eq!( + correlated.as_str(), + format!("ws://127.0.0.1:9515/session/{SESSION_ID}") + ); + assert!(!correlated.is_secure()); + assert_eq!(correlated.host(), "127.0.0.1"); + assert_eq!(correlated.port(), 9515); + assert_eq!(correlated.session_id(), SESSION_ID); +} + +#[test] +fn a_different_canonical_session_identity_fails_closed() { + assert!(matches!( + endpoint().correlate_session_id(OTHER_SESSION_ID), + Err(WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch) + )); +} + +#[test] +fn malformed_expected_session_identity_is_rejected_before_comparison() { + for expected in [ + "", + "01234567-89ab-cdef-0123-456789abcdeF", + "0123456789ab-cdef-0123-456789abcdef", + "01234567-89ab-cdef-0123-456789abcdeg", + "01234567_89ab-cdef-0123-456789abcdef", + ] { + assert!(matches!( + endpoint().correlate_session_id(expected), + Err(WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId) + )); + } +} + +#[test] +fn session_correlation_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId, + WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} From 96fdd9dc9eeb54a03b9392514aa4304811ed36ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:16:29 -0700 Subject: [PATCH 181/229] style(core): apply canonical BiDi session-correlation formatting --- .../tests/webdriver_bidi_websocket_session_correlation.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs index 7945cf629..074faf57e 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs @@ -8,9 +8,8 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const OTHER_SESSION_ID: &str = "11234567-89ab-cdef-0123-456789abcdef"; fn endpoint() -> WebDriverBiDiWebSocketEndpoint { - let result = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://127.0.0.1:9515/session/{SESSION_ID}" - )); + let result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://127.0.0.1:9515/session/{SESSION_ID}")); assert!(result.is_ok(), "{result:?}"); let Ok(endpoint) = result else { unreachable!("asserted valid endpoint") From c4d2e4d77d6bab17dfec6b0353f1bf8c2b28f86c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:20:20 -0700 Subject: [PATCH 182/229] feat(core): correlate BiDi endpoint with exact session --- .../src/webdriver_bidi_websocket_endpoint.rs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs index 3a555493b..6b8955a58 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -23,6 +23,17 @@ pub struct WebDriverBiDiWebSocketEndpoint { session_id: String, } +/// One admitted WebDriver BiDi WebSocket endpoint correlated to an expected session id. +/// +/// Correlation proves only that the endpoint resource and the caller-supplied expected session id +/// contain the same canonical UUID text. The expected session id must itself come from a trusted +/// session-creation boundary. This value does not authenticate Chromium, ChromeDriver, the caller, +/// the operating-system peer, TLS, policy, or Agent authority, and it does not establish a socket. +#[derive(Debug, PartialEq, Eq)] +pub struct CorrelatedWebDriverBiDiWebSocketEndpoint { + endpoint: WebDriverBiDiWebSocketEndpoint, +} + impl WebDriverBiDiWebSocketEndpoint { /// Admit one bounded canonical first-fixture WebDriver BiDi endpoint. pub fn new(value: &str) -> Result { @@ -128,6 +139,30 @@ impl WebDriverBiDiWebSocketEndpoint { }) } + /// Correlate this endpoint resource to one exact expected WebDriver session id. + /// + /// The endpoint is consumed so downstream connection code can require the correlated type and + /// cannot accidentally retain an uncorrelated copy. This comparison does not establish that the + /// caller-supplied expected id is authentic; the caller must obtain that id from its trusted + /// session-creation boundary. + pub fn correlate_session_id( + self, + expected_session_id: &str, + ) -> Result< + CorrelatedWebDriverBiDiWebSocketEndpoint, + WebDriverBiDiWebSocketEndpointCorrelationError, + > { + if !is_canonical_session_uuid(expected_session_id) { + return Err( + WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId, + ); + } + if self.session_id != expected_session_id { + return Err(WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch); + } + Ok(CorrelatedWebDriverBiDiWebSocketEndpoint { endpoint: self }) + } + /// Return the exact admitted endpoint text. #[must_use] pub fn as_str(&self) -> &str { @@ -159,6 +194,38 @@ impl WebDriverBiDiWebSocketEndpoint { } } +impl CorrelatedWebDriverBiDiWebSocketEndpoint { + /// Return the exact admitted endpoint text. + #[must_use] + pub fn as_str(&self) -> &str { + self.endpoint.as_str() + } + + /// Return whether the endpoint uses `wss` rather than `ws`. + #[must_use] + pub const fn is_secure(&self) -> bool { + self.endpoint.is_secure() + } + + /// Return the canonical loopback listener host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + self.endpoint.host() + } + + /// Return the explicit nonzero listener port. + #[must_use] + pub const fn port(&self) -> u16 { + self.endpoint.port() + } + + /// Return the exact session id proven equal to the caller-supplied expected session id. + #[must_use] + pub fn session_id(&self) -> &str { + self.endpoint.session_id() + } +} + fn is_canonical_session_uuid(value: &str) -> bool { let bytes = value.as_bytes(); if bytes.len() != 36 { @@ -227,3 +294,28 @@ impl fmt::Display for WebDriverBiDiWebSocketEndpointAdmissionError { } impl std::error::Error for WebDriverBiDiWebSocketEndpointAdmissionError {} + +/// Fail-closed errors while correlating an admitted endpoint with an expected session id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketEndpointCorrelationError { + /// The expected session id is not one canonical lower-case UUID representation. + InvalidExpectedSessionId, + /// The endpoint resource belongs to a different canonical session id. + SessionIdMismatch, +} + +impl fmt::Display for WebDriverBiDiWebSocketEndpointCorrelationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidExpectedSessionId => { + "expected WebDriver session id is not a canonical lower-case UUID" + } + Self::SessionIdMismatch => { + "WebDriver BiDi WebSocket endpoint session id does not match the expected session" + } + }; + f.write_str(message) + } +} + +impl std::error::Error for WebDriverBiDiWebSocketEndpointCorrelationError {} From 45935f51eadbce5d64fc480abbc3165cc7e373cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:21:22 -0700 Subject: [PATCH 183/229] feat(core): export correlated BiDi endpoint contract --- crates/originweave-core/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 67753ed6c..27929c727 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -89,6 +89,7 @@ pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; pub use webdriver_bidi_websocket_endpoint::{ - MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, WebDriverBiDiWebSocketEndpoint, - WebDriverBiDiWebSocketEndpointAdmissionError, + CorrelatedWebDriverBiDiWebSocketEndpoint, MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, + WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointAdmissionError, + WebDriverBiDiWebSocketEndpointCorrelationError, }; From 70461f197bbef8fc9f81985d2f4b9e80619a4b2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:24:58 -0700 Subject: [PATCH 184/229] style(core): apply canonical session-correlation formatting --- .../originweave-core/src/webdriver_bidi_websocket_endpoint.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs index 6b8955a58..c95c309e1 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -153,9 +153,7 @@ impl WebDriverBiDiWebSocketEndpoint { WebDriverBiDiWebSocketEndpointCorrelationError, > { if !is_canonical_session_uuid(expected_session_id) { - return Err( - WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId, - ); + return Err(WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId); } if self.session_id != expected_session_id { return Err(WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch); From 4047cef971bfdc43e352fca7b8d98c3efee31f41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:29:43 -0700 Subject: [PATCH 185/229] docs(changelog): record BiDi session correlation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f87011269..3be520bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. - Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. - One consuming bounded WebDriver BiDi response-document correlation boundary that parses the exact admitted JSON document and feeds only its typed response kind and protocol-range id into the existing exact command-correlation gate, preserving nested parser/correlation error sources without authenticating transport or granting browser/Agent authority. +- Consuming WebDriver BiDi WebSocket endpoint/session correlation that validates one caller-supplied canonical session UUID and rejects exact session mismatches before later transport use; the correlated type preserves only bounded endpoint metadata and does not authenticate Chromium, ChromeDriver, the caller, or the socket peer. - 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. From 48ad304eb4c2a18848d5fc4fbf6e2969f5576bfb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:10:04 -0700 Subject: [PATCH 186/229] test(core): require explicit BiDi connect target --- ...webdriver_bidi_websocket_connect_target.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs new file mode 100644 index 000000000..b71957f9d --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs @@ -0,0 +1,69 @@ +use std::{error::Error, net::SocketAddr}; + +use originweave_core::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketConnectTargetError, + WebDriverBiDiWebSocketEndpoint, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn correlated(endpoint: &str) -> CorrelatedWebDriverBiDiWebSocketEndpoint { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + correlated +} + +#[test] +fn explicit_ipv4_loopback_becomes_exact_no_dns_connect_target() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(result.is_ok(), "{result:?}"); + let Ok(target) = result else { + return; + }; + + assert_eq!(target.socket_addr(), SocketAddr::from(([127, 0, 0, 1], 9515))); + assert!(!target.requires_tls()); + assert_eq!(target.session_id(), SESSION_ID); +} + +#[test] +fn explicit_ipv6_loopback_preserves_exact_destination_and_tls_requirement() { + let endpoint = format!("wss://[::1]:9443/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(result.is_ok(), "{result:?}"); + let Ok(target) = result else { + return; + }; + + assert_eq!(target.socket_addr(), SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9443))); + assert!(target.requires_tls()); + assert_eq!(target.session_id(), SESSION_ID); +} + +#[test] +fn localhost_name_never_silently_inherits_ambient_dns_authority() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + assert!(matches!( + correlated(&endpoint).into_explicit_connect_target(), + Err(WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired) + )); +} + +#[test] +fn connect_target_errors_are_deterministic_and_source_free() { + let error = WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired; + assert_eq!( + error.to_string(), + "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution" + ); + assert!(error.source().is_none()); +} From 3cc67111a08c5f5ae1c748bab795273a6cad4c89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:16:43 -0700 Subject: [PATCH 187/229] test(core): format explicit BiDi connect target regressions --- .../tests/webdriver_bidi_websocket_connect_target.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs index b71957f9d..d068b3d5a 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs @@ -30,7 +30,10 @@ fn explicit_ipv4_loopback_becomes_exact_no_dns_connect_target() { return; }; - assert_eq!(target.socket_addr(), SocketAddr::from(([127, 0, 0, 1], 9515))); + assert_eq!( + target.socket_addr(), + SocketAddr::from(([127, 0, 0, 1], 9515)) + ); assert!(!target.requires_tls()); assert_eq!(target.session_id(), SESSION_ID); } @@ -44,7 +47,10 @@ fn explicit_ipv6_loopback_preserves_exact_destination_and_tls_requirement() { return; }; - assert_eq!(target.socket_addr(), SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9443))); + assert_eq!( + target.socket_addr(), + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9443)) + ); assert!(target.requires_tls()); assert_eq!(target.session_id(), SESSION_ID); } From 2e9898dea986a54f5c4cbd0adfaf8b96adae8ded Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:17:50 -0700 Subject: [PATCH 188/229] feat(core): derive explicit BiDi loopback connect targets --- ...webdriver_bidi_websocket_connect_target.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs new file mode 100644 index 000000000..de4bf5a69 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs @@ -0,0 +1,89 @@ +//! Explicit no-DNS connection targets for correlated WebDriver BiDi endpoints. +//! +//! This boundary converts only literal loopback listener identities into exact socket metadata. +//! It deliberately refuses `localhost` so a later connector cannot silently inherit ambient DNS +//! authority from an admitted WebDriver endpoint. The resulting value does not open a socket, +//! authenticate a peer, negotiate TLS, perform a WebSocket handshake, or grant Agent authority. + +use std::{fmt, net::{Ipv4Addr, Ipv6Addr, SocketAddr}}; + +use crate::CorrelatedWebDriverBiDiWebSocketEndpoint; + +/// An exact loopback socket destination derived from one correlated WebDriver BiDi endpoint. +/// +/// The destination is inert connection metadata. It proves only that the already-admitted endpoint +/// named a literal loopback IP address, retained an explicit nonzero port, and was correlated to the +/// expected WebDriver session id. A runtime connector must independently enforce peer identity, +/// TLS, WebSocket, process, policy, and browser authority before using transport I/O. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiWebSocketConnectTarget { + socket_addr: SocketAddr, + requires_tls: bool, + session_id: String, +} + +impl WebDriverBiDiWebSocketConnectTarget { + /// Return the exact loopback socket destination without performing name resolution. + #[must_use] + pub const fn socket_addr(&self) -> SocketAddr { + self.socket_addr + } + + /// Return whether the admitted endpoint requires a TLS-protected WebSocket transport. + #[must_use] + pub const fn requires_tls(&self) -> bool { + self.requires_tls + } + + /// Return the exact WebDriver session id established by the preceding correlation boundary. + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } +} + +impl CorrelatedWebDriverBiDiWebSocketEndpoint { + /// Consume this correlated endpoint and derive one exact no-DNS loopback socket destination. + /// + /// Literal IPv4 and IPv6 loopback hosts become an exact [`SocketAddr`]. Any admitted host that + /// is not an IP literal—including `localhost`—fails closed so the caller must perform an + /// explicit, separately trusted name-resolution step rather than inheriting ambient resolver + /// authority. This method performs no DNS lookup, socket I/O, peer authentication, TLS, or + /// WebSocket handshake. + pub fn into_explicit_connect_target( + self, + ) -> Result { + let socket_addr = if let Ok(ipv4) = self.host().parse::() { + SocketAddr::from((ipv4, self.port())) + } else if let Ok(ipv6) = self.host().parse::() { + SocketAddr::from((ipv6, self.port())) + } else { + return Err(WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired); + }; + + Ok(WebDriverBiDiWebSocketConnectTarget { + socket_addr, + requires_tls: self.is_secure(), + session_id: self.session_id().to_owned(), + }) + } +} + +/// Fail-closed errors while deriving an explicit WebDriver BiDi socket destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketConnectTargetError { + /// The admitted endpoint used a host name and therefore requires explicit trusted resolution. + NameResolutionRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketConnectTargetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NameResolutionRequired => formatter.write_str( + "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiWebSocketConnectTargetError {} From aace3a8587ed7d295ff6f5dd240c3d0995f3a10f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:18:15 -0700 Subject: [PATCH 189/229] feat(core): export explicit BiDi connect target contract --- crates/originweave-core/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 27929c727..eaeecd064 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -37,6 +37,7 @@ mod webdriver_bidi_response_document; mod webdriver_bidi_response_document_correlation; mod webdriver_bidi_response_envelope; mod webdriver_bidi_result; +mod webdriver_bidi_websocket_connect_target; mod webdriver_bidi_websocket_endpoint; pub use browser_authority_registry::BrowserAuthorityRegistry; @@ -88,6 +89,9 @@ pub use webdriver_bidi_response_envelope::{ pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; +pub use webdriver_bidi_websocket_connect_target::{ + WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketConnectTargetError, +}; pub use webdriver_bidi_websocket_endpoint::{ CorrelatedWebDriverBiDiWebSocketEndpoint, MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointAdmissionError, From 2e43ad65d57fd32a6f4a78c386e19b507e8d10a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:18:46 -0700 Subject: [PATCH 190/229] style(core): format explicit BiDi connect target --- .../src/webdriver_bidi_websocket_connect_target.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs index de4bf5a69..26e7998b7 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs @@ -5,7 +5,10 @@ //! authority from an admitted WebDriver endpoint. The resulting value does not open a socket, //! authenticate a peer, negotiate TLS, perform a WebSocket handshake, or grant Agent authority. -use std::{fmt, net::{Ipv4Addr, Ipv6Addr, SocketAddr}}; +use std::{ + fmt, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, +}; use crate::CorrelatedWebDriverBiDiWebSocketEndpoint; From 6a21bedff31217372f63e0ef1a2f5b343fee749d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 01:04:05 -0700 Subject: [PATCH 191/229] test(core): require BiDi connect target changelog evidence --- ...ebdriver_bidi_connect_target_governance.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/test_webdriver_bidi_connect_target_governance.py diff --git a/tests/test_webdriver_bidi_connect_target_governance.py b/tests/test_webdriver_bidi_connect_target_governance.py new file mode 100644 index 000000000..b593727ff --- /dev/null +++ b/tests/test_webdriver_bidi_connect_target_governance.py @@ -0,0 +1,27 @@ +"""Governance regression for explicit WebDriver BiDi socket destinations.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +CHANGELOG = ROOT / "CHANGELOG.md" + + +class WebDriverBiDiConnectTargetGovernanceTests(unittest.TestCase): + """Keep the active no-DNS transport boundary visible in release evidence.""" + + def test_changelog_records_explicit_no_dns_connect_target_boundary(self) -> None: + """The production connect-target slice must have a truthful Unreleased record.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + self.assertIn( + "Explicit no-DNS WebDriver BiDi loopback connection targets", + changelog, + ) + self.assertIn("localhost", changelog) + self.assertIn("no socket I/O", changelog) + self.assertIn("no Agent authority", changelog) + + +if __name__ == "__main__": + unittest.main() From 7e303600a03e7d24a5d6df3237cd63f280ca9a23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 01:12:50 -0700 Subject: [PATCH 192/229] docs(core): record explicit BiDi connect target boundary --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be520bcd..64946b209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, normalizes capability-set identity independently of caller ordering, and exposes typed fail-closed capability requirements without granting browser, action, network, or secret authority by protocol kind alone. - Canonical OriginWeave protocol-version parsing for exact `originweave/.` syntax, with typed fail-closed rejection of malformed, ambiguous, overflowed, or noncanonical serialized generations; parsing does not negotiate compatibility or grant adapter authority. From dc5cfd8a55f1dee5677eedebb3e93585003d7b83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:07:52 -0700 Subject: [PATCH 193/229] test(core): preserve BiDi endpoint across resolver handoff --- ...webdriver_bidi_websocket_connect_target.rs | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs index d068b3d5a..85f26f658 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs @@ -58,15 +58,39 @@ fn explicit_ipv6_loopback_preserves_exact_destination_and_tls_requirement() { #[test] fn localhost_name_never_silently_inherits_ambient_dns_authority() { let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); assert!(matches!( - correlated(&endpoint).into_explicit_connect_target(), - Err(WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired) + &result, + Err(WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired { .. }) )); } +#[test] +fn name_resolution_failure_preserves_correlated_endpoint_for_trusted_resolver() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + let Err(error) = result else { + return; + }; + + assert_eq!(error.correlated_endpoint().as_str(), endpoint); + assert_eq!(error.correlated_endpoint().session_id(), SESSION_ID); + assert!(!error.correlated_endpoint().is_secure()); + assert_eq!(error.correlated_endpoint().port(), 9515); + + let recovered = error.into_correlated_endpoint(); + assert_eq!(recovered.as_str(), endpoint); + assert_eq!(recovered.session_id(), SESSION_ID); +} + #[test] fn connect_target_errors_are_deterministic_and_source_free() { - let error = WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired; + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + let Err(error) = result else { + return; + }; + assert_eq!( error.to_string(), "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution" From 3a9506c5b14113ff3fe86a84965876a549827759 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:09:36 -0700 Subject: [PATCH 194/229] fix(core): retain correlated BiDi endpoint on resolver handoff --- ...webdriver_bidi_websocket_connect_target.rs | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs index 26e7998b7..085335f45 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs @@ -2,8 +2,10 @@ //! //! This boundary converts only literal loopback listener identities into exact socket metadata. //! It deliberately refuses `localhost` so a later connector cannot silently inherit ambient DNS -//! authority from an admitted WebDriver endpoint. The resulting value does not open a socket, -//! authenticate a peer, negotiate TLS, perform a WebSocket handshake, or grant Agent authority. +//! authority from an admitted WebDriver endpoint. When explicit trusted name resolution is needed, +//! the typed error preserves the correlated endpoint instead of discarding its session evidence. +//! The resulting value does not open a socket, authenticate a peer, negotiate TLS, perform a +//! WebSocket handshake, or grant Agent authority. use std::{ fmt, @@ -51,8 +53,10 @@ impl CorrelatedWebDriverBiDiWebSocketEndpoint { /// Literal IPv4 and IPv6 loopback hosts become an exact [`SocketAddr`]. Any admitted host that /// is not an IP literal—including `localhost`—fails closed so the caller must perform an /// explicit, separately trusted name-resolution step rather than inheriting ambient resolver - /// authority. This method performs no DNS lookup, socket I/O, peer authentication, TLS, or - /// WebSocket handshake. + /// authority. The name-resolution-required error retains this correlated endpoint so that + /// trusted resolver handoff does not require reconstructing or recorrelation of session evidence. + /// This method performs no DNS lookup, socket I/O, peer authentication, TLS, or WebSocket + /// handshake. pub fn into_explicit_connect_target( self, ) -> Result { @@ -61,7 +65,11 @@ impl CorrelatedWebDriverBiDiWebSocketEndpoint { } else if let Ok(ipv6) = self.host().parse::() { SocketAddr::from((ipv6, self.port())) } else { - return Err(WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired); + return Err( + WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired { + correlated_endpoint: self, + }, + ); }; Ok(WebDriverBiDiWebSocketConnectTarget { @@ -73,16 +81,41 @@ impl CorrelatedWebDriverBiDiWebSocketEndpoint { } /// Fail-closed errors while deriving an explicit WebDriver BiDi socket destination. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub enum WebDriverBiDiWebSocketConnectTargetError { /// The admitted endpoint used a host name and therefore requires explicit trusted resolution. - NameResolutionRequired, + NameResolutionRequired { + /// The still-correlated endpoint that must be handed to a separately trusted resolver. + correlated_endpoint: CorrelatedWebDriverBiDiWebSocketEndpoint, + }, +} + +impl WebDriverBiDiWebSocketConnectTargetError { + /// Borrow the correlated endpoint preserved for an explicit trusted resolver handoff. + #[must_use] + pub const fn correlated_endpoint(&self) -> &CorrelatedWebDriverBiDiWebSocketEndpoint { + match self { + Self::NameResolutionRequired { + correlated_endpoint, + } => correlated_endpoint, + } + } + + /// Recover the correlated endpoint for an explicit trusted resolver handoff. + #[must_use] + pub fn into_correlated_endpoint(self) -> CorrelatedWebDriverBiDiWebSocketEndpoint { + match self { + Self::NameResolutionRequired { + correlated_endpoint, + } => correlated_endpoint, + } + } } impl fmt::Display for WebDriverBiDiWebSocketConnectTargetError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::NameResolutionRequired => formatter.write_str( + Self::NameResolutionRequired { .. } => formatter.write_str( "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution", ), } From d34c528d5cbc06403c36feea97147b4f0cf2d262 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:44:39 -0700 Subject: [PATCH 195/229] test(core): require exact BiDi socket peer verification --- ...webdriver_bidi_socket_peer_verification.rs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs b/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs new file mode 100644 index 000000000..dd20a4ef1 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs @@ -0,0 +1,107 @@ +use std::{error::Error, net::SocketAddr}; + +use originweave_core::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, WebDriverBiDiSocketPeerVerificationError, + WebDriverBiDiWebSocketEndpoint, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn connect_target(endpoint: &str) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + + let correlated: Result = + admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted literal loopback target") + }; + target +} + +#[test] +fn exact_connected_peer_becomes_verified_transport_metadata() { + let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let peer = SocketAddr::from(([127, 0, 0, 1], 9443)); + + let verified = target.verify_connected_peer(peer); + assert!(verified.is_ok(), "{verified:?}"); + let Ok(verified) = verified else { + return; + }; + + assert_eq!(verified.socket_addr(), peer); + assert!(verified.requires_tls()); + assert_eq!(verified.session_id(), SESSION_ID); +} + +#[test] +fn connected_peer_with_wrong_port_fails_closed() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9516)); + + let result = target.verify_connected_peer(actual); + assert_eq!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { + expected: SocketAddr::from(([127, 0, 0, 1], 9515)), + actual, + }) + ); +} + +#[test] +fn connected_peer_with_different_address_fails_closed() { + let endpoint = format!("ws://[::1]:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9515)); + + let result = target.verify_connected_peer(actual); + assert!(matches!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { .. }) + )); +} + +#[test] +fn non_loopback_observed_peer_cannot_inherit_approved_loopback_authority() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([192, 0, 2, 10], 9515)); + + let result = target.verify_connected_peer(actual); + assert!(matches!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { .. }) + )); +} + +#[test] +fn peer_mismatch_error_is_deterministic_and_source_free() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9516)); + + let result = target.verify_connected_peer(actual); + let Err(error) = result else { + return; + }; + + assert_eq!( + error.to_string(), + "connected WebDriver BiDi socket peer does not match the approved destination" + ); + assert!(error.source().is_none()); +} From d7cf226ba92f404262d4bab02c3fa622e676f459 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:49:13 -0700 Subject: [PATCH 196/229] feat(core): verify exact BiDi socket peer --- ...webdriver_bidi_websocket_connect_target.rs | 89 ++++++++++++++++++- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs index 085335f45..5002731db 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs @@ -4,8 +4,9 @@ //! It deliberately refuses `localhost` so a later connector cannot silently inherit ambient DNS //! authority from an admitted WebDriver endpoint. When explicit trusted name resolution is needed, //! the typed error preserves the correlated endpoint instead of discarding its session evidence. -//! The resulting value does not open a socket, authenticate a peer, negotiate TLS, perform a -//! WebSocket handshake, or grant Agent authority. +//! A separately observed connected peer must also match the approved socket destination exactly +//! before it becomes verified transport metadata. These values do not open a socket, authenticate +//! a process, negotiate TLS, perform a WebSocket handshake, or grant Agent authority. use std::{ fmt, @@ -18,8 +19,9 @@ use crate::CorrelatedWebDriverBiDiWebSocketEndpoint; /// /// The destination is inert connection metadata. It proves only that the already-admitted endpoint /// named a literal loopback IP address, retained an explicit nonzero port, and was correlated to the -/// expected WebDriver session id. A runtime connector must independently enforce peer identity, -/// TLS, WebSocket, process, policy, and browser authority before using transport I/O. +/// expected WebDriver session id. A runtime connector must independently establish a connection and +/// verify its observed peer before treating that transport as the approved destination. TLS, +/// WebSocket, process, policy, and browser authority remain separate boundaries. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WebDriverBiDiWebSocketConnectTarget { socket_addr: SocketAddr, @@ -45,8 +47,87 @@ impl WebDriverBiDiWebSocketConnectTarget { pub fn session_id(&self) -> &str { &self.session_id } + + /// Consume this approved destination and verify one observed connected socket peer exactly. + /// + /// Matching requires the complete [`SocketAddr`]—IP address and port—to equal the approved + /// no-DNS destination. A mismatch consumes the target and fails closed, preventing a connector + /// from accidentally reusing the same authority after observing a different peer. Success + /// produces inert verified-peer metadata only; it does not authenticate an OS process, + /// negotiate TLS, perform a WebSocket handshake, or grant browser/Agent authority. + pub fn verify_connected_peer( + self, + observed_peer: SocketAddr, + ) -> Result { + let expected = self.socket_addr; + if observed_peer != expected { + return Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { + expected, + actual: observed_peer, + }); + } + + Ok(VerifiedWebDriverBiDiSocketPeer { + connect_target: self, + }) + } +} + +/// Inert metadata proving that a connected peer exactly matched the approved BiDi destination. +/// +/// This value carries only the destination, TLS requirement, and correlated WebDriver session id +/// already established by preceding boundaries. It does not prove process identity, TLS peer +/// identity, WebSocket protocol state, browser authenticity, policy authorization, or Agent action +/// authority. +#[derive(Debug, PartialEq, Eq)] +pub struct VerifiedWebDriverBiDiSocketPeer { + connect_target: WebDriverBiDiWebSocketConnectTarget, +} + +impl VerifiedWebDriverBiDiSocketPeer { + /// Return the exact approved and observed socket peer address. + #[must_use] + pub const fn socket_addr(&self) -> SocketAddr { + self.connect_target.socket_addr() + } + + /// Return whether the correlated endpoint still requires TLS before WebSocket use. + #[must_use] + pub const fn requires_tls(&self) -> bool { + self.connect_target.requires_tls() + } + + /// Return the exact correlated WebDriver session id. + #[must_use] + pub fn session_id(&self) -> &str { + self.connect_target.session_id() + } +} + +/// Fail-closed errors while verifying an observed BiDi socket peer. +#[derive(Debug, PartialEq, Eq)] +pub enum WebDriverBiDiSocketPeerVerificationError { + /// The connected peer differed from the exact destination approved before connection. + PeerMismatch { + /// Exact socket address that the connector was authorized to reach. + expected: SocketAddr, + /// Socket peer address observed after connection. + actual: SocketAddr, + }, } +impl fmt::Display for WebDriverBiDiSocketPeerVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PeerMismatch { .. } => formatter.write_str( + "connected WebDriver BiDi socket peer does not match the approved destination", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiSocketPeerVerificationError {} + impl CorrelatedWebDriverBiDiWebSocketEndpoint { /// Consume this correlated endpoint and derive one exact no-DNS loopback socket destination. /// From 89fc9c3d5524c2bd76ef4cb4c27398acf5146b32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:49:45 -0700 Subject: [PATCH 197/229] feat(core): export verified BiDi peer contract --- crates/originweave-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index eaeecd064..b9d69c8b0 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -90,6 +90,7 @@ pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; pub use webdriver_bidi_websocket_connect_target::{ + VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiSocketPeerVerificationError, WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketConnectTargetError, }; pub use webdriver_bidi_websocket_endpoint::{ From f62d20496b1d2ec4aa257ef28944b2edc2d57ff2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:03:34 -0700 Subject: [PATCH 198/229] test(core): require BiDi socket-peer release evidence --- tests/test_webdriver_bidi_connect_target_governance.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_webdriver_bidi_connect_target_governance.py b/tests/test_webdriver_bidi_connect_target_governance.py index b593727ff..de7949478 100644 --- a/tests/test_webdriver_bidi_connect_target_governance.py +++ b/tests/test_webdriver_bidi_connect_target_governance.py @@ -22,6 +22,14 @@ def test_changelog_records_explicit_no_dns_connect_target_boundary(self) -> None self.assertIn("no socket I/O", changelog) self.assertIn("no Agent authority", changelog) + def test_changelog_records_exact_connected_peer_verification_boundary(self) -> None: + """Verified socket-peer metadata must be visible without overstating transport trust.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + self.assertIn("Exact WebDriver BiDi socket-peer verification", changelog) + self.assertIn("IP address and port", changelog) + self.assertIn("does not authenticate an OS process", changelog) + self.assertIn("does not negotiate TLS", changelog) + if __name__ == "__main__": unittest.main() From d52cdfefb8336df4edf4fb49d57ca99e1a6947e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:06:09 -0700 Subject: [PATCH 199/229] docs(changelog): record BiDi socket-peer verification --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64946b209..b8a348bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, normalizes capability-set identity independently of caller ordering, and exposes typed fail-closed capability requirements without granting browser, action, network, or secret authority by protocol kind alone. @@ -77,7 +78,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - `localhost` may approve only loopback addresses, while literal IPv4 and IPv6 origins may approve only the exact canonical address encoded in the origin. - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. - Every redirect rechecks target-origin authority, target-bound resolution, HTTPS downgrade, complete-target cycle state, and hop capacity before policy state changes. -- Direct TCP plans reject port zero, zero or excessive timeouts, excessive attempts, unapproved IPs, non-canonical IPv4-mapped IPv6 sockets, and IPv6 flow or scope metadata not represented in destination authority before connection I/O. +- Direct TCP plans reject port zero, zero or excessive timeouts, excessive attempts, excessive attempts, unapproved IPs, non-canonical IPv4-mapped IPv6 sockets, and IPv6 flow or scope metadata not represented in destination authority before connection I/O. - Direct connection code accepts only an explicit `SocketAddr`, never a hostname, and does not read proxy environment variables. - Established streams are discarded when peer inspection fails or the observed remote IP or port differs from the approved socket. - TLS accepts only an already verified direct stream, never a hostname or new socket, and requires the TLS origin to match the transport-authority origin exactly. From fa510a0e180b08f38f0e92920b16b874815504c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:08:26 -0700 Subject: [PATCH 200/229] fix(changelog): preserve direct TCP release wording --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a348bce..7ca383831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - `localhost` may approve only loopback addresses, while literal IPv4 and IPv6 origins may approve only the exact canonical address encoded in the origin. - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. - Every redirect rechecks target-origin authority, target-bound resolution, HTTPS downgrade, complete-target cycle state, and hop capacity before policy state changes. -- Direct TCP plans reject port zero, zero or excessive timeouts, excessive attempts, excessive attempts, unapproved IPs, non-canonical IPv4-mapped IPv6 sockets, and IPv6 flow or scope metadata not represented in destination authority before connection I/O. +- Direct TCP plans reject port zero, zero or excessive timeouts, excessive attempts, unapproved IPs, non-canonical IPv4-mapped IPv6 sockets, and IPv6 flow or scope metadata not represented in destination authority before connection I/O. - Direct connection code accepts only an explicit `SocketAddr`, never a hostname, and does not read proxy environment variables. - Established streams are discarded when peer inspection fails or the observed remote IP or port differs from the approved socket. - TLS accepts only an already verified direct stream, never a hostname or new socket, and requires the TLS origin to match the transport-authority origin exactly. From fcde9755057a38827c0efc4d1fbbf3a6d6b9608a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:30:54 -0700 Subject: [PATCH 201/229] test(network): require bounded BiDi loopback TCP connection --- .../tests/webdriver_bidi_tcp_connection.rs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs new file mode 100644 index 000000000..5cfd49783 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs @@ -0,0 +1,100 @@ +use std::{ + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn connect_target( + endpoint: &str, +) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted literal loopback target") + }; + target +} + +#[test] +fn exact_loopback_target_opens_one_verified_bidi_tcp_stream() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + + let server = thread::spawn(move || listener.accept().map(|_| ())); + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + return; + }; + + assert_eq!(connection.verified_peer().socket_addr(), local_addr); + assert!(!connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_secs(1)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn bidi_tcp_plan_rejects_invalid_retry_settings_before_io() { + let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); + let zero_timeout = WebDriverBiDiTcpConnectionPlan::new( + connect_target(&endpoint), + Duration::ZERO, + 1, + ); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(&endpoint), + Duration::from_secs(1), + 0, + ); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} From aef0ac7763c1ed288bf17580162cf37e42348d04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:32:20 -0700 Subject: [PATCH 202/229] test(network): format BiDi TCP RED contract --- .../tests/webdriver_bidi_tcp_connection.rs | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs index 5cfd49783..bfc80457b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs +++ b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs @@ -1,19 +1,11 @@ -use std::{ - net::TcpListener, - thread, - time::Duration, -}; +use std::{net::TcpListener, thread, time::Duration}; use originweave_core::WebDriverBiDiWebSocketEndpoint; -use originweave_network::{ - WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, -}; +use originweave_network::{WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan}; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; -fn connect_target( - endpoint: &str, -) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { +fn connect_target(endpoint: &str) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); assert!(admitted.is_ok(), "{admitted:?}"); let Ok(admitted) = admitted else { @@ -78,21 +70,15 @@ fn exact_loopback_target_opens_one_verified_bidi_tcp_stream() { #[test] fn bidi_tcp_plan_rejects_invalid_retry_settings_before_io() { let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); - let zero_timeout = WebDriverBiDiTcpConnectionPlan::new( - connect_target(&endpoint), - Duration::ZERO, - 1, - ); + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::ZERO, 1); assert!(matches!( zero_timeout, Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) )); - let zero_attempts = WebDriverBiDiTcpConnectionPlan::new( - connect_target(&endpoint), - Duration::from_secs(1), - 0, - ); + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::from_secs(1), 0); assert!(matches!( zero_attempts, Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) From 1de304b0d944b7c1ac9412b7cc84d787cb34b0ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:35:18 -0700 Subject: [PATCH 203/229] feat(network): connect exact BiDi loopback transport --- .../src/webdriver_bidi_connection.rs | 691 ++++++++++++++++++ 1 file changed, 691 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_connection.rs diff --git a/crates/originweave-network/src/webdriver_bidi_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs new file mode 100644 index 000000000..0be687193 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -0,0 +1,691 @@ +use std::{fmt, io, net::SocketAddr, net::TcpStream, time::Duration}; + +use originweave_core::{ + VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiSocketPeerVerificationError, + WebDriverBiDiWebSocketConnectTarget, +}; + +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +fn is_retryable_connect_error(kind: io::ErrorKind) -> bool { + matches!( + kind, + io::ErrorKind::TimedOut + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::Interrupted + ) +} + +/// Single-use authority to open one exact WebDriver BiDi loopback TCP destination. +/// +/// The plan consumes a session-correlated, no-DNS [`WebDriverBiDiWebSocketConnectTarget`] +/// produced by `originweave-core`. It applies the same bounded per-attempt timeout and retry +/// ceilings as the general direct-network connector, opens only the exact [`SocketAddr`] carried by +/// that target, and does not expose the stream until the operating system's observed peer has been +/// verified by the consumed target. +/// +/// This boundary performs no DNS lookup, proxy or PAC routing, Chromium/ChromeDriver process +/// authentication, TLS negotiation, WebSocket upgrade, BiDi framing, browser policy decision, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionPlan { + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, +} + +impl WebDriverBiDiTcpConnectionPlan { + /// Validate one bounded exact-loopback connection plan without performing network I/O. + pub fn new( + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, + ) -> Result { + if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT { + return Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }); + } + if maximum_attempts == 0 || maximum_attempts > MAX_CONNECTION_ATTEMPTS { + return Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: maximum_attempts, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }); + } + + Ok(Self { + target, + connect_timeout, + maximum_attempts, + }) + } + + /// Open the exact approved loopback socket and expose it only after peer verification. + /// + /// Retry is limited to transport errors that can occur transiently while a local browser driver + /// listener is becoming ready. Peer-inspection and peer-mismatch failures are integrity failures + /// and therefore fail closed without retry or fallback. + pub fn connect(self) -> Result { + self.connect_with(&SystemWebDriverBiDiConnector) + } + + fn connect_with( + self, + connector: &dyn WebDriverBiDiSocketConnector, + ) -> Result { + let socket_address = self.target.socket_addr(); + let connect_timeout = self.connect_timeout; + let maximum_attempts = self.maximum_attempts; + let target = self.target; + let mut attempt_number = 1; + + loop { + match connector.connect_timeout(&socket_address, connect_timeout) { + Ok(stream) => { + let observed_peer = connector.peer_addr(&stream).map_err(|source| { + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address, + attempt_number, + source, + } + })?; + let verified_peer = target.verify_connected_peer(observed_peer).map_err( + |source| WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number, + source, + }, + )?; + return Ok(WebDriverBiDiTcpConnection { + stream, + verified_peer, + attempt_number, + connect_timeout, + }); + } + Err(source) + if is_retryable_connect_error(source.kind()) + && attempt_number < maximum_attempts => + { + attempt_number += 1; + } + Err(source) => { + if source.kind() == io::ErrorKind::TimedOut { + return Err(WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address, + attempt_count: attempt_number, + connect_timeout, + source, + }); + } + return Err(WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address, + attempt_count: attempt_number, + source, + }); + } + } + } + } +} + +trait WebDriverBiDiSocketConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result; + + fn peer_addr(&self, stream: &TcpStream) -> io::Result; +} + +struct SystemWebDriverBiDiConnector; + +impl WebDriverBiDiSocketConnector for SystemWebDriverBiDiConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result { + TcpStream::connect_timeout(socket_address, timeout) + } + + fn peer_addr(&self, stream: &TcpStream) -> io::Result { + stream.peer_addr() + } +} + +/// Established WebDriver BiDi TCP stream whose observed peer matched the approved target exactly. +/// +/// This wrapper proves only exact transport-destination equality for one bounded connection. The +/// caller must still establish any required TLS channel, complete a WebSocket handshake, bind the +/// transport to the expected browser process/session, and pass separate action-policy checks. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnection { + stream: TcpStream, + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnection { + /// Borrow the verified TCP stream. + #[must_use] + pub const fn stream(&self) -> &TcpStream { + &self.stream + } + + /// Borrow the session-correlated exact peer evidence consumed by this connection. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing this connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } +} + +/// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. +#[derive(Debug)] +pub enum WebDriverBiDiTcpConnectionError { + /// The requested timeout was zero or exceeded [`MAX_CONNECT_TIMEOUT`]. + InvalidConnectTimeout { + /// The rejected timeout. + connect_timeout: Duration, + /// The largest accepted per-attempt timeout. + maximum_timeout: Duration, + }, + /// The requested attempt count was outside `1..=MAX_CONNECTION_ATTEMPTS`. + InvalidAttemptCount { + /// The rejected attempt count. + attempt_count: u8, + /// The largest accepted attempt count. + maximum_attempts: u8, + }, + /// The final bounded connection attempt timed out. + ConnectionTimedOut { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Per-attempt timeout used by the plan. + connect_timeout: Duration, + /// Final operating-system timeout error. + source: io::Error, + }, + /// The final bounded connection attempt failed without a timeout. + ConnectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Final operating-system connection error. + source: io::Error, + }, + /// The established stream did not reveal an operating-system peer address. + PeerInspectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// One-based attempt that established the stream. + attempt_number: u8, + /// Operating-system peer-inspection error. + source: io::Error, + }, + /// The established stream reported a peer other than the exact approved BiDi target. + PeerMismatch { + /// One-based attempt that established the stream. + attempt_number: u8, + /// Typed core peer-verification failure preserving expected and actual socket addresses. + source: WebDriverBiDiSocketPeerVerificationError, + }, +} + +impl WebDriverBiDiTcpConnectionError { + /// Return the number of transport attempts associated with this failure, when applicable. + #[must_use] + pub const fn attempt_count(&self) -> Option { + match self { + Self::ConnectionTimedOut { attempt_count, .. } + | Self::ConnectionFailed { attempt_count, .. } => Some(*attempt_count), + Self::PeerInspectionFailed { attempt_number, .. } + | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} + +impl fmt::Display for WebDriverBiDiTcpConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConnectTimeout { + connect_timeout, + maximum_timeout, + } => write!( + formatter, + "WebDriver BiDi connect timeout {connect_timeout:?} is outside 1ns..={maximum_timeout:?}", + ), + Self::InvalidAttemptCount { + attempt_count, + maximum_attempts, + } => write!( + formatter, + "WebDriver BiDi connection attempt count {attempt_count} is outside 1..={maximum_attempts}", + ), + Self::ConnectionTimedOut { + socket_address, + attempt_count, + connect_timeout, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} timed out after {attempt_count} attempts with per-attempt timeout {connect_timeout:?}", + ), + Self::ConnectionFailed { + socket_address, + attempt_count, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} failed after {attempt_count} attempts", + ), + Self::PeerInspectionFailed { + socket_address, + attempt_number, + .. + } => write!( + formatter, + "WebDriver BiDi TCP peer inspection failed for {socket_address} on attempt {attempt_number}", + ), + Self::PeerMismatch { attempt_number, .. } => write!( + formatter, + "WebDriver BiDi TCP peer did not match the approved target on attempt {attempt_number}", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiTcpConnectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConnectionTimedOut { source, .. } + | Self::ConnectionFailed { source, .. } + | Self::PeerInspectionFailed { source, .. } => Some(source), + Self::PeerMismatch { source, .. } => Some(source), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + error::Error, + net::{TcpListener, TcpStream}, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + use super::*; + + const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + const SOCKET_ADDRESS: SocketAddr = SocketAddr::from(([127, 0, 0, 1], 9515)); + + enum ConnectOutcome { + Success(TcpStream), + Error(io::ErrorKind), + } + + enum PeerOutcome { + Address(SocketAddr), + Error(io::ErrorKind), + } + + struct FakeConnector { + connect_outcomes: RefCell>, + peer_outcomes: RefCell>, + connect_calls: Cell, + peer_calls: Cell, + } + + impl FakeConnector { + fn new(connect_outcomes: Vec, peer_outcomes: Vec) -> Self { + Self { + connect_outcomes: RefCell::new(connect_outcomes.into()), + peer_outcomes: RefCell::new(peer_outcomes.into()), + connect_calls: Cell::new(0), + peer_calls: Cell::new(0), + } + } + } + + impl WebDriverBiDiSocketConnector for FakeConnector { + fn connect_timeout( + &self, + _socket_address: &SocketAddr, + _timeout: Duration, + ) -> io::Result { + self.connect_calls.set(self.connect_calls.get() + 1); + match self + .connect_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a connection outcome") + { + ConnectOutcome::Success(stream) => Ok(stream), + ConnectOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } + + fn peer_addr(&self, _stream: &TcpStream) -> io::Result { + self.peer_calls.set(self.peer_calls.get() + 1); + match self + .peer_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a peer outcome") + { + PeerOutcome::Address(address) => Ok(address), + PeerOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn loopback_stream() -> TcpStream { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener.local_addr().expect("read loopback listener address"); + let client = TcpStream::connect(address).expect("connect loopback client"); + let (server, _) = listener.accept().expect("accept loopback client"); + drop(server); + client + } + + fn connect_target(secure: bool) -> WebDriverBiDiWebSocketConnectTarget { + let scheme = if secure { "wss" } else { "ws" }; + let endpoint = format!("{scheme}://127.0.0.1:9515/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint).expect("admit endpoint"); + let correlated = admitted + .correlate_session_id(SESSION_ID) + .expect("correlate endpoint"); + correlated + .into_explicit_connect_target() + .expect("derive explicit connect target") + } + + fn plan(maximum_attempts: u8) -> WebDriverBiDiTcpConnectionPlan { + WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + maximum_attempts, + ) + .expect("valid test plan") + } + + #[test] + fn validates_timeout_and_attempt_bounds_before_io() { + let zero_timeout = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::ZERO, + 1, + ); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let excessive_timeout = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + MAX_CONNECT_TIMEOUT + Duration::from_nanos(1), + 1, + ); + assert!(matches!( + excessive_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + 0, + ); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); + + let excessive_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + MAX_CONNECTION_ATTEMPTS + 1, + ); + assert!(matches!( + excessive_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); + } + + #[test] + fn verified_peer_is_required_before_stream_exposure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(SOCKET_ADDRESS)], + ); + let connection = WebDriverBiDiTcpConnectionPlan::new( + connect_target(true), + Duration::from_millis(250), + 1, + ) + .expect("valid plan") + .connect_with(&connector) + .expect("verified connection"); + + assert!(connection.stream().peer_addr().is_ok()); + assert_eq!(connection.verified_peer().socket_addr(), SOCKET_ADDRESS); + assert!(connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_millis(250)); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + } + + #[test] + fn all_recoverable_connect_kinds_can_retry_once() { + for kind in [ + io::ErrorKind::TimedOut, + io::ErrorKind::ConnectionRefused, + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + io::ErrorKind::Interrupted, + ] { + assert!(is_retryable_connect_error(kind)); + let connector = FakeConnector::new( + vec![ + ConnectOutcome::Error(kind), + ConnectOutcome::Success(loopback_stream()), + ], + vec![PeerOutcome::Address(SOCKET_ADDRESS)], + ); + let connection = plan(2) + .connect_with(&connector) + .expect("second bounded attempt succeeds"); + assert_eq!(connection.attempt_number(), 2); + assert_eq!(connector.connect_calls.get(), 2); + assert_eq!(connector.peer_calls.get(), 1); + } + assert!(!is_retryable_connect_error(io::ErrorKind::PermissionDenied)); + } + + #[test] + fn final_timeout_preserves_source_and_attempt_count() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::TimedOut)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("timeout must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); + assert_eq!(error.attempt_count(), Some(1)); + } + + #[test] + fn exhausted_retryable_non_timeout_error_is_connection_failure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::ConnectionRefused)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("refusal must fail after the bounded final attempt"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); + } + + #[test] + fn non_retryable_connection_error_fails_without_retry() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::PermissionDenied)], + Vec::new(), + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("permission failure must not retry"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + } + + #[test] + fn peer_inspection_failure_is_not_retried() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Error(io::ErrorKind::NotConnected)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer inspection failure must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); + } + + #[test] + fn peer_mismatch_is_not_retried_or_converted_to_success() { + let wrong_peer = SocketAddr::from(([127, 0, 0, 1], 9516)); + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(wrong_peer)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer mismatch must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); + } + + #[test] + fn error_display_source_and_attempt_contracts_cover_every_variant() { + let mismatch = connect_target(false) + .verify_connected_peer(SocketAddr::from(([127, 0, 0, 1], 9516))) + .expect_err("wrong peer must fail"); + let errors = [ + WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout: Duration::ZERO, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }, + WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: 0, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address: SOCKET_ADDRESS, + attempt_count: 2, + connect_timeout: Duration::from_millis(250), + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address: SOCKET_ADDRESS, + attempt_count: 3, + source: io::Error::from(io::ErrorKind::ConnectionRefused), + }, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address: SOCKET_ADDRESS, + attempt_number: 1, + source: io::Error::from(io::ErrorKind::NotConnected), + }, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + source: mismatch, + }, + ]; + + let messages: Vec = errors.iter().map(ToString::to_string).collect(); + assert!(messages[0].contains("outside 1ns")); + assert!(messages[1].contains("attempt count 0")); + assert!(messages[2].contains("timed out after 2 attempts")); + assert!(messages[3].contains("failed after 3 attempts")); + assert!(messages[4].contains("peer inspection failed")); + assert!(messages[5].contains("did not match the approved target")); + + assert_eq!(errors[0].attempt_count(), None); + assert_eq!(errors[1].attempt_count(), None); + assert_eq!(errors[2].attempt_count(), Some(2)); + assert_eq!(errors[3].attempt_count(), Some(3)); + assert_eq!(errors[4].attempt_count(), Some(1)); + assert_eq!(errors[5].attempt_count(), Some(1)); + assert!(errors[0].source().is_none()); + assert!(errors[1].source().is_none()); + assert!(errors[2].source().is_some()); + assert!(errors[3].source().is_some()); + assert!(errors[4].source().is_some()); + assert!(errors[5].source().is_some()); + } +} From ccb7d31dfe7654bab800d463c2391cc1a19c7d74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:36:54 -0700 Subject: [PATCH 204/229] feat(network): export bounded BiDi TCP transport --- crates/originweave-network/src/lib.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index d5b26c1c3..67f85c973 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -1,15 +1,22 @@ //! Direct-only policy-bound TCP connection authority for OriginWeave. //! -//! The crate consumes a validated connection plan, opens one exact socket -//! address without hostname resolution or proxy inheritance, verifies the -//! operating-system peer, and emits credential-free evidence. +//! The crate consumes validated connection plans, opens exact socket addresses +//! without hostname resolution or proxy inheritance, verifies operating-system +//! peers before exposing transport I/O, and emits credential-free evidence. +//! It also bridges a session-correlated WebDriver BiDi loopback target from +//! `originweave-core` into one bounded exact TCP connection without granting +//! browser, WebSocket, TLS, policy, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; +mod webdriver_bidi_connection; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, NetworkError, SocketConnectionEvidence, }; +pub use webdriver_bidi_connection::{ + WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, +}; From b4ce10df0f3a76794a08b91b64cf242f9591c1e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:40:16 -0700 Subject: [PATCH 205/229] refactor(network): isolate BiDi transport errors --- .../src/webdriver_bidi_connection/error.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_connection/error.rs diff --git a/crates/originweave-network/src/webdriver_bidi_connection/error.rs b/crates/originweave-network/src/webdriver_bidi_connection/error.rs new file mode 100644 index 000000000..613f3101a --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/error.rs @@ -0,0 +1,136 @@ +use std::{fmt, io, net::SocketAddr, time::Duration}; + +use originweave_core::WebDriverBiDiSocketPeerVerificationError; + +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +/// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. +#[derive(Debug)] +pub enum WebDriverBiDiTcpConnectionError { + /// The requested timeout was zero or exceeded [`MAX_CONNECT_TIMEOUT`]. + InvalidConnectTimeout { + /// The rejected timeout. + connect_timeout: Duration, + /// The largest accepted per-attempt timeout. + maximum_timeout: Duration, + }, + /// The requested attempt count was outside `1..=MAX_CONNECTION_ATTEMPTS`. + InvalidAttemptCount { + /// The rejected attempt count. + attempt_count: u8, + /// The largest accepted attempt count. + maximum_attempts: u8, + }, + /// The final bounded connection attempt timed out. + ConnectionTimedOut { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Per-attempt timeout used by the plan. + connect_timeout: Duration, + /// Final operating-system timeout error. + source: io::Error, + }, + /// The final bounded connection attempt failed without a timeout. + ConnectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Final operating-system connection error. + source: io::Error, + }, + /// The established stream did not reveal an operating-system peer address. + PeerInspectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// One-based attempt that established the stream. + attempt_number: u8, + /// Operating-system peer-inspection error. + source: io::Error, + }, + /// The established stream reported a peer other than the exact approved BiDi target. + PeerMismatch { + /// One-based attempt that established the stream. + attempt_number: u8, + /// Typed core peer-verification failure preserving expected and actual socket addresses. + source: WebDriverBiDiSocketPeerVerificationError, + }, +} + +impl WebDriverBiDiTcpConnectionError { + /// Return the number of transport attempts associated with this failure, when applicable. + #[must_use] + pub const fn attempt_count(&self) -> Option { + match self { + Self::ConnectionTimedOut { attempt_count, .. } + | Self::ConnectionFailed { attempt_count, .. } => Some(*attempt_count), + Self::PeerInspectionFailed { attempt_number, .. } + | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} + +impl fmt::Display for WebDriverBiDiTcpConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConnectTimeout { + connect_timeout, + maximum_timeout, + } => write!( + formatter, + "WebDriver BiDi connect timeout {connect_timeout:?} is outside 1ns..={maximum_timeout:?}", + ), + Self::InvalidAttemptCount { + attempt_count, + maximum_attempts, + } => write!( + formatter, + "WebDriver BiDi connection attempt count {attempt_count} is outside 1..={maximum_attempts}", + ), + Self::ConnectionTimedOut { + socket_address, + attempt_count, + connect_timeout, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} timed out after {attempt_count} attempts with per-attempt timeout {connect_timeout:?}", + ), + Self::ConnectionFailed { + socket_address, + attempt_count, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} failed after {attempt_count} attempts", + ), + Self::PeerInspectionFailed { + socket_address, + attempt_number, + .. + } => write!( + formatter, + "WebDriver BiDi TCP peer inspection failed for {socket_address} on attempt {attempt_number}", + ), + Self::PeerMismatch { attempt_number, .. } => write!( + formatter, + "WebDriver BiDi TCP peer did not match the approved target on attempt {attempt_number}", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiTcpConnectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConnectionTimedOut { source, .. } + | Self::ConnectionFailed { source, .. } + | Self::PeerInspectionFailed { source, .. } => Some(source), + Self::PeerMismatch { source, .. } => Some(source), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} From 8ed075c262df6b7338074f8eebbd748248628213 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:40:57 -0700 Subject: [PATCH 206/229] test(network): isolate BiDi transport resilience coverage --- .../src/webdriver_bidi_connection/tests.rs | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_connection/tests.rs diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs new file mode 100644 index 000000000..4720493f6 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs @@ -0,0 +1,367 @@ +#![allow(clippy::expect_used)] + +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + error::Error, + io, + net::{SocketAddr, TcpListener, TcpStream}, + time::Duration, +}; + +use originweave_core::{WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketEndpoint}; + +use super::{ + is_retryable_connect_error, WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, + WebDriverBiDiTcpConnectionPlan, +}; +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn socket_address() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 9515)) +} + +enum ConnectOutcome { + Success(TcpStream), + Error(io::ErrorKind), +} + +enum PeerOutcome { + Address(SocketAddr), + Error(io::ErrorKind), +} + +struct FakeConnector { + connect_outcomes: RefCell>, + peer_outcomes: RefCell>, + connect_calls: Cell, + peer_calls: Cell, +} + +impl FakeConnector { + fn new(connect_outcomes: Vec, peer_outcomes: Vec) -> Self { + Self { + connect_outcomes: RefCell::new(connect_outcomes.into()), + peer_outcomes: RefCell::new(peer_outcomes.into()), + connect_calls: Cell::new(0), + peer_calls: Cell::new(0), + } + } +} + +impl WebDriverBiDiSocketConnector for FakeConnector { + fn connect_timeout( + &self, + _socket_address: &SocketAddr, + _timeout: Duration, + ) -> io::Result { + self.connect_calls.set(self.connect_calls.get() + 1); + match self + .connect_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a connection outcome") + { + ConnectOutcome::Success(stream) => Ok(stream), + ConnectOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } + + fn peer_addr(&self, _stream: &TcpStream) -> io::Result { + self.peer_calls.set(self.peer_calls.get() + 1); + match self + .peer_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a peer outcome") + { + PeerOutcome::Address(address) => Ok(address), + PeerOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } +} + +fn loopback_stream() -> TcpStream { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener + .local_addr() + .expect("read loopback listener address"); + let client = TcpStream::connect(address).expect("connect loopback client"); + let (server, _) = listener.accept().expect("accept loopback client"); + drop(server); + client +} + +fn connect_target(secure: bool) -> WebDriverBiDiWebSocketConnectTarget { + let scheme = if secure { "wss" } else { "ws" }; + let endpoint = format!("{scheme}://127.0.0.1:9515/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint).expect("admit endpoint"); + let correlated = admitted + .correlate_session_id(SESSION_ID) + .expect("correlate endpoint"); + correlated + .into_explicit_connect_target() + .expect("derive explicit connect target") +} + +fn plan(maximum_attempts: u8) -> WebDriverBiDiTcpConnectionPlan { + WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + maximum_attempts, + ) + .expect("valid test plan") +} + +#[test] +fn validates_timeout_and_attempt_bounds_before_io() { + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::ZERO, 1); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let excessive_timeout = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + MAX_CONNECT_TIMEOUT + Duration::from_nanos(1), + 1, + ); + assert!(matches!( + excessive_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + 0, + ); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); + + let excessive_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + MAX_CONNECTION_ATTEMPTS + 1, + ); + assert!(matches!( + excessive_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} + +#[test] +fn verified_peer_is_required_before_stream_exposure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = WebDriverBiDiTcpConnectionPlan::new( + connect_target(true), + Duration::from_millis(250), + 1, + ) + .expect("valid plan") + .connect_with(&connector) + .expect("verified connection"); + + assert!(connection.stream().peer_addr().is_ok()); + assert_eq!(connection.verified_peer().socket_addr(), socket_address()); + assert!(connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_millis(250)); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); +} + +#[test] +fn all_recoverable_connect_kinds_can_retry_once() { + for kind in [ + io::ErrorKind::TimedOut, + io::ErrorKind::ConnectionRefused, + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + io::ErrorKind::Interrupted, + ] { + assert!(is_retryable_connect_error(kind)); + let connector = FakeConnector::new( + vec![ + ConnectOutcome::Error(kind), + ConnectOutcome::Success(loopback_stream()), + ], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = plan(2) + .connect_with(&connector) + .expect("second bounded attempt succeeds"); + assert_eq!(connection.attempt_number(), 2); + assert_eq!(connector.connect_calls.get(), 2); + assert_eq!(connector.peer_calls.get(), 1); + } + assert!(!is_retryable_connect_error(io::ErrorKind::PermissionDenied)); +} + +#[test] +fn final_timeout_preserves_source_and_attempt_count() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::TimedOut)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("timeout must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); + assert_eq!(error.attempt_count(), Some(1)); +} + +#[test] +fn exhausted_retryable_non_timeout_error_is_connection_failure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::ConnectionRefused)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("refusal must fail after the bounded final attempt"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); +} + +#[test] +fn non_retryable_connection_error_fails_without_retry() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::PermissionDenied)], + Vec::new(), + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("permission failure must not retry"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); +} + +#[test] +fn peer_inspection_failure_is_not_retried() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Error(io::ErrorKind::NotConnected)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer inspection failure must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn peer_mismatch_is_not_retried_or_converted_to_success() { + let wrong_peer = SocketAddr::from(([127, 0, 0, 1], 9516)); + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(wrong_peer)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer mismatch must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn error_display_source_and_attempt_contracts_cover_every_variant() { + let mismatch = connect_target(false) + .verify_connected_peer(SocketAddr::from(([127, 0, 0, 1], 9516))) + .expect_err("wrong peer must fail"); + let errors = [ + WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout: Duration::ZERO, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }, + WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: 0, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address: socket_address(), + attempt_count: 2, + connect_timeout: Duration::from_millis(250), + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address: socket_address(), + attempt_count: 3, + source: io::Error::from(io::ErrorKind::ConnectionRefused), + }, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address: socket_address(), + attempt_number: 1, + source: io::Error::from(io::ErrorKind::NotConnected), + }, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + source: mismatch, + }, + ]; + + let messages: Vec = errors.iter().map(ToString::to_string).collect(); + assert!(messages[0].contains("outside 1ns")); + assert!(messages[1].contains("attempt count 0")); + assert!(messages[2].contains("timed out after 2 attempts")); + assert!(messages[3].contains("failed after 3 attempts")); + assert!(messages[4].contains("peer inspection failed")); + assert!(messages[5].contains("did not match the approved target")); + + assert_eq!(errors[0].attempt_count(), None); + assert_eq!(errors[1].attempt_count(), None); + assert_eq!(errors[2].attempt_count(), Some(2)); + assert_eq!(errors[3].attempt_count(), Some(3)); + assert_eq!(errors[4].attempt_count(), Some(1)); + assert_eq!(errors[5].attempt_count(), Some(1)); + assert!(errors[0].source().is_none()); + assert!(errors[1].source().is_none()); + assert!(errors[2].source().is_some()); + assert!(errors[3].source().is_some()); + assert!(errors[4].source().is_some()); + assert!(errors[5].source().is_some()); +} From ec66b21bd4bcae0799a744961aa01a74c0ed66be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:41:36 -0700 Subject: [PATCH 207/229] refactor(network): keep BiDi transport boundary focused --- .../src/webdriver_bidi_connection.rs | 525 +----------------- 1 file changed, 20 insertions(+), 505 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs index 0be687193..edf750d7a 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -1,12 +1,20 @@ -use std::{fmt, io, net::SocketAddr, net::TcpStream, time::Duration}; - -use originweave_core::{ - VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiSocketPeerVerificationError, - WebDriverBiDiWebSocketConnectTarget, +use std::{ + io, + net::{SocketAddr, TcpStream}, + time::Duration, }; +use originweave_core::{VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiWebSocketConnectTarget}; + use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; +mod error; + +pub use error::WebDriverBiDiTcpConnectionError; + +#[cfg(test)] +mod tests; + fn is_retryable_connect_error(kind: io::ErrorKind) -> bool { matches!( kind, @@ -92,12 +100,13 @@ impl WebDriverBiDiTcpConnectionPlan { source, } })?; - let verified_peer = target.verify_connected_peer(observed_peer).map_err( - |source| WebDriverBiDiTcpConnectionError::PeerMismatch { - attempt_number, - source, - }, - )?; + let verified_peer = + target + .verify_connected_peer(observed_peer) + .map_err(|source| WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number, + source, + })?; return Ok(WebDriverBiDiTcpConnection { stream, verified_peer, @@ -195,497 +204,3 @@ impl WebDriverBiDiTcpConnection { self.connect_timeout } } - -/// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. -#[derive(Debug)] -pub enum WebDriverBiDiTcpConnectionError { - /// The requested timeout was zero or exceeded [`MAX_CONNECT_TIMEOUT`]. - InvalidConnectTimeout { - /// The rejected timeout. - connect_timeout: Duration, - /// The largest accepted per-attempt timeout. - maximum_timeout: Duration, - }, - /// The requested attempt count was outside `1..=MAX_CONNECTION_ATTEMPTS`. - InvalidAttemptCount { - /// The rejected attempt count. - attempt_count: u8, - /// The largest accepted attempt count. - maximum_attempts: u8, - }, - /// The final bounded connection attempt timed out. - ConnectionTimedOut { - /// Exact approved socket address submitted to the operating system. - socket_address: SocketAddr, - /// Number of attempts completed before failure. - attempt_count: u8, - /// Per-attempt timeout used by the plan. - connect_timeout: Duration, - /// Final operating-system timeout error. - source: io::Error, - }, - /// The final bounded connection attempt failed without a timeout. - ConnectionFailed { - /// Exact approved socket address submitted to the operating system. - socket_address: SocketAddr, - /// Number of attempts completed before failure. - attempt_count: u8, - /// Final operating-system connection error. - source: io::Error, - }, - /// The established stream did not reveal an operating-system peer address. - PeerInspectionFailed { - /// Exact approved socket address submitted to the operating system. - socket_address: SocketAddr, - /// One-based attempt that established the stream. - attempt_number: u8, - /// Operating-system peer-inspection error. - source: io::Error, - }, - /// The established stream reported a peer other than the exact approved BiDi target. - PeerMismatch { - /// One-based attempt that established the stream. - attempt_number: u8, - /// Typed core peer-verification failure preserving expected and actual socket addresses. - source: WebDriverBiDiSocketPeerVerificationError, - }, -} - -impl WebDriverBiDiTcpConnectionError { - /// Return the number of transport attempts associated with this failure, when applicable. - #[must_use] - pub const fn attempt_count(&self) -> Option { - match self { - Self::ConnectionTimedOut { attempt_count, .. } - | Self::ConnectionFailed { attempt_count, .. } => Some(*attempt_count), - Self::PeerInspectionFailed { attempt_number, .. } - | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), - Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, - } - } -} - -impl fmt::Display for WebDriverBiDiTcpConnectionError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidConnectTimeout { - connect_timeout, - maximum_timeout, - } => write!( - formatter, - "WebDriver BiDi connect timeout {connect_timeout:?} is outside 1ns..={maximum_timeout:?}", - ), - Self::InvalidAttemptCount { - attempt_count, - maximum_attempts, - } => write!( - formatter, - "WebDriver BiDi connection attempt count {attempt_count} is outside 1..={maximum_attempts}", - ), - Self::ConnectionTimedOut { - socket_address, - attempt_count, - connect_timeout, - .. - } => write!( - formatter, - "WebDriver BiDi TCP connection to {socket_address} timed out after {attempt_count} attempts with per-attempt timeout {connect_timeout:?}", - ), - Self::ConnectionFailed { - socket_address, - attempt_count, - .. - } => write!( - formatter, - "WebDriver BiDi TCP connection to {socket_address} failed after {attempt_count} attempts", - ), - Self::PeerInspectionFailed { - socket_address, - attempt_number, - .. - } => write!( - formatter, - "WebDriver BiDi TCP peer inspection failed for {socket_address} on attempt {attempt_number}", - ), - Self::PeerMismatch { attempt_number, .. } => write!( - formatter, - "WebDriver BiDi TCP peer did not match the approved target on attempt {attempt_number}", - ), - } - } -} - -impl std::error::Error for WebDriverBiDiTcpConnectionError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::ConnectionTimedOut { source, .. } - | Self::ConnectionFailed { source, .. } - | Self::PeerInspectionFailed { source, .. } => Some(source), - Self::PeerMismatch { source, .. } => Some(source), - Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, - } - } -} - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used)] - - use std::{ - cell::{Cell, RefCell}, - collections::VecDeque, - error::Error, - net::{TcpListener, TcpStream}, - }; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - use super::*; - - const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; - const SOCKET_ADDRESS: SocketAddr = SocketAddr::from(([127, 0, 0, 1], 9515)); - - enum ConnectOutcome { - Success(TcpStream), - Error(io::ErrorKind), - } - - enum PeerOutcome { - Address(SocketAddr), - Error(io::ErrorKind), - } - - struct FakeConnector { - connect_outcomes: RefCell>, - peer_outcomes: RefCell>, - connect_calls: Cell, - peer_calls: Cell, - } - - impl FakeConnector { - fn new(connect_outcomes: Vec, peer_outcomes: Vec) -> Self { - Self { - connect_outcomes: RefCell::new(connect_outcomes.into()), - peer_outcomes: RefCell::new(peer_outcomes.into()), - connect_calls: Cell::new(0), - peer_calls: Cell::new(0), - } - } - } - - impl WebDriverBiDiSocketConnector for FakeConnector { - fn connect_timeout( - &self, - _socket_address: &SocketAddr, - _timeout: Duration, - ) -> io::Result { - self.connect_calls.set(self.connect_calls.get() + 1); - match self - .connect_outcomes - .borrow_mut() - .pop_front() - .expect("test must provide a connection outcome") - { - ConnectOutcome::Success(stream) => Ok(stream), - ConnectOutcome::Error(kind) => Err(io::Error::from(kind)), - } - } - - fn peer_addr(&self, _stream: &TcpStream) -> io::Result { - self.peer_calls.set(self.peer_calls.get() + 1); - match self - .peer_outcomes - .borrow_mut() - .pop_front() - .expect("test must provide a peer outcome") - { - PeerOutcome::Address(address) => Ok(address), - PeerOutcome::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - fn loopback_stream() -> TcpStream { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); - let address = listener.local_addr().expect("read loopback listener address"); - let client = TcpStream::connect(address).expect("connect loopback client"); - let (server, _) = listener.accept().expect("accept loopback client"); - drop(server); - client - } - - fn connect_target(secure: bool) -> WebDriverBiDiWebSocketConnectTarget { - let scheme = if secure { "wss" } else { "ws" }; - let endpoint = format!("{scheme}://127.0.0.1:9515/session/{SESSION_ID}"); - let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint).expect("admit endpoint"); - let correlated = admitted - .correlate_session_id(SESSION_ID) - .expect("correlate endpoint"); - correlated - .into_explicit_connect_target() - .expect("derive explicit connect target") - } - - fn plan(maximum_attempts: u8) -> WebDriverBiDiTcpConnectionPlan { - WebDriverBiDiTcpConnectionPlan::new( - connect_target(false), - Duration::from_millis(250), - maximum_attempts, - ) - .expect("valid test plan") - } - - #[test] - fn validates_timeout_and_attempt_bounds_before_io() { - let zero_timeout = WebDriverBiDiTcpConnectionPlan::new( - connect_target(false), - Duration::ZERO, - 1, - ); - assert!(matches!( - zero_timeout, - Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) - )); - - let excessive_timeout = WebDriverBiDiTcpConnectionPlan::new( - connect_target(false), - MAX_CONNECT_TIMEOUT + Duration::from_nanos(1), - 1, - ); - assert!(matches!( - excessive_timeout, - Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) - )); - - let zero_attempts = WebDriverBiDiTcpConnectionPlan::new( - connect_target(false), - Duration::from_millis(250), - 0, - ); - assert!(matches!( - zero_attempts, - Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) - )); - - let excessive_attempts = WebDriverBiDiTcpConnectionPlan::new( - connect_target(false), - Duration::from_millis(250), - MAX_CONNECTION_ATTEMPTS + 1, - ); - assert!(matches!( - excessive_attempts, - Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) - )); - } - - #[test] - fn verified_peer_is_required_before_stream_exposure() { - let connector = FakeConnector::new( - vec![ConnectOutcome::Success(loopback_stream())], - vec![PeerOutcome::Address(SOCKET_ADDRESS)], - ); - let connection = WebDriverBiDiTcpConnectionPlan::new( - connect_target(true), - Duration::from_millis(250), - 1, - ) - .expect("valid plan") - .connect_with(&connector) - .expect("verified connection"); - - assert!(connection.stream().peer_addr().is_ok()); - assert_eq!(connection.verified_peer().socket_addr(), SOCKET_ADDRESS); - assert!(connection.verified_peer().requires_tls()); - assert_eq!(connection.verified_peer().session_id(), SESSION_ID); - assert_eq!(connection.attempt_number(), 1); - assert_eq!(connection.connect_timeout(), Duration::from_millis(250)); - assert_eq!(connector.connect_calls.get(), 1); - assert_eq!(connector.peer_calls.get(), 1); - } - - #[test] - fn all_recoverable_connect_kinds_can_retry_once() { - for kind in [ - io::ErrorKind::TimedOut, - io::ErrorKind::ConnectionRefused, - io::ErrorKind::ConnectionReset, - io::ErrorKind::ConnectionAborted, - io::ErrorKind::Interrupted, - ] { - assert!(is_retryable_connect_error(kind)); - let connector = FakeConnector::new( - vec![ - ConnectOutcome::Error(kind), - ConnectOutcome::Success(loopback_stream()), - ], - vec![PeerOutcome::Address(SOCKET_ADDRESS)], - ); - let connection = plan(2) - .connect_with(&connector) - .expect("second bounded attempt succeeds"); - assert_eq!(connection.attempt_number(), 2); - assert_eq!(connector.connect_calls.get(), 2); - assert_eq!(connector.peer_calls.get(), 1); - } - assert!(!is_retryable_connect_error(io::ErrorKind::PermissionDenied)); - } - - #[test] - fn final_timeout_preserves_source_and_attempt_count() { - let connector = FakeConnector::new( - vec![ConnectOutcome::Error(io::ErrorKind::TimedOut)], - Vec::new(), - ); - let error = plan(1) - .connect_with(&connector) - .expect_err("timeout must fail closed"); - assert!(matches!( - error, - WebDriverBiDiTcpConnectionError::ConnectionTimedOut { - attempt_count: 1, - .. - } - )); - assert!(error.source().is_some()); - assert_eq!(error.attempt_count(), Some(1)); - } - - #[test] - fn exhausted_retryable_non_timeout_error_is_connection_failure() { - let connector = FakeConnector::new( - vec![ConnectOutcome::Error(io::ErrorKind::ConnectionRefused)], - Vec::new(), - ); - let error = plan(1) - .connect_with(&connector) - .expect_err("refusal must fail after the bounded final attempt"); - assert!(matches!( - error, - WebDriverBiDiTcpConnectionError::ConnectionFailed { - attempt_count: 1, - .. - } - )); - assert!(error.source().is_some()); - } - - #[test] - fn non_retryable_connection_error_fails_without_retry() { - let connector = FakeConnector::new( - vec![ConnectOutcome::Error(io::ErrorKind::PermissionDenied)], - Vec::new(), - ); - let error = plan(MAX_CONNECTION_ATTEMPTS) - .connect_with(&connector) - .expect_err("permission failure must not retry"); - assert!(matches!( - error, - WebDriverBiDiTcpConnectionError::ConnectionFailed { - attempt_count: 1, - .. - } - )); - assert_eq!(connector.connect_calls.get(), 1); - } - - #[test] - fn peer_inspection_failure_is_not_retried() { - let connector = FakeConnector::new( - vec![ConnectOutcome::Success(loopback_stream())], - vec![PeerOutcome::Error(io::ErrorKind::NotConnected)], - ); - let error = plan(MAX_CONNECTION_ATTEMPTS) - .connect_with(&connector) - .expect_err("peer inspection failure must fail closed"); - assert!(matches!( - error, - WebDriverBiDiTcpConnectionError::PeerInspectionFailed { - attempt_number: 1, - .. - } - )); - assert_eq!(connector.connect_calls.get(), 1); - assert_eq!(connector.peer_calls.get(), 1); - assert!(error.source().is_some()); - } - - #[test] - fn peer_mismatch_is_not_retried_or_converted_to_success() { - let wrong_peer = SocketAddr::from(([127, 0, 0, 1], 9516)); - let connector = FakeConnector::new( - vec![ConnectOutcome::Success(loopback_stream())], - vec![PeerOutcome::Address(wrong_peer)], - ); - let error = plan(MAX_CONNECTION_ATTEMPTS) - .connect_with(&connector) - .expect_err("peer mismatch must fail closed"); - assert!(matches!( - error, - WebDriverBiDiTcpConnectionError::PeerMismatch { - attempt_number: 1, - .. - } - )); - assert_eq!(connector.connect_calls.get(), 1); - assert_eq!(connector.peer_calls.get(), 1); - assert!(error.source().is_some()); - } - - #[test] - fn error_display_source_and_attempt_contracts_cover_every_variant() { - let mismatch = connect_target(false) - .verify_connected_peer(SocketAddr::from(([127, 0, 0, 1], 9516))) - .expect_err("wrong peer must fail"); - let errors = [ - WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { - connect_timeout: Duration::ZERO, - maximum_timeout: MAX_CONNECT_TIMEOUT, - }, - WebDriverBiDiTcpConnectionError::InvalidAttemptCount { - attempt_count: 0, - maximum_attempts: MAX_CONNECTION_ATTEMPTS, - }, - WebDriverBiDiTcpConnectionError::ConnectionTimedOut { - socket_address: SOCKET_ADDRESS, - attempt_count: 2, - connect_timeout: Duration::from_millis(250), - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiTcpConnectionError::ConnectionFailed { - socket_address: SOCKET_ADDRESS, - attempt_count: 3, - source: io::Error::from(io::ErrorKind::ConnectionRefused), - }, - WebDriverBiDiTcpConnectionError::PeerInspectionFailed { - socket_address: SOCKET_ADDRESS, - attempt_number: 1, - source: io::Error::from(io::ErrorKind::NotConnected), - }, - WebDriverBiDiTcpConnectionError::PeerMismatch { - attempt_number: 1, - source: mismatch, - }, - ]; - - let messages: Vec = errors.iter().map(ToString::to_string).collect(); - assert!(messages[0].contains("outside 1ns")); - assert!(messages[1].contains("attempt count 0")); - assert!(messages[2].contains("timed out after 2 attempts")); - assert!(messages[3].contains("failed after 3 attempts")); - assert!(messages[4].contains("peer inspection failed")); - assert!(messages[5].contains("did not match the approved target")); - - assert_eq!(errors[0].attempt_count(), None); - assert_eq!(errors[1].attempt_count(), None); - assert_eq!(errors[2].attempt_count(), Some(2)); - assert_eq!(errors[3].attempt_count(), Some(3)); - assert_eq!(errors[4].attempt_count(), Some(1)); - assert_eq!(errors[5].attempt_count(), Some(1)); - assert!(errors[0].source().is_none()); - assert!(errors[1].source().is_none()); - assert!(errors[2].source().is_some()); - assert!(errors[3].source().is_some()); - assert!(errors[4].source().is_some()); - assert!(errors[5].source().is_some()); - } -} From 755d626ff6e2bf390b66a254261b8084d2e299ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:43:19 -0700 Subject: [PATCH 208/229] test(network): apply canonical BiDi resilience formatting --- .../src/webdriver_bidi_connection/tests.rs | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs index 4720493f6..e2d287ca1 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs @@ -12,8 +12,8 @@ use std::{ use originweave_core::{WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketEndpoint}; use super::{ - is_retryable_connect_error, WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, - WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, + is_retryable_connect_error, }; use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; @@ -134,11 +134,8 @@ fn validates_timeout_and_attempt_bounds_before_io() { Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) )); - let zero_attempts = WebDriverBiDiTcpConnectionPlan::new( - connect_target(false), - Duration::from_millis(250), - 0, - ); + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::from_millis(250), 0); assert!(matches!( zero_attempts, Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) @@ -161,14 +158,11 @@ fn verified_peer_is_required_before_stream_exposure() { vec![ConnectOutcome::Success(loopback_stream())], vec![PeerOutcome::Address(socket_address())], ); - let connection = WebDriverBiDiTcpConnectionPlan::new( - connect_target(true), - Duration::from_millis(250), - 1, - ) - .expect("valid plan") - .connect_with(&connector) - .expect("verified connection"); + let connection = + WebDriverBiDiTcpConnectionPlan::new(connect_target(true), Duration::from_millis(250), 1) + .expect("valid plan") + .connect_with(&connector) + .expect("verified connection"); assert!(connection.stream().peer_addr().is_ok()); assert_eq!(connection.verified_peer().socket_addr(), socket_address()); From fd91248908eb68a0b483ab588b17ec2834c1e47c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:45:49 -0700 Subject: [PATCH 209/229] fix(network): remove unused BiDi error imports --- .../src/webdriver_bidi_connection/error.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_connection/error.rs b/crates/originweave-network/src/webdriver_bidi_connection/error.rs index 613f3101a..226cd1d2b 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection/error.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection/error.rs @@ -2,12 +2,10 @@ use std::{fmt, io, net::SocketAddr, time::Duration}; use originweave_core::WebDriverBiDiSocketPeerVerificationError; -use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; - /// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. #[derive(Debug)] pub enum WebDriverBiDiTcpConnectionError { - /// The requested timeout was zero or exceeded [`MAX_CONNECT_TIMEOUT`]. + /// The requested timeout was zero or exceeded [`crate::MAX_CONNECT_TIMEOUT`]. InvalidConnectTimeout { /// The rejected timeout. connect_timeout: Duration, From 975e2e2653306a41ee0b33d2d5030ed9ccffdfa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:49:51 -0700 Subject: [PATCH 210/229] docs(changelog): record bounded BiDi TCP transport --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca383831..587f92976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, preserves correlated session/TLS metadata and typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. From c491464e8c83bf45d96cf3f4d9ea01f4b437996c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:00:20 -0700 Subject: [PATCH 211/229] test(network): require consumable BiDi TCP evidence --- .../tests/webdriver_bidi_tcp_connection.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs index bfc80457b..fac15b6a5 100644 --- a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs +++ b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs @@ -60,6 +60,14 @@ fn exact_loopback_target_opens_one_verified_bidi_tcp_stream() { assert_eq!(connection.attempt_number(), 1); assert_eq!(connection.connect_timeout(), Duration::from_secs(1)); + let (stream, evidence) = connection.into_parts(); + assert_eq!(stream.peer_addr().ok(), Some(local_addr)); + assert_eq!(evidence.verified_peer().socket_addr(), local_addr); + assert!(!evidence.verified_peer().requires_tls()); + assert_eq!(evidence.verified_peer().session_id(), SESSION_ID); + assert_eq!(evidence.attempt_number(), 1); + assert_eq!(evidence.connect_timeout(), Duration::from_secs(1)); + let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); if let Ok(accept_result) = server_result { From 6aab73ccf742c570067e7c23621c80209b65f1af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:03:38 -0700 Subject: [PATCH 212/229] feat(network): hand off verified BiDi TCP evidence --- crates/originweave-network/src/lib.rs | 3 +- .../src/webdriver_bidi_connection.rs | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 67f85c973..3c267b6ed 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -18,5 +18,6 @@ pub use connection::{ NetworkError, SocketConnectionEvidence, }; pub use webdriver_bidi_connection::{ - WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, + WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; diff --git a/crates/originweave-network/src/webdriver_bidi_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs index edf750d7a..5d39bb5e3 100644 --- a/crates/originweave-network/src/webdriver_bidi_connection.rs +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -203,4 +203,52 @@ impl WebDriverBiDiTcpConnection { pub const fn connect_timeout(&self) -> Duration { self.connect_timeout } + + /// Consume the wrapper into the original verified stream and credential-free transport evidence. + /// + /// This handoff does not clone the socket or create reusable connection authority. The returned + /// evidence records only the already-verified peer plus bounded connection-attempt metadata; it + /// does not authenticate a browser process, establish TLS, complete WebSocket framing, or grant + /// browser or Agent authority. + #[must_use] + pub fn into_parts(self) -> (TcpStream, WebDriverBiDiTcpConnectionEvidence) { + let evidence = WebDriverBiDiTcpConnectionEvidence { + verified_peer: self.verified_peer, + attempt_number: self.attempt_number, + connect_timeout: self.connect_timeout, + }; + (self.stream, evidence) + } +} + +/// Credential-free evidence retained when a verified WebDriver BiDi TCP stream is consumed. +/// +/// This value records exact peer/session/TLS-requirement metadata inherited from the consumed +/// no-DNS target together with the successful bounded attempt and per-attempt timeout. It is +/// transport evidence only and grants no process, TLS, WebSocket, browser-action, or Agent authority. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionEvidence { + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnectionEvidence { + /// Borrow the exact session-correlated peer verified before stream exposure. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing the connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } } From 18703be8dc09d875834761f937165232f73e6c2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:07:23 -0700 Subject: [PATCH 213/229] docs(changelog): record BiDi TCP evidence handoff --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 587f92976..5598829e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added -- Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, preserves correlated session/TLS metadata and typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. +- Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. From 11a533dfba366f0b11b36c47a2470ad57fd0344a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:14:35 -0700 Subject: [PATCH 214/229] test(network): require bounded BiDi WebSocket opening request --- .../webdriver_bidi_websocket_handshake.rs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs new file mode 100644 index 000000000..33a632bb0 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -0,0 +1,126 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +#[test] +fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let expected = format!( + "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + assert_eq!(plan.request_bytes(), expected.as_bytes()); + assert_eq!(plan.verified_peer().socket_addr(), local_addr); + assert_eq!(plan.verified_peer().session_id(), SESSION_ID); + assert!(!plan.verified_peer().requires_tls()); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { + let invalid_character = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZ!=="); + assert!(matches!( + invalid_character, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + let invalid_padding_bits = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZR=="); + assert!(matches!( + invalid_padding_bits, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("wss://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(matches!( + plan, + Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired) + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} From 980330348d8007000a5c8ed4b049390a97e591e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:18:13 -0700 Subject: [PATCH 215/229] feat(network): bind BiDi WebSocket opening request --- crates/originweave-network/src/lib.rs | 10 +- .../src/webdriver_bidi_websocket_handshake.rs | 124 ++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 3c267b6ed..a77d9b794 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -4,14 +4,16 @@ //! without hostname resolution or proxy inheritance, verifies operating-system //! peers before exposing transport I/O, and emits credential-free evidence. //! It also bridges a session-correlated WebDriver BiDi loopback target from -//! `originweave-core` into one bounded exact TCP connection without granting -//! browser, WebSocket, TLS, policy, or Agent authority. +//! `originweave-core` into one bounded exact TCP connection and can bind an inert +//! RFC 6455 opening request to an already-verified plain BiDi stream without +//! granting browser, WebSocket, TLS, policy, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_websocket_handshake; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -21,3 +23,7 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub use webdriver_bidi_websocket_handshake::{ + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakePlan, +}; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs new file mode 100644 index 000000000..8b0854305 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -0,0 +1,124 @@ +use std::fmt; + +use originweave_core::VerifiedWebDriverBiDiSocketPeer; + +use crate::WebDriverBiDiTcpConnection; + +const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; + +fn is_base64_data_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') +} + +fn is_canonical_16_byte_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH + && bytes[..22].iter().copied().all(is_base64_data_byte) + && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') + && bytes[22] == b'=' + && bytes[23] == b'=' +} + +/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketHandshakeError { + /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. + InvalidClientKey, + /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. + TlsRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidClientKey => formatter.write_str( + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", + ), + Self::TlsRequired => formatter.write_str( + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} + +/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. +/// +/// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type +/// validates only the canonical wire representation, including zero padding bits. It does not +/// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce +/// for each connection attempt. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketClientKey(String); + +impl WebDriverBiDiWebSocketClientKey { + /// Admit one canonical base64 client key representing exactly 16 bytes. + pub fn new(value: &str) -> Result { + if !is_canonical_16_byte_base64(value) { + return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +/// +/// The plan consumes the verified TCP connection so the opening request cannot be detached from the +/// socket peer/session evidence that authorized its exact loopback destination. It serializes only +/// the fixed WebSocket version-13 request required for the admitted `/session/` resource. +/// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary +/// before any WebSocket bytes may be written. +/// +/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` +/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiWebSocketHandshakePlan { + connection: WebDriverBiDiTcpConnection, + request: Vec, +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + ) -> Result { + if connection.verified_peer().requires_tls() { + return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); + } + + let peer = connection.verified_peer(); + let request = format!( + "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", + peer.session_id(), + peer.socket_addr(), + client_key.as_str(), + ) + .into_bytes(); + + Ok(Self { + connection, + request, + }) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + &self.request + } + + /// Borrow the exact peer/session evidence already verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.connection.verified_peer() + } +} From 8b8f664513b863d33c31452ae6b49a647f693a07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:05:32 -0700 Subject: [PATCH 216/229] test(network): cover BiDi handshake diagnostics --- .../tests/webdriver_bidi_websocket_handshake.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 33a632bb0..c45fc6315 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -80,6 +80,18 @@ fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { } } +#[test] +fn handshake_errors_render_actionable_fail_closed_messages() { + assert_eq!( + WebDriverBiDiWebSocketHandshakeError::InvalidClientKey.to_string(), + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes" + ); + assert_eq!( + WebDriverBiDiWebSocketHandshakeError::TlsRequired.to_string(), + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request" + ); +} + #[test] fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { let invalid_character = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZ!=="); From 73f2fbdc1cbb1a26e5d80654b2afb816fa0c4d6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:11:11 -0700 Subject: [PATCH 217/229] test(network): close BiDi handshake branch coverage --- .../tests/webdriver_bidi_websocket_handshake.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index c45fc6315..987fcfd6c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -94,6 +94,11 @@ fn handshake_errors_render_actionable_fail_closed_messages() { #[test] fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { + let invalid_length = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ="); + assert!(matches!( + invalid_length, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); let invalid_character = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZ!=="); assert!(matches!( invalid_character, @@ -104,6 +109,12 @@ fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { invalid_padding_bits, Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) )); + let invalid_padding_character = + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQA="); + assert!(matches!( + invalid_padding_character, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); let listener = TcpListener::bind(("127.0.0.1", 0)); assert!(listener.is_ok(), "{listener:?}"); From d39d0d36508934ff06781ac0dca7a360b6b3d0d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:18:37 -0700 Subject: [PATCH 218/229] test(network): retain BiDi WebSocket client key --- .../tests/webdriver_bidi_websocket_handshake.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 987fcfd6c..264f5a46c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -69,6 +69,7 @@ fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" ); assert_eq!(plan.request_bytes(), expected.as_bytes()); + assert_eq!(plan.client_key().as_str(), RFC6455_SAMPLE_KEY); assert_eq!(plan.verified_peer().socket_addr(), local_addr); assert_eq!(plan.verified_peer().session_id(), SESSION_ID); assert!(!plan.verified_peer().requires_tls()); From ccfc641e50fe4c381c13d2eae3857455eb9ed0b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:20:21 -0700 Subject: [PATCH 219/229] fix(network): retain BiDi WebSocket client key --- .../src/webdriver_bidi_websocket_handshake.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 8b0854305..6e896800d 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -72,7 +72,8 @@ impl WebDriverBiDiWebSocketClientKey { /// /// The plan consumes the verified TCP connection so the opening request cannot be detached from the /// socket peer/session evidence that authorized its exact loopback destination. It serializes only -/// the fixed WebSocket version-13 request required for the admitted `/session/` resource. +/// the fixed WebSocket version-13 request required for the admitted `/session/` resource +/// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary /// before any WebSocket bytes may be written. /// @@ -82,6 +83,7 @@ impl WebDriverBiDiWebSocketClientKey { #[derive(Debug)] pub struct WebDriverBiDiWebSocketHandshakePlan { connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, request: Vec, } @@ -106,6 +108,7 @@ impl WebDriverBiDiWebSocketHandshakePlan { Ok(Self { connection, + client_key, request, }) } @@ -116,6 +119,12 @@ impl WebDriverBiDiWebSocketHandshakePlan { &self.request } + /// Borrow the exact client key that a later server-handshake validator must correlate. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + /// Borrow the exact peer/session evidence already verified before request construction. #[must_use] pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { From 2495b5a6db3643d2fb030aec410fdedae0b950b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:41:27 -0700 Subject: [PATCH 220/229] test(core): cover ChromeDriver session id compatibility --- .../tests/webdriver_bidi_websocket_endpoint.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs index ecf5df6b6..1e38f8ec4 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -6,6 +6,7 @@ use originweave_core::{ }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CHROMEDRIVER_SESSION_ID: &str = "0123456789abcdef0123456789abcdef"; #[test] fn canonical_loopback_session_endpoints_are_admitted_without_granting_authority() { @@ -43,6 +44,18 @@ fn canonical_loopback_session_endpoints_are_admitted_without_granting_authority( assert_eq!(ipv6.port(), 9222); } +#[test] +fn chromedriver_generated_session_identifier_is_admitted_for_real_chromium_fixture() { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://127.0.0.1:9515/session/{CHROMEDRIVER_SESSION_ID}" + )); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + assert_eq!(endpoint.session_id(), CHROMEDRIVER_SESSION_ID); +} + #[test] fn remote_or_ambiguous_authorities_fail_closed() { for endpoint in [ From baf917c07cdf7187c66a3bda721970e1bc0b3657 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:44:51 -0700 Subject: [PATCH 221/229] fix(core): accept canonical ChromeDriver session ids --- .../src/webdriver_bidi_websocket_endpoint.rs | 39 +++++++++++-------- .../webdriver_bidi_websocket_endpoint.rs | 4 ++ 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs index 3a555493b..68ba9b061 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -115,7 +115,7 @@ impl WebDriverBiDiWebSocketEndpoint { if session_id.is_empty() || session_id.contains('/') { return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); } - if !is_canonical_session_uuid(session_id) { + if !is_canonical_session_id(session_id) { return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId); } @@ -152,29 +152,36 @@ impl WebDriverBiDiWebSocketEndpoint { self.port } - /// Return the canonical lower-case UUID session identifier. + /// Return the exact canonical session identifier admitted from the WebDriver endpoint. #[must_use] pub fn session_id(&self) -> &str { &self.session_id } } -fn is_canonical_session_uuid(value: &str) -> bool { +fn is_lowercase_hex(byte: u8) -> bool { + byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') +} + +fn is_canonical_session_id(value: &str) -> bool { let bytes = value.as_bytes(); - if bytes.len() != 36 { - return false; - } - for (index, byte) in bytes.iter().copied().enumerate() { - let valid = if matches!(index, 8 | 13 | 18 | 23) { - byte == b'-' - } else { - byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') - }; - if !valid { - return false; + match bytes.len() { + 32 => bytes.iter().copied().all(is_lowercase_hex), + 36 => { + for (index, byte) in bytes.iter().copied().enumerate() { + let valid = if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + is_lowercase_hex(byte) + }; + if !valid { + return false; + } + } + true } + _ => false, } - true } /// Fail-closed admission errors for WebDriver BiDi WebSocket endpoint metadata. @@ -198,7 +205,7 @@ pub enum WebDriverBiDiWebSocketEndpointAdmissionError { InvalidPort, /// The path is not exactly one `/session/` resource. InvalidSessionResource, - /// The session id is not one canonical lower-case UUID representation. + /// The session id is not an admitted canonical W3C/ChromeDriver representation. InvalidSessionId, } diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs index 1e38f8ec4..9440dff76 100644 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -132,6 +132,10 @@ fn port_and_session_resource_are_canonical_and_bounded() { "0123456789ab-cdef-0123-456789abcdef", "01234567-89ab-cdef-0123-456789abcdeg", "01234567_89ab-cdef-0123-456789abcdef", + "0123456789abcdef0123456789abcdeF", + "0123456789abcdef0123456789abcdeg", + "0123456789abcdef0123456789abcde_", + "0123456789abcdef0123456789abcde", ] { assert!(matches!( WebDriverBiDiWebSocketEndpoint::new(&format!( From 30da73a684eacd01c78472dc0120258965480091 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:02:25 -0700 Subject: [PATCH 222/229] test(network): redact WebSocket client nonce debug --- .../webdriver_bidi_websocket_handshake.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 264f5a46c..93550245a 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -8,6 +8,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REDACTED_CLIENT_KEY: &str = ""; fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); @@ -38,6 +39,57 @@ fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { connection } +#[test] +fn client_key_debug_redacts_websocket_nonce() { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + + let debug = format!("{key:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); +} + +#[test] +fn handshake_plan_debug_redacts_websocket_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let debug = format!("{plan:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { let listener = TcpListener::bind(("127.0.0.1", 0)); From 6922dd98779e8f8aad132a3b1f563d7ba6e6d070 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:05:33 -0700 Subject: [PATCH 223/229] fix(network): redact WebSocket client nonce debug --- .../src/webdriver_bidi_websocket_handshake.rs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 6e896800d..026fa390b 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -5,6 +5,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::WebDriverBiDiTcpConnection; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') @@ -48,10 +49,20 @@ impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} /// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type /// validates only the canonical wire representation, including zero padding bits. It does not /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] +/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so +/// diagnostic output cannot disclose handshake material. +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiWebSocketClientKey") + .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) + .finish() + } +} + impl WebDriverBiDiWebSocketClientKey { /// Admit one canonical base64 client key representing exactly 16 bytes. pub fn new(value: &str) -> Result { @@ -75,18 +86,29 @@ impl WebDriverBiDiWebSocketClientKey { /// the fixed WebSocket version-13 request required for the admitted `/session/` resource /// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. +/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized +/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. /// /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or /// Agent-authority grant. -#[derive(Debug)] pub struct WebDriverBiDiWebSocketHandshakePlan { connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, request: Vec, } +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.connection.verified_peer()) + .field("client_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) + .field("request_byte_count", &self.request.len()) + .finish() + } +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( From 82f9be4d2e587c0e60e553bb1eac74a822e3fafe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:04:36 +0900 Subject: [PATCH 224/229] docs: record correlated BiDi result admission --- CHANGELOG.md | 1 + tests/test_product_documentation_contract.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8afb9ec7c..87ff7b68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. - Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. - Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. +- Correlated WebDriver BiDi `locateNodes` result admission that consumes exact response-correlation evidence, revalidates the serialized browsing-context identifier against the registered browsing-context identity, enforces the command's node budget, and binds admitted nodes atomically without granting browser or Agent authority. - Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. - 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. diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 1313189ea..0d55bfac5 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -11,6 +11,12 @@ class ProductDocumentationContractTests(unittest.TestCase): """Keep product requirements, technical design, diagrams, and traceability discoverable.""" + def test_changelog_records_correlated_locate_nodes_admission(self) -> None: + """Public BiDi result admission must remain visible in release evidence.""" + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("Correlated WebDriver BiDi `locateNodes` result admission", changelog) + self.assertIn("registered browsing-context identity", changelog) + def test_authoritative_product_documentation_graph_exists(self) -> None: """Major product decisions must not require reconstructing chat or PR history.""" required_paths = { From 2d7d25f4155e54e6276156cc470e197eaf8414d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:17:29 +0900 Subject: [PATCH 225/229] fix(core): require BiDi response classification --- .../src/webdriver_bidi_command.rs | 14 ++++++++++- ..._bidi_locate_nodes_response_correlation.rs | 24 +++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index 9a019cc45..2350f92b2 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -225,6 +225,18 @@ impl CorrelatedWebDriverBiDiLocateNodesResponse { /// Deterministic serialized command envelope for one bounded WebDriver BiDi accessibility query. /// +/// Direct identifier-only correlation is intentionally unavailable outside this crate; callers +/// must classify the response envelope before success evidence can reach result admission. +/// +/// ```compile_fail +/// use originweave_core::{WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand}; +/// +/// let query = WebDriverBiDiAccessibilityQuery::new(None, None, 1)?; +/// let command = WebDriverBiDiLocateNodesCommand::new(1, "context-a", &query)?; +/// let _ = command.correlate_response_id(1)?; +/// # Ok::<(), Box>(()) +/// ``` +/// /// Construction accepts only a WebDriver BiDi `js-uint` command identifier, a bounded opaque /// browsing-context identifier, and an already validated [`WebDriverBiDiAccessibilityQuery`]. The /// serialized envelope fixes the exact `browsingContext.locateNodes` method, accessibility locator, @@ -332,7 +344,7 @@ impl WebDriverBiDiLocateNodesCommand { /// evidence also retains the exact `maxNodeCount` serialized by this command so later result /// admission cannot substitute a different query budget. This does not parse a response, /// authenticate the transport, or grant browser/Agent authority. - pub fn correlate_response_id( + fn correlate_response_id( self, response_id: u64, ) -> Result< diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs index a8147feb4..91f7fc143 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs @@ -2,7 +2,9 @@ use std::error::Error; use originweave_core::{ MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, }; fn locate_nodes_command( @@ -18,7 +20,9 @@ fn locate_nodes_command( #[test] fn locate_nodes_response_requires_exact_command_id() -> Result<(), Box> { - let correlated = locate_nodes_command(42)?.correlate_response_id(42)?; + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; assert_eq!(correlated.command_id(), 42); assert_eq!(correlated.browsing_context(), "context-a"); @@ -27,16 +31,17 @@ fn locate_nodes_response_requires_exact_command_id() -> Result<(), Box Result<(), Box> { - let error = locate_nodes_command(42)?.correlate_response_id(41); + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(41)); assert_eq!( error, - Err( + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { expected: 42, actual: 41, } - ) + )) ); Ok(()) } @@ -44,11 +49,16 @@ fn locate_nodes_response_rejects_mismatched_command_id() -> Result<(), Box Result<(), Box> { - let error = locate_nodes_command(1)?.correlate_response_id(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1); + let error = locate_nodes_command(1)?.correlate_response_envelope( + WebDriverBiDiCommandResponseKind::Success, + Some(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1), + ); assert_eq!( error, - Err(WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId) + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId + )) ); Ok(()) } From 889659e9798bd2d365ff7378376529d31e799dc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:22:59 +0900 Subject: [PATCH 226/229] docs(core): avoid private correlation links --- crates/originweave-core/src/webdriver_bidi_command.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index 2350f92b2..77d310ab2 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -119,7 +119,7 @@ impl Error for WebDriverBiDiLocateNodesResponseEnvelopeError { /// Non-cloneable evidence that one `locateNodes` response matched the exact command id. /// -/// Only [`WebDriverBiDiLocateNodesCommand::correlate_response_id`] can construct this value. It +/// Only the command's internal exact-id correlation can construct this value. It /// retains the exact command identifier, bounded browsing-context identifier, and exact serialized /// result budget so a later trusted transport boundary can carry correlation evidence forward /// without reconstructing authority from ambient query state. It does not authenticate a browser or @@ -376,7 +376,7 @@ impl WebDriverBiDiLocateNodesCommand { /// id when no valid command id can be recovered; that case returns /// [`WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse`] and produces /// no correlation evidence. When an id is present, the same protocol-range and exact-id checks - /// as [`Self::correlate_response_id`] apply. The returned evidence retains whether the envelope + /// as the internal exact-id correlation apply. The returned evidence retains whether the envelope /// was success or error so an error cannot silently become success evidence. /// /// The caller must obtain `kind` and `response_id` from a separately reviewed exact response From c9915c1e8a02013e2b7158fd85dbe2d59972b900 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:35:42 +0900 Subject: [PATCH 227/229] docs: record origin-gated protocol dispatch --- CHANGELOG.md | 3 ++- tests/test_repository_contract.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d0ef113..d7d172458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Runtime browser-adapter version binding at the atomic protocol-use boundary: the caller-supplied bounded adapter-version token must exactly match the reviewed descriptor version before runtime revision or capability checks can succeed, preventing adapter-build drift from silently reusing otherwise matching protocol/browser metadata without authenticating or attesting the adapter process. - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. +- Origin-gated `dispatch_if_context_origin_current` composition that revalidates the exact registered session, browsing context, and canonical origin, carries the current document epoch into the same synchronous callback, and then validates protocol metadata and capability without claiming destination, network, TLS, adapter-authentication, action, or post-condition authority. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. @@ -80,4 +81,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..eda14c04f 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -175,6 +175,12 @@ def test_product_name_is_consistent_in_binding_documents(self) -> None: self.assertNotIn("TraceWeave", text, relative) self.assertNotIn("ProofRail", text, relative) + def test_context_origin_dispatch_is_recorded_in_the_changelog(self) -> None: + """The public origin-gated dispatch boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("dispatch_if_context_origin_current", changelog) + def test_database_contract_requires_two_word_snake_case(self) -> None: """Persistent naming policy must include the mandated canonical form.""" @@ -185,4 +191,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From c9702cdeab1826a90bb30931fa2dc19979fa23c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:40:53 +0900 Subject: [PATCH 228/229] docs: record epoch-gated protocol dispatch --- CHANGELOG.md | 1 + tests/test_repository_contract.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf71c8931..0e75467ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Explicit `BrowserAuthorityRegistry::bind_context_origin` registration that binds one canonical origin to the exact current browser session, browsing context, and document epoch before origin-sensitive protocol use, rejecting cross-session ownership and same-epoch origin changes without granting navigation or action authority. - Explicit `BrowserAuthorityRegistry::require_context_origin` revalidation that requires the exact registered canonical origin for the current browser session, browsing context, and document epoch before origin-sensitive protocol use, failing closed when binding is absent or mismatched without granting navigation or action authority. - Origin-gated `dispatch_if_context_origin_current` composition that revalidates the exact registered session, browsing context, and canonical origin, carries the current document epoch into the same synchronous callback, and then validates protocol metadata and capability without claiming destination, network, TLS, adapter-authentication, action, or post-condition authority. +- Epoch-gated `dispatch_if_context_origin_epoch_current` composition that additionally requires the registry's current document epoch to equal the observed epoch before protocol validation or callback execution, failing closed on same-origin navigation without granting browser, action, or post-condition authority. - 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. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 45254041b..b41052ceb 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -193,6 +193,12 @@ def test_context_origin_dispatch_is_recorded_in_the_changelog(self) -> None: changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") self.assertIn("dispatch_if_context_origin_current", changelog) + def test_context_origin_epoch_dispatch_is_recorded_in_the_changelog(self) -> None: + """The public epoch-gated dispatch boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("dispatch_if_context_origin_epoch_current", changelog) + def test_database_contract_requires_two_word_snake_case(self) -> None: """Persistent naming policy must include the mandated canonical form.""" From 7f482887142d0c8be21c7e16f59535ddbe2e42f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 07:08:56 +0900 Subject: [PATCH 229/229] chore: retrigger exact-head checks