From a228447e55fa5be6f986cd0c54cca38e6979907f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:37:38 +0900 Subject: [PATCH 001/313] test(core): require typed unsupported browser capability denial --- .../tests/browser_protocol_adapter.rs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index 3e369bfc6..f3658c18f 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -3,8 +3,9 @@ use std::error::Error; use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, - BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, + MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; const BIDI_ADAPTER_VERSION: &str = "originweave-bidi-v1"; @@ -57,6 +58,29 @@ fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?; + + assert_eq!( + descriptor.require_capability(BrowserProtocolCapability::Navigation), + Ok(()) + ); + assert_eq!( + descriptor.require_capability(BrowserProtocolCapability::NetworkObservation), + Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + )) + ); + Ok(()) +} + #[test] fn malformed_or_ambiguous_metadata_fails_closed() { let valid_capabilities = [BrowserProtocolCapability::Navigation]; @@ -238,3 +262,31 @@ fn descriptor_errors_are_stable_and_source_free() { assert!(error.source().is_none()); } } + +#[test] +fn capability_requirement_errors_are_stable_and_source_free() { + let cases = [ + ( + BrowserProtocolCapability::Navigation, + "browser protocol adapter does not declare required navigation capability", + ), + ( + BrowserProtocolCapability::SemanticObservation, + "browser protocol adapter does not declare required semantic-observation capability", + ), + ( + BrowserProtocolCapability::TypedInput, + "browser protocol adapter does not declare required typed-input capability", + ), + ( + BrowserProtocolCapability::NetworkObservation, + "browser protocol adapter does not declare required network-observation capability", + ), + ]; + + for (capability, expected) in cases { + let error = BrowserProtocolCapabilityRequirementError::UnsupportedCapability(capability); + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} From df2eb6e5a8ecb9b1506013a9af176b14fafa752a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:39:06 +0900 Subject: [PATCH 002/313] style(core): apply canonical rustfmt to capability requirement tests --- .../tests/browser_protocol_adapter.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index f3658c18f..b18d22318 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -59,7 +59,8 @@ fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box Result<(), Box> { +fn required_capability_fails_closed_without_side_effectful_fallback() -> Result<(), Box> +{ let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, BIDI_ADAPTER_VERSION, @@ -74,9 +75,11 @@ fn required_capability_fails_closed_without_side_effectful_fallback() -> Result< ); assert_eq!( descriptor.require_capability(BrowserProtocolCapability::NetworkObservation), - Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability( - BrowserProtocolCapability::NetworkObservation, - )) + Err( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + ) + ) ); Ok(()) } From 094adb42af24c7e71cdab908f28b77dcfb539374 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:42:00 +0900 Subject: [PATCH 003/313] feat(core): fail closed on unsupported browser capabilities --- .../originweave-core/src/browser_protocol.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 3421b27b3..7eec66418 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -127,6 +127,25 @@ impl BrowserProtocolAdapterDescriptor { pub fn supports(&self, capability: BrowserProtocolCapability) -> bool { self.capabilities.contains(&capability) } + + /// Require one explicitly declared adapter capability before later use. + /// + /// This method never infers support from the browser protocol family. An + /// absent capability fails closed with a typed error so a caller cannot + /// silently fall back to another upstream protocol or a raw browser escape + /// hatch merely because the selected adapter lacks the requested surface. + pub fn require_capability( + &self, + capability: BrowserProtocolCapability, + ) -> Result<(), BrowserProtocolCapabilityRequirementError> { + if self.supports(capability) { + Ok(()) + } else { + Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + capability, + )) + } + } } const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { @@ -138,6 +157,15 @@ const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { } } +fn capability_name(capability: BrowserProtocolCapability) -> &'static str { + match capability { + BrowserProtocolCapability::Navigation => "navigation", + BrowserProtocolCapability::SemanticObservation => "semantic-observation", + BrowserProtocolCapability::TypedInput => "typed-input", + BrowserProtocolCapability::NetworkObservation => "network-observation", + } +} + fn metadata_token_is_valid(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_BROWSER_PROTOCOL_METADATA_BYTES @@ -148,6 +176,27 @@ fn metadata_token_is_valid(value: &str) -> bool { && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) } +/// Failure to require one browser protocol capability from an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolCapabilityRequirementError { + /// The adapter did not explicitly declare the required capability. + UnsupportedCapability(BrowserProtocolCapability), +} + +impl fmt::Display for BrowserProtocolCapabilityRequirementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedCapability(capability) => write!( + formatter, + "browser protocol adapter does not declare required {} capability", + capability_name(*capability) + ), + } + } +} + +impl std::error::Error for BrowserProtocolCapabilityRequirementError {} + /// Failure to construct canonical browser protocol adapter metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolDescriptorError { From 91a636df77f65c5570ec92983cd5c54ba3fc6e4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:42:17 +0900 Subject: [PATCH 004/313] feat(core): export browser capability requirement error --- 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 06619a80b..3ce833620 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -15,7 +15,8 @@ mod browser_registry_coverage; mod contracts; pub use browser_protocol::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; pub use browser_registry::{ From be952c3b7b2be6d80164e121bf8cae60e8cf1ad3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:45:41 +0900 Subject: [PATCH 005/313] style(core): apply canonical browser capability formatting --- crates/originweave-core/src/browser_protocol.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 7eec66418..6af83a411 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -141,9 +141,7 @@ impl BrowserProtocolAdapterDescriptor { if self.supports(capability) { Ok(()) } else { - Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability( - capability, - )) + Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability(capability)) } } } From ea180b147d3800a65a98e905ecf77122c7dfa489 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:46:52 +0900 Subject: [PATCH 006/313] style(core): apply canonical capability export formatting --- 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 3ce833620..775abfcad 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -16,8 +16,8 @@ mod contracts; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, - BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, - BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, + MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From 72efca6acccc66409a9c38cf57e7f4279b2d8c3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:47:48 +0900 Subject: [PATCH 007/313] docs: record fail-closed browser capability requirement --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6217a595e..9cabdcc5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - 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, and grants no browser, action, network, or secret authority by protocol kind alone. +- 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 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. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. From a439c9b50da460c6bde624cf42d3fe7a5588ef2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:00:16 +0900 Subject: [PATCH 008/313] test(core): require OriginWeave protocol version binding --- .../tests/browser_protocol_adapter.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index b18d22318..f555927ae 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -5,19 +5,39 @@ use std::error::Error; use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, + BrowserProtocolVersionRequirementError, OriginWeaveProtocolVersion, MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; +const CURRENT_ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const FUTURE_ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 2); const BIDI_ADAPTER_VERSION: &str = "originweave-bidi-v1"; const BIDI_PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; const CDP_PROTOCOL_REVISION: &str = "cdp-browser-r1639810"; const BROWSER_REVISION: &str = "chromium-r1639810"; +#[test] +fn originweave_protocol_version_is_explicit_and_canonical() { + assert_eq!(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.major(), 0); + assert_eq!(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.minor(), 1); + assert_eq!( + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.to_string(), + "originweave/0.1" + ); + assert_ne!( + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + FUTURE_ORIGINWEAVE_PROTOCOL_VERSION + ); +} + #[test] fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), Box> { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -29,6 +49,10 @@ fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), )?; assert_eq!(descriptor.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!( + descriptor.originweave_protocol_version(), + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION + ); assert_eq!(descriptor.adapter_version(), BIDI_ADAPTER_VERSION); assert_eq!(descriptor.protocol_revision(), BIDI_PROTOCOL_REVISION); assert_eq!(descriptor.browser_revision(), BROWSER_REVISION); @@ -44,6 +68,7 @@ fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box> { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::ChromeDevToolsProtocol, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, CDP_ADAPTER_VERSION, CDP_PROTOCOL_REVISION, BROWSER_REVISION, @@ -63,6 +88,7 @@ fn required_capability_fails_closed_without_side_effectful_fallback() -> Result< { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -84,6 +110,31 @@ fn required_capability_fails_closed_without_side_effectful_fallback() -> Result< Ok(()) } +#[test] +fn required_originweave_protocol_version_fails_closed() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?; + + assert_eq!( + descriptor.require_originweave_protocol_version(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION), + Ok(()) + ); + assert_eq!( + descriptor.require_originweave_protocol_version(FUTURE_ORIGINWEAVE_PROTOCOL_VERSION), + Err(BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required: FUTURE_ORIGINWEAVE_PROTOCOL_VERSION, + actual: CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + }) + ); + Ok(()) +} + #[test] fn malformed_or_ambiguous_metadata_fails_closed() { let valid_capabilities = [BrowserProtocolCapability::Navigation]; @@ -92,6 +143,7 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, adapter_version, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -113,6 +165,7 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, protocol_revision, BROWSER_REVISION, @@ -134,6 +187,7 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, browser_revision, @@ -147,6 +201,7 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, &oversized, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -157,6 +212,7 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, &oversized, BROWSER_REVISION, @@ -167,6 +223,7 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, &oversized, @@ -181,6 +238,7 @@ fn capability_set_must_be_nonempty_and_canonical() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -192,6 +250,7 @@ fn capability_set_must_be_nonempty_and_canonical() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -208,6 +267,7 @@ fn capability_set_must_be_nonempty_and_canonical() { fn capability_order_does_not_change_descriptor_identity() -> Result<(), Box> { let forward = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -220,6 +280,7 @@ fn capability_order_does_not_change_descriptor_identity() -> Result<(), Box Date: Wed, 12 Aug 2026 14:02:41 +0900 Subject: [PATCH 009/313] test(core): apply canonical protocol-version formatting --- .../tests/browser_protocol_adapter.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index f555927ae..5cf457a66 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -5,8 +5,8 @@ use std::error::Error; use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, - BrowserProtocolVersionRequirementError, OriginWeaveProtocolVersion, - MAX_BROWSER_PROTOCOL_METADATA_BYTES, + BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + OriginWeaveProtocolVersion, }; const CURRENT_ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -127,10 +127,12 @@ fn required_originweave_protocol_version_fails_closed() -> Result<(), Box Date: Wed, 12 Aug 2026 14:04:43 +0900 Subject: [PATCH 010/313] feat(core): bind browser adapters to protocol version --- .../originweave-core/src/browser_protocol.rs | 112 ++++++++++++++++-- 1 file changed, 103 insertions(+), 9 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 6af83a411..9d4733b02 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -3,6 +3,45 @@ use std::fmt; /// Maximum UTF-8 byte length for browser protocol adapter metadata tokens. pub const MAX_BROWSER_PROTOCOL_METADATA_BYTES: usize = 128; +/// One OriginWeave Protocol generation. +/// +/// This value identifies the OriginWeave contract spoken by an adapter. It is +/// deliberately independent from the upstream WebDriver BiDi/CDP revision and +/// from the browser build. Constructing a version does not make that version +/// supported; callers must compare it with the exact version required by the +/// surrounding OriginWeave protocol boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OriginWeaveProtocolVersion { + major: u16, + minor: u16, +} + +impl OriginWeaveProtocolVersion { + /// Construct an OriginWeave Protocol generation identifier. + #[must_use] + pub const fn new(major: u16, minor: u16) -> Self { + Self { major, minor } + } + + /// Return the protocol major version. + #[must_use] + pub const fn major(self) -> u16 { + self.major + } + + /// Return the protocol minor version. + #[must_use] + pub const fn minor(self) -> u16 { + self.minor + } +} + +impl fmt::Display for OriginWeaveProtocolVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "originweave/{}.{}", self.major, self.minor) + } +} + /// Browser automation protocol family used by one versioned adapter. /// /// The protocol family is descriptive metadata only. Selecting a kind does not @@ -33,12 +72,13 @@ pub enum BrowserProtocolCapability { /// /// This value is deliberately not browser authority. It contains no browser /// session, context, origin, node handle, action grant, credential, or network -/// permission. Higher layers may use it to fail closed when a required adapter -/// capability is absent, while all OriginWeave authority remains separately -/// validated. +/// permission. Higher layers may use it to fail closed when the adapter targets +/// the wrong OriginWeave Protocol generation or lacks a required browser +/// capability, while all OriginWeave authority remains separately validated. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BrowserProtocolAdapterDescriptor { kind: BrowserProtocolKind, + originweave_protocol_version: OriginWeaveProtocolVersion, adapter_version: String, protocol_revision: String, browser_revision: String, @@ -48,14 +88,16 @@ pub struct BrowserProtocolAdapterDescriptor { impl BrowserProtocolAdapterDescriptor { /// Construct one explicit adapter descriptor. /// - /// Adapter version, upstream protocol revision, and browser revision are - /// separate bounded ASCII metadata tokens. This prevents an OriginWeave - /// adapter release from being mistaken for the WebDriver BiDi/CDP revision - /// or the pinned browser build it was validated against. The declared - /// capability list must be non-empty and duplicate-free and is normalized - /// into one stable order so caller ordering cannot change descriptor identity. + /// The OriginWeave Protocol generation, adapter version, upstream protocol + /// revision, and browser revision are distinct metadata. This prevents an + /// OriginWeave contract version from being mistaken for the WebDriver + /// BiDi/CDP revision or the pinned browser build it was validated against. + /// The declared capability list must be non-empty and duplicate-free and is + /// normalized into one stable order so caller ordering cannot change + /// descriptor identity. pub fn new( kind: BrowserProtocolKind, + originweave_protocol_version: OriginWeaveProtocolVersion, adapter_version: &str, protocol_revision: &str, browser_revision: &str, @@ -85,6 +127,7 @@ impl BrowserProtocolAdapterDescriptor { Ok(Self { kind, + originweave_protocol_version, adapter_version: adapter_version.to_owned(), protocol_revision: protocol_revision.to_owned(), browser_revision: browser_revision.to_owned(), @@ -98,6 +141,12 @@ impl BrowserProtocolAdapterDescriptor { self.kind } + /// Return the exact OriginWeave Protocol generation implemented by this adapter. + #[must_use] + pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { + self.originweave_protocol_version + } + /// Return the bounded OriginWeave adapter-version metadata token. #[must_use] pub fn adapter_version(&self) -> &str { @@ -128,6 +177,26 @@ impl BrowserProtocolAdapterDescriptor { self.capabilities.contains(&capability) } + /// Require one exact OriginWeave Protocol generation before later adapter use. + /// + /// Pre-alpha compatibility is deliberately exact at this boundary. A caller + /// may add a separately reviewed compatibility transform later, but this + /// descriptor never silently treats a different major or minor generation + /// as equivalent. + pub fn require_originweave_protocol_version( + &self, + required: OriginWeaveProtocolVersion, + ) -> Result<(), BrowserProtocolVersionRequirementError> { + if self.originweave_protocol_version == required { + Ok(()) + } else { + Err(BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required, + actual: self.originweave_protocol_version, + }) + } + } + /// Require one explicitly declared adapter capability before later use. /// /// This method never infers support from the browser protocol family. An @@ -174,6 +243,31 @@ fn metadata_token_is_valid(value: &str) -> bool { && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) } +/// Failure to require one exact OriginWeave Protocol generation from an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolVersionRequirementError { + /// The adapter targets a different OriginWeave Protocol generation. + ProtocolVersionMismatch { + /// Exact OriginWeave Protocol generation required by the caller. + required: OriginWeaveProtocolVersion, + /// Exact OriginWeave Protocol generation declared by the adapter. + actual: OriginWeaveProtocolVersion, + }, +} + +impl fmt::Display for BrowserProtocolVersionRequirementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ProtocolVersionMismatch { required, actual } => write!( + formatter, + "browser protocol adapter targets {actual} but {required} is required" + ), + } + } +} + +impl std::error::Error for BrowserProtocolVersionRequirementError {} + /// Failure to require one browser protocol capability from an adapter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolCapabilityRequirementError { From fb342952a422edef4ed8ea5119938f78dc72578a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:05:01 +0900 Subject: [PATCH 011/313] feat(core): export protocol version contract --- 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 775abfcad..ed33e341e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -17,7 +17,8 @@ mod contracts; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, - MAX_BROWSER_PROTOCOL_METADATA_BYTES, + BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + OriginWeaveProtocolVersion, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From c4cd417266860e006e068e9f58d86b98779b646d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:09:00 +0900 Subject: [PATCH 012/313] style(core): apply canonical protocol-version formatting --- crates/originweave-core/src/browser_protocol.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 9d4733b02..4b7fe0f00 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -190,10 +190,12 @@ impl BrowserProtocolAdapterDescriptor { if self.originweave_protocol_version == required { Ok(()) } else { - Err(BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { - required, - actual: self.originweave_protocol_version, - }) + Err( + BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required, + actual: self.originweave_protocol_version, + }, + ) } } From ea45a91230e003babc33fff08acb9ada4b07957a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:09:36 +0900 Subject: [PATCH 013/313] test(core): exercise runtime protocol-version construction --- .../tests/protocol_version_runtime_coverage.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 crates/originweave-core/tests/protocol_version_runtime_coverage.rs diff --git a/crates/originweave-core/tests/protocol_version_runtime_coverage.rs b/crates/originweave-core/tests/protocol_version_runtime_coverage.rs new file mode 100644 index 000000000..aeca15dec --- /dev/null +++ b/crates/originweave-core/tests/protocol_version_runtime_coverage.rs @@ -0,0 +1,12 @@ +use originweave_core::OriginWeaveProtocolVersion; + +#[test] +fn protocol_version_can_be_constructed_from_runtime_values() { + let major = std::hint::black_box(0_u16); + let minor = std::hint::black_box(1_u16); + let version = OriginWeaveProtocolVersion::new(major, minor); + + assert_eq!(version.major(), 0); + assert_eq!(version.minor(), 1); + assert_eq!(version.to_string(), "originweave/0.1"); +} From ac780f08ff825fae08c64d993eaaab6fe6817e3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:32:14 +0900 Subject: [PATCH 014/313] test(core): require canonical protocol version parsing --- .../tests/protocol_version_parsing.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 crates/originweave-core/tests/protocol_version_parsing.rs diff --git a/crates/originweave-core/tests/protocol_version_parsing.rs b/crates/originweave-core/tests/protocol_version_parsing.rs new file mode 100644 index 000000000..189bde6be --- /dev/null +++ b/crates/originweave-core/tests/protocol_version_parsing.rs @@ -0,0 +1,59 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; +use std::str::FromStr; + +use originweave_core::{OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError}; + +#[test] +fn canonical_protocol_versions_parse_and_round_trip() -> Result<(), Box> { + let current = OriginWeaveProtocolVersion::from_str("originweave/0.1")?; + assert_eq!(current, OriginWeaveProtocolVersion::new(0, 1)); + assert_eq!(current.to_string(), "originweave/0.1"); + + let maximum = OriginWeaveProtocolVersion::from_str("originweave/65535.65535")?; + assert_eq!(maximum, OriginWeaveProtocolVersion::new(u16::MAX, u16::MAX)); + assert_eq!(maximum.to_string(), "originweave/65535.65535"); + Ok(()) +} + +#[test] +fn malformed_or_noncanonical_protocol_versions_fail_closed() { + let malformed = [ + "", + "originweave/", + "originweave/0", + "originweave/0.", + "originweave/.1", + "originweave/0.1.0", + "OriginWeave/0.1", + "originweave/00.1", + "originweave/0.01", + "originweave/+0.1", + "originweave/0.+1", + "originweave/-0.1", + "originweave/0.-1", + "originweave/65536.1", + "originweave/0.65536", + " originweave/0.1", + "originweave/0.1 ", + "originweave/0.1", + ]; + + for value in malformed { + assert_eq!( + OriginWeaveProtocolVersion::from_str(value), + Err(OriginWeaveProtocolVersionParseError::InvalidFormat) + ); + } +} + +#[test] +fn protocol_version_parse_error_is_stable_and_source_free() { + let error = OriginWeaveProtocolVersionParseError::InvalidFormat; + assert_eq!( + error.to_string(), + "OriginWeave protocol version must use canonical originweave/. syntax" + ); + assert!(error.source().is_none()); +} From eec3f462b6df93e1dd10a05aeee23393ddd7bfb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:36:55 +0900 Subject: [PATCH 015/313] feat(core): parse canonical protocol versions --- .../originweave-core/src/browser_protocol.rs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 4b7fe0f00..981597e03 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{fmt, str::FromStr}; /// Maximum UTF-8 byte length for browser protocol adapter metadata tokens. pub const MAX_BROWSER_PROTOCOL_METADATA_BYTES: usize = 128; @@ -42,6 +42,53 @@ impl fmt::Display for OriginWeaveProtocolVersion { } } +impl FromStr for OriginWeaveProtocolVersion { + type Err = OriginWeaveProtocolVersionParseError; + + fn from_str(value: &str) -> Result { + let Some(remainder) = value.strip_prefix("originweave/") else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + let Some((major_text, minor_text)) = remainder.split_once('.') else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + if minor_text.contains('.') { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + } + let Ok(major) = major_text.parse::() else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + let Ok(minor) = minor_text.parse::() else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + + let version = Self::new(major, minor); + if version.to_string() != value { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + } + Ok(version) + } +} + +/// Failure to parse a canonical serialized OriginWeave Protocol generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginWeaveProtocolVersionParseError { + /// The value did not use the exact canonical `originweave/.` syntax. + InvalidFormat, +} + +impl fmt::Display for OriginWeaveProtocolVersionParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => formatter.write_str( + "OriginWeave protocol version must use canonical originweave/. syntax", + ), + } + } +} + +impl std::error::Error for OriginWeaveProtocolVersionParseError {} + /// Browser automation protocol family used by one versioned adapter. /// /// The protocol family is descriptive metadata only. Selecting a kind does not From aeb9629704dfc1d6dcc14a6e9de2ddb85853a302 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:37:16 +0900 Subject: [PATCH 016/313] feat(core): export protocol version parse error --- crates/originweave-core/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index ed33e341e..5b6275eec 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -18,7 +18,7 @@ pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, - OriginWeaveProtocolVersion, + OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From ea17243bf3e0a332bc4a62e25207c80697fde067 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:40:58 +0900 Subject: [PATCH 017/313] docs(changelog): record canonical protocol version parsing --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cabdcc5d..7d197d67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. - 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. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. From 66f6ad78004471ffb4cc881a20f67ac512dc4fb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:46:05 +0900 Subject: [PATCH 018/313] test(core): require exact runtime browser revisions --- .../browser_protocol_runtime_revision.rs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 crates/originweave-core/tests/browser_protocol_runtime_revision.rs diff --git a/crates/originweave-core/tests/browser_protocol_runtime_revision.rs b/crates/originweave-core/tests/browser_protocol_runtime_revision.rs new file mode 100644 index 000000000..db5e13172 --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_revision.rs @@ -0,0 +1,99 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeRequirementError, OriginWeaveProtocolVersion, +}; + +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() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +#[test] +fn exact_runtime_revisions_are_required_before_adapter_use() -> Result<(), Box> { + let descriptor = descriptor()?; + assert_eq!( + descriptor.require_runtime_revisions(PROTOCOL_REVISION, BROWSER_REVISION), + Ok(()) + ); + Ok(()) +} + +#[test] +fn runtime_revision_drift_fails_closed() -> Result<(), Box> { + let descriptor = descriptor()?; + assert_eq!( + descriptor.require_runtime_revisions("webdriver-bidi-wd-2026-07-01", BROWSER_REVISION), + Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch) + ); + assert_eq!( + descriptor.require_runtime_revisions(PROTOCOL_REVISION, "chromium-r1639811"), + Err(BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch) + ); + assert_eq!( + descriptor.require_runtime_revisions( + "webdriver-bidi-wd-2026-07-01", + "chromium-r1639811" + ), + Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch) + ); + Ok(()) +} + +#[test] +fn malformed_runtime_revision_evidence_fails_before_comparison() -> Result<(), Box> { + let descriptor = descriptor()?; + assert_eq!( + descriptor.require_runtime_revisions("webdriver bidi current", BROWSER_REVISION), + Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision) + ); + assert_eq!( + descriptor.require_runtime_revisions(PROTOCOL_REVISION, "chromium/current"), + Err(BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision) + ); + assert_eq!( + descriptor.require_runtime_revisions("", ""), + Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision) + ); + Ok(()) +} + +#[test] +fn runtime_requirement_errors_are_stable_and_source_free() { + let cases = [ + ( + BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision, + "runtime browser protocol revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision, + "runtime browser revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, + "runtime browser protocol revision does not match the pinned adapter revision", + ), + ( + BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch, + "runtime browser revision does not match the pinned adapter browser revision", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} From 03f7b188b4290ed6eb748df102bbe20119e8fa6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:49:30 +0900 Subject: [PATCH 019/313] style(core): apply canonical runtime revision test formatting --- .../tests/browser_protocol_runtime_revision.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_runtime_revision.rs b/crates/originweave-core/tests/browser_protocol_runtime_revision.rs index db5e13172..60a04acdb 100644 --- a/crates/originweave-core/tests/browser_protocol_runtime_revision.rs +++ b/crates/originweave-core/tests/browser_protocol_runtime_revision.rs @@ -44,10 +44,7 @@ fn runtime_revision_drift_fails_closed() -> Result<(), Box> { Err(BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch) ); assert_eq!( - descriptor.require_runtime_revisions( - "webdriver-bidi-wd-2026-07-01", - "chromium-r1639811" - ), + descriptor.require_runtime_revisions("webdriver-bidi-wd-2026-07-01", "chromium-r1639811"), Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch) ); Ok(()) From e7c3e3e2dfa58b9a14f3a4027511cea50cd0324a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:52:11 +0900 Subject: [PATCH 020/313] feat(core): validate browser runtime revisions --- .../originweave-core/src/browser_protocol.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 981597e03..bbb6ca8ce 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -246,6 +246,33 @@ impl BrowserProtocolAdapterDescriptor { } } + /// Require exact runtime browser-protocol and browser revisions before use. + /// + /// The caller must derive both values from the trusted runtime adapter that + /// is about to perform browser work. This deterministic comparison does not + /// authenticate or attest that caller. It only prevents a descriptor pinned + /// to one validated upstream-protocol/browser pair from being silently used + /// when the supplied runtime evidence is malformed or has drifted. + pub fn require_runtime_revisions( + &self, + protocol_revision: &str, + browser_revision: &str, + ) -> Result<(), BrowserProtocolRuntimeRequirementError> { + if !metadata_token_is_valid(protocol_revision) { + return Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision); + } + if !metadata_token_is_valid(browser_revision) { + return Err(BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision); + } + if self.protocol_revision != protocol_revision { + return Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch); + } + if self.browser_revision != browser_revision { + return Err(BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch); + } + Ok(()) + } + /// Require one explicitly declared adapter capability before later use. /// /// This method never infers support from the browser protocol family. An @@ -317,6 +344,40 @@ impl fmt::Display for BrowserProtocolVersionRequirementError { impl std::error::Error for BrowserProtocolVersionRequirementError {} +/// Failure to require exact pinned runtime revision evidence from an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolRuntimeRequirementError { + /// The runtime upstream-protocol revision token was malformed. + InvalidProtocolRevision, + /// The runtime browser revision token was malformed. + InvalidBrowserRevision, + /// The runtime upstream-protocol revision differs from the pinned descriptor. + ProtocolRevisionMismatch, + /// The runtime browser revision differs from the pinned descriptor. + BrowserRevisionMismatch, +} + +impl fmt::Display for BrowserProtocolRuntimeRequirementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidProtocolRevision => formatter.write_str( + "runtime browser protocol revision must be a bounded ASCII metadata token", + ), + Self::InvalidBrowserRevision => { + formatter.write_str("runtime browser revision must be a bounded ASCII metadata token") + } + Self::ProtocolRevisionMismatch => formatter.write_str( + "runtime browser protocol revision does not match the pinned adapter revision", + ), + Self::BrowserRevisionMismatch => formatter.write_str( + "runtime browser revision does not match the pinned adapter browser revision", + ), + } + } +} + +impl std::error::Error for BrowserProtocolRuntimeRequirementError {} + /// Failure to require one browser protocol capability from an adapter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolCapabilityRequirementError { From 4b1ce55bd946a3fb57c50c9ea4b2456590e3dccf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:52:42 +0900 Subject: [PATCH 021/313] feat(core): export browser runtime revision error --- 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 5b6275eec..6a8c7b6bd 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -17,8 +17,9 @@ mod contracts; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, - BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, - OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, + BrowserProtocolRuntimeRequirementError, BrowserProtocolVersionRequirementError, + MAX_BROWSER_PROTOCOL_METADATA_BYTES, OriginWeaveProtocolVersion, + OriginWeaveProtocolVersionParseError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From f0fc8f9cfc66dd8b7664b058a8243cc2bf9d95e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:10:20 +0900 Subject: [PATCH 022/313] style(core): apply canonical runtime revision formatting --- crates/originweave-core/src/browser_protocol.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index bbb6ca8ce..a23e6c3e1 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -363,9 +363,8 @@ impl fmt::Display for BrowserProtocolRuntimeRequirementError { Self::InvalidProtocolRevision => formatter.write_str( "runtime browser protocol revision must be a bounded ASCII metadata token", ), - Self::InvalidBrowserRevision => { - formatter.write_str("runtime browser revision must be a bounded ASCII metadata token") - } + Self::InvalidBrowserRevision => formatter + .write_str("runtime browser revision must be a bounded ASCII metadata token"), Self::ProtocolRevisionMismatch => formatter.write_str( "runtime browser protocol revision does not match the pinned adapter revision", ), From 9f987aedb1d6370d9e23b6afefd8be89f1e97bc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:18:16 +0900 Subject: [PATCH 023/313] test(core): require atomic browser protocol use validation --- .../tests/browser_protocol_use_validation.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 crates/originweave-core/tests/browser_protocol_use_validation.rs diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs new file mode 100644 index 000000000..536b2f2b3 --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -0,0 +1,112 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolUseValidationError, BrowserProtocolVersionRequirementError, + OriginWeaveProtocolVersion, +}; + +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() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::TypedInput, + ], + )?) +} + +#[test] +fn validated_use_binds_all_required_adapter_metadata() -> Result<(), Box> { + let descriptor = descriptor()?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + )?; + + assert_eq!(validated.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!( + validated.originweave_protocol_version(), + ORIGINWEAVE_PROTOCOL_VERSION + ); + assert_eq!(validated.adapter_version(), ADAPTER_VERSION); + assert_eq!(validated.protocol_revision(), PROTOCOL_REVISION); + assert_eq!(validated.browser_revision(), BROWSER_REVISION); + assert_eq!( + validated.capability(), + BrowserProtocolCapability::Navigation + ); + Ok(()) +} + +#[test] +fn protocol_generation_mismatch_precedes_runtime_and_capability_checks() -> Result<(), Box> { + let descriptor = descriptor()?; + let wrong_generation = OriginWeaveProtocolVersion::new(0, 2); + + assert_eq!( + descriptor.validate_use( + wrong_generation, + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::ProtocolVersion( + BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required: wrong_generation, + actual: ORIGINWEAVE_PROTOCOL_VERSION, + } + )) + ); + Ok(()) +} + +#[test] +fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box> { + let descriptor = descriptor()?; + + assert_eq!( + descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + "webdriver-bidi-wd-2026-07-01", + BROWSER_REVISION, + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::RuntimeRevision( + originweave_core::BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, + )) + ); + Ok(()) +} + +#[test] +fn undeclared_capability_cannot_produce_validated_use() -> Result<(), Box> { + let descriptor = descriptor()?; + + assert_eq!( + descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::Capability( + originweave_core::BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + ), + )) + ); + Ok(()) +} From e5a55985ef9009d8044c2c68c92e58a64660786f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:20:51 +0900 Subject: [PATCH 024/313] style(core): apply canonical browser protocol validation formatting --- .../originweave-core/tests/browser_protocol_use_validation.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs index 536b2f2b3..ba559a5af 100644 --- a/crates/originweave-core/tests/browser_protocol_use_validation.rs +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -52,7 +52,8 @@ fn validated_use_binds_all_required_adapter_metadata() -> Result<(), Box Result<(), Box> { +fn protocol_generation_mismatch_precedes_runtime_and_capability_checks() +-> Result<(), Box> { let descriptor = descriptor()?; let wrong_generation = OriginWeaveProtocolVersion::new(0, 2); From 8d8e9b9bc38072113ff82516a89ce9b276383155 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:23:57 +0900 Subject: [PATCH 025/313] feat(core): validate browser protocol use prerequisites atomically --- .../originweave-core/src/browser_protocol.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index a23e6c3e1..369666ece 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -289,6 +289,87 @@ impl BrowserProtocolAdapterDescriptor { Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability(capability)) } } + + /// Validate all adapter metadata prerequisites for one immediate browser operation. + /// + /// Validation is intentionally ordered and fail closed: the exact + /// OriginWeave Protocol generation is checked first, then the supplied + /// runtime protocol/browser revisions, then the required adapter + /// capability. Success returns a non-cloneable value that a later trusted + /// transport can consume as proof that these metadata prerequisites were + /// checked together. It is not browser or Agent authority and does not + /// authenticate the caller supplying runtime revision evidence. + pub fn validate_use( + &self, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_protocol_revision: &str, + runtime_browser_revision: &str, + required_capability: BrowserProtocolCapability, + ) -> Result { + self.require_originweave_protocol_version(required_originweave_protocol_version) + .map_err(BrowserProtocolUseValidationError::ProtocolVersion)?; + self.require_runtime_revisions(runtime_protocol_revision, runtime_browser_revision) + .map_err(BrowserProtocolUseValidationError::RuntimeRevision)?; + self.require_capability(required_capability) + .map_err(BrowserProtocolUseValidationError::Capability)?; + + Ok(ValidatedBrowserProtocolUse { + descriptor: self.clone(), + capability: required_capability, + }) + } +} + +/// Snapshot proving that one descriptor passed all browser-protocol metadata checks for one use. +/// +/// Only [`BrowserProtocolAdapterDescriptor::validate_use`] can construct this +/// value. It intentionally does not implement `Clone`: a future trusted browser +/// transport can consume the value by ownership at the operation boundary +/// rather than treating it as reusable ambient authority. The value still does +/// not authenticate an adapter or attest that supplied runtime metadata came +/// from the running browser process. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedBrowserProtocolUse { + descriptor: BrowserProtocolAdapterDescriptor, + capability: BrowserProtocolCapability, +} + +impl ValidatedBrowserProtocolUse { + /// Return the validated browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.descriptor.kind + } + + /// Return the validated OriginWeave Protocol generation. + #[must_use] + pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { + self.descriptor.originweave_protocol_version + } + + /// Return the validated bounded adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.descriptor.adapter_version + } + + /// Return the validated bounded upstream protocol-revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.descriptor.protocol_revision + } + + /// Return the validated bounded browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.descriptor.browser_revision + } + + /// Return the exact adapter capability validated for this use. + #[must_use] + pub const fn capability(&self) -> BrowserProtocolCapability { + self.capability + } } const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { @@ -398,6 +479,37 @@ impl fmt::Display for BrowserProtocolCapabilityRequirementError { impl std::error::Error for BrowserProtocolCapabilityRequirementError {} +/// Failure to validate all browser-protocol metadata prerequisites for one use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolUseValidationError { + /// The descriptor targets the wrong OriginWeave Protocol generation. + ProtocolVersion(BrowserProtocolVersionRequirementError), + /// The supplied runtime protocol or browser revision is invalid or has drifted. + RuntimeRevision(BrowserProtocolRuntimeRequirementError), + /// The descriptor does not explicitly declare the required capability. + Capability(BrowserProtocolCapabilityRequirementError), +} + +impl fmt::Display for BrowserProtocolUseValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ProtocolVersion(error) => error.fmt(formatter), + Self::RuntimeRevision(error) => error.fmt(formatter), + Self::Capability(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for BrowserProtocolUseValidationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ProtocolVersion(error) => Some(error), + Self::RuntimeRevision(error) => Some(error), + Self::Capability(error) => Some(error), + } + } +} + /// Failure to construct canonical browser protocol adapter metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolDescriptorError { From d39cbf5d31da8578a495d6795bb4983405403d3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:24:24 +0900 Subject: [PATCH 026/313] feat(core): export browser protocol use validation types --- 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 6a8c7b6bd..bf8f2fa5e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -17,9 +17,9 @@ mod contracts; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, - BrowserProtocolRuntimeRequirementError, BrowserProtocolVersionRequirementError, - MAX_BROWSER_PROTOCOL_METADATA_BYTES, OriginWeaveProtocolVersion, - OriginWeaveProtocolVersionParseError, + BrowserProtocolRuntimeRequirementError, BrowserProtocolUseValidationError, + BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From 72c4c3359b745357ec23942efabf13cebaa0f36f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:25:32 +0900 Subject: [PATCH 027/313] test(core): cover browser protocol validation error evidence --- .../tests/browser_protocol_use_validation.rs | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs index ba559a5af..cf7c8ad0b 100644 --- a/crates/originweave-core/tests/browser_protocol_use_validation.rs +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -1,9 +1,10 @@ use std::error::Error; use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolUseValidationError, BrowserProtocolVersionRequirementError, - OriginWeaveProtocolVersion, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, + BrowserProtocolRuntimeRequirementError, BrowserProtocolUseValidationError, + BrowserProtocolVersionRequirementError, OriginWeaveProtocolVersion, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -86,7 +87,7 @@ fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box Result<(), Box Date: Wed, 12 Aug 2026 15:37:05 +0900 Subject: [PATCH 028/313] test(core): require runtime browser protocol kind binding --- .../tests/browser_protocol_use_validation.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs index cf7c8ad0b..1bba239de 100644 --- a/crates/originweave-core/tests/browser_protocol_use_validation.rs +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -150,3 +150,24 @@ fn validation_errors_preserve_stable_typed_sources() { ); } } + +#[test] +fn runtime_protocol_kind_mismatch_precedes_revision_and_capability_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + assert_eq!( + descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { + descriptor_kind: BrowserProtocolKind::WebDriverBiDi, + runtime_kind: BrowserProtocolKind::ChromeDevToolsProtocol, + }) + ); + Ok(()) +} From de107ca6fa0de44d911fbfb52f7dc8f9a9fb8bca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:41:47 +0900 Subject: [PATCH 029/313] test(core): bind all validated uses to runtime protocol kind --- .../tests/browser_protocol_use_validation.rs | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs index 1bba239de..8994253ec 100644 --- a/crates/originweave-core/tests/browser_protocol_use_validation.rs +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -32,6 +32,7 @@ fn validated_use_binds_all_required_adapter_metadata() -> Result<(), Box Result<(), Box> { + let descriptor = descriptor()?; + + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { + descriptor_kind: BrowserProtocolKind::WebDriverBiDi, + runtime_kind: BrowserProtocolKind::ChromeDevToolsProtocol, + }) + ); + let error = error.err().ok_or("expected protocol kind mismatch")?; + assert_eq!( + error.to_string(), + "runtime browser protocol kind does not match the pinned adapter kind" + ); + assert!(error.source().is_none()); + Ok(()) +} + #[test] fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box> { let descriptor = descriptor()?; @@ -82,6 +113,7 @@ fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box Result<(), Box Result<(), Box> { - let descriptor = descriptor()?; - - assert_eq!( - descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::ChromeDevToolsProtocol, - "runtime revision with spaces", - "browser/revision", - BrowserProtocolCapability::NetworkObservation, - ), - Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { - descriptor_kind: BrowserProtocolKind::WebDriverBiDi, - runtime_kind: BrowserProtocolKind::ChromeDevToolsProtocol, - }) - ); - Ok(()) -} From 3294f2779e9e27f6bf17834841b9e64c178619aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:43:00 +0900 Subject: [PATCH 030/313] feat(core): bind validated use to runtime protocol kind --- .../originweave-core/src/browser_protocol.rs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 369666ece..d4650ac30 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -293,21 +293,29 @@ impl BrowserProtocolAdapterDescriptor { /// Validate all adapter metadata prerequisites for one immediate browser operation. /// /// Validation is intentionally ordered and fail closed: the exact - /// OriginWeave Protocol generation is checked first, then the supplied - /// runtime protocol/browser revisions, then the required adapter - /// capability. Success returns a non-cloneable value that a later trusted - /// transport can consume as proof that these metadata prerequisites were - /// checked together. It is not browser or Agent authority and does not - /// authenticate the caller supplying runtime revision evidence. + /// OriginWeave Protocol generation is checked first, then the caller-supplied + /// runtime protocol family, then the supplied runtime protocol/browser + /// revisions, and finally the required adapter capability. Success returns + /// a non-cloneable value that a later trusted transport can consume as proof + /// that these metadata prerequisites were checked together. It is not + /// browser or Agent authority and does not authenticate or attest the caller + /// supplying runtime metadata. pub fn validate_use( &self, required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_kind: BrowserProtocolKind, runtime_protocol_revision: &str, runtime_browser_revision: &str, required_capability: BrowserProtocolCapability, ) -> Result { self.require_originweave_protocol_version(required_originweave_protocol_version) .map_err(BrowserProtocolUseValidationError::ProtocolVersion)?; + if self.kind != runtime_kind { + return Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { + descriptor_kind: self.kind, + runtime_kind, + }); + } self.require_runtime_revisions(runtime_protocol_revision, runtime_browser_revision) .map_err(BrowserProtocolUseValidationError::RuntimeRevision)?; self.require_capability(required_capability) @@ -484,6 +492,13 @@ impl std::error::Error for BrowserProtocolCapabilityRequirementError {} pub enum BrowserProtocolUseValidationError { /// The descriptor targets the wrong OriginWeave Protocol generation. ProtocolVersion(BrowserProtocolVersionRequirementError), + /// The runtime transport reports a different protocol family than the descriptor. + ProtocolKindMismatch { + /// Browser protocol family pinned by the adapter descriptor. + descriptor_kind: BrowserProtocolKind, + /// Browser protocol family reported by the runtime transport. + runtime_kind: BrowserProtocolKind, + }, /// The supplied runtime protocol or browser revision is invalid or has drifted. RuntimeRevision(BrowserProtocolRuntimeRequirementError), /// The descriptor does not explicitly declare the required capability. @@ -494,6 +509,8 @@ impl fmt::Display for BrowserProtocolUseValidationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::ProtocolVersion(error) => error.fmt(formatter), + Self::ProtocolKindMismatch { .. } => formatter + .write_str("runtime browser protocol kind does not match the pinned adapter kind"), Self::RuntimeRevision(error) => error.fmt(formatter), Self::Capability(error) => error.fmt(formatter), } @@ -504,6 +521,7 @@ impl std::error::Error for BrowserProtocolUseValidationError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::ProtocolVersion(error) => Some(error), + Self::ProtocolKindMismatch { .. } => None, Self::RuntimeRevision(error) => Some(error), Self::Capability(error) => Some(error), } From 9aed5ae21aca022f58253566c87e67be648675bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:46:08 +0900 Subject: [PATCH 031/313] docs(changelog): record browser protocol use validation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d197d67e..2241b0dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. +- Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant 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. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. From 2e939a6a64515efd7a73e9d9ea74f0d23128a93d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 15:56:54 +0900 Subject: [PATCH 032/313] test(evidence): require browser protocol validation audit metadata --- .../browser_protocol_validation_evidence.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs diff --git a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs new file mode 100644 index 000000000..0f81870a2 --- /dev/null +++ b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs @@ -0,0 +1,78 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + OriginWeaveProtocolVersion, +}; +use originweave_evidence::BrowserProtocolValidationEvidence; + +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"; + +#[test] +fn records_exact_metadata_from_one_validated_browser_protocol_use() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?; + + let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); + + assert_eq!(evidence.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!( + evidence.originweave_protocol_version(), + ORIGINWEAVE_PROTOCOL_VERSION + ); + assert_eq!(evidence.adapter_version(), ADAPTER_VERSION); + assert_eq!(evidence.protocol_revision(), PROTOCOL_REVISION); + assert_eq!(evidence.browser_revision(), BROWSER_REVISION); + assert_eq!( + evidence.capability(), + BrowserProtocolCapability::SemanticObservation + ); + Ok(()) +} + +#[test] +fn evidence_is_owned_audit_metadata_not_reusable_validation_authority() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + ORIGINWEAVE_PROTOCOL_VERSION, + "originweave-cdp-v1", + "cdp-1-3-r1639810", + BROWSER_REVISION, + &[BrowserProtocolCapability::NetworkObservation], + )?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + "cdp-1-3-r1639810", + BROWSER_REVISION, + BrowserProtocolCapability::NetworkObservation, + )?; + + let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); + let cloned = evidence.clone(); + + assert_eq!(cloned, evidence); + assert_eq!(cloned.kind(), BrowserProtocolKind::ChromeDevToolsProtocol); + assert_eq!( + cloned.capability(), + BrowserProtocolCapability::NetworkObservation + ); + Ok(()) +} From f11a88b62c58f076e853ecbf8eb053ab4716f583 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 16:10:35 +0900 Subject: [PATCH 033/313] test(evidence): apply canonical browser protocol evidence formatting --- .../tests/browser_protocol_validation_evidence.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs index 0f81870a2..3e8e975db 100644 --- a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs +++ b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs @@ -48,7 +48,8 @@ fn records_exact_metadata_from_one_validated_browser_protocol_use() -> Result<() } #[test] -fn evidence_is_owned_audit_metadata_not_reusable_validation_authority() -> Result<(), Box> { +fn evidence_is_owned_audit_metadata_not_reusable_validation_authority() -> Result<(), Box> +{ let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::ChromeDevToolsProtocol, ORIGINWEAVE_PROTOCOL_VERSION, From 9ae9b62ed39364fea852656fac0a52393b71c69d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 16:18:00 +0900 Subject: [PATCH 034/313] feat(evidence): record validated browser protocol metadata --- crates/originweave-evidence/src/lib.rs | 74 +++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index ad183e9eb..406c8be03 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -17,7 +17,10 @@ pub use sensitive_access::{ use std::collections::BTreeMap; -use originweave_core::Origin; +use originweave_core::{ + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, +}; const REDACTED: &str = "[REDACTED]"; @@ -34,6 +37,75 @@ pub const MAX_METADATA_VALUE_BYTES: usize = 8_192; /// Maximum source URL or source-locator size retained in provenance metadata. pub const MAX_PROVENANCE_TEXT_BYTES: usize = 8_192; +/// Immutable credential-safe audit metadata for one validated browser protocol use. +/// +/// This value can only be constructed from [`ValidatedBrowserProtocolUse`], so +/// it records metadata that already passed the exact OriginWeave generation, +/// runtime protocol-family, pinned runtime-revision, and capability checks. It +/// intentionally remains ordinary cloneable evidence: cloning this value does +/// not recreate the non-cloneable validation prerequisite or grant browser, +/// Agent, origin, session, context, network, or secret authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserProtocolValidationEvidence { + kind: BrowserProtocolKind, + originweave_protocol_version: OriginWeaveProtocolVersion, + adapter_version: String, + protocol_revision: String, + browser_revision: String, + capability: BrowserProtocolCapability, +} + +impl BrowserProtocolValidationEvidence { + /// Record owned audit metadata from one already validated browser protocol use. + #[must_use] + pub fn from_validated_use(validated: &ValidatedBrowserProtocolUse) -> Self { + Self { + kind: validated.kind(), + originweave_protocol_version: validated.originweave_protocol_version(), + adapter_version: validated.adapter_version().to_owned(), + protocol_revision: validated.protocol_revision().to_owned(), + browser_revision: validated.browser_revision().to_owned(), + capability: validated.capability(), + } + } + + /// Return the validated browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.kind + } + + /// Return the validated OriginWeave Protocol generation. + #[must_use] + pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { + self.originweave_protocol_version + } + + /// Return the bounded validated adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.adapter_version + } + + /// Return the bounded validated upstream protocol-revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.protocol_revision + } + + /// Return the bounded validated browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.browser_revision + } + + /// Return the exact browser protocol capability validated for this use. + #[must_use] + pub const fn capability(&self) -> BrowserProtocolCapability { + self.capability + } +} + /// An HTTP method recorded for network evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum HttpMethod { From 79aeef1cdc7dffa7b11ae2a7e29867eb1881019d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 16:22:49 +0900 Subject: [PATCH 035/313] docs: record browser protocol validation evidence --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2241b0dea..1361033af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. - Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant browser/Agent 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. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. @@ -76,4 +77,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 +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From b521bbcc3c70907cdf66a189d932c6cfa8c3a526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:07:35 +0900 Subject: [PATCH 036/313] test(browser): require runtime adapter version binding --- ...rowser_protocol_runtime_adapter_version.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs diff --git a/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs new file mode 100644 index 000000000..ccfa6f9b5 --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs @@ -0,0 +1,83 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolAdapterVersionRequirementError, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolUseValidationError, + OriginWeaveProtocolVersion, +}; + +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() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +#[test] +fn runtime_adapter_version_is_bound_into_atomic_use_validation() -> Result<(), Box> { + let descriptor = descriptor()?; + + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + )?; + + assert_eq!(validated.adapter_version(), ADAPTER_VERSION); + Ok(()) +} + +#[test] +fn runtime_adapter_version_mismatch_precedes_revision_and_capability_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + assert_eq!( + descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::AdapterVersion( + BrowserProtocolAdapterVersionRequirementError::AdapterVersionMismatch, + )) + ); + Ok(()) +} + +#[test] +fn malformed_runtime_adapter_version_fails_closed_before_revision_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + assert_eq!( + descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "runtime adapter/version", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::AdapterVersion( + BrowserProtocolAdapterVersionRequirementError::InvalidAdapterVersion, + )) + ); + Ok(()) +} From 79fbb1f0957120bef9c4d836504a4c25ba6ce9ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:11:59 +0900 Subject: [PATCH 037/313] test(browser): narrow runtime adapter version contract --- ...rowser_protocol_runtime_adapter_version.rs | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs index ccfa6f9b5..d3ea4c7fe 100644 --- a/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs +++ b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs @@ -1,9 +1,8 @@ use std::error::Error; use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolAdapterVersionRequirementError, - BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolUseValidationError, - OriginWeaveProtocolVersion, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -45,19 +44,25 @@ fn runtime_adapter_version_mismatch_precedes_revision_and_capability_checks() -> Result<(), Box> { let descriptor = descriptor()?; + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) + ); + let error = error.err().ok_or("expected adapter version mismatch")?; assert_eq!( - descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - "originweave-bidi-v2", - "runtime revision with spaces", - "browser/revision", - BrowserProtocolCapability::NetworkObservation, - ), - Err(BrowserProtocolUseValidationError::AdapterVersion( - BrowserProtocolAdapterVersionRequirementError::AdapterVersionMismatch, - )) + error.to_string(), + "runtime browser adapter version does not match the pinned adapter version" ); + assert!(error.source().is_none()); Ok(()) } @@ -66,18 +71,24 @@ fn malformed_runtime_adapter_version_fails_closed_before_revision_checks() -> Result<(), Box> { let descriptor = descriptor()?; + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "runtime adapter/version", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::InvalidAdapterVersion) + ); + let error = error.err().ok_or("expected invalid adapter version")?; assert_eq!( - descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - "runtime adapter/version", - "runtime revision with spaces", - "browser/revision", - BrowserProtocolCapability::NetworkObservation, - ), - Err(BrowserProtocolUseValidationError::AdapterVersion( - BrowserProtocolAdapterVersionRequirementError::InvalidAdapterVersion, - )) + error.to_string(), + "runtime browser adapter version must be a bounded ASCII metadata token" ); + assert!(error.source().is_none()); Ok(()) } From 6db44cb5f6ced1d081859e0b2aac9250baa1a7c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:13:52 +0900 Subject: [PATCH 038/313] feat(browser): bind runtime adapter version --- .../originweave-core/src/browser_protocol.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index d4650ac30..d5d941163 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -294,7 +294,7 @@ impl BrowserProtocolAdapterDescriptor { /// /// Validation is intentionally ordered and fail closed: the exact /// OriginWeave Protocol generation is checked first, then the caller-supplied - /// runtime protocol family, then the supplied runtime protocol/browser + /// runtime protocol family, runtime adapter version, protocol/browser /// revisions, and finally the required adapter capability. Success returns /// a non-cloneable value that a later trusted transport can consume as proof /// that these metadata prerequisites were checked together. It is not @@ -304,6 +304,7 @@ impl BrowserProtocolAdapterDescriptor { &self, required_originweave_protocol_version: OriginWeaveProtocolVersion, runtime_kind: BrowserProtocolKind, + runtime_adapter_version: &str, runtime_protocol_revision: &str, runtime_browser_revision: &str, required_capability: BrowserProtocolCapability, @@ -316,6 +317,12 @@ impl BrowserProtocolAdapterDescriptor { runtime_kind, }); } + if !metadata_token_is_valid(runtime_adapter_version) { + return Err(BrowserProtocolUseValidationError::InvalidAdapterVersion); + } + if self.adapter_version != runtime_adapter_version { + return Err(BrowserProtocolUseValidationError::AdapterVersionMismatch); + } self.require_runtime_revisions(runtime_protocol_revision, runtime_browser_revision) .map_err(BrowserProtocolUseValidationError::RuntimeRevision)?; self.require_capability(required_capability) @@ -499,6 +506,10 @@ pub enum BrowserProtocolUseValidationError { /// Browser protocol family reported by the runtime transport. runtime_kind: BrowserProtocolKind, }, + /// The runtime adapter-version token was malformed. + InvalidAdapterVersion, + /// The runtime adapter version differs from the pinned descriptor version. + AdapterVersionMismatch, /// The supplied runtime protocol or browser revision is invalid or has drifted. RuntimeRevision(BrowserProtocolRuntimeRequirementError), /// The descriptor does not explicitly declare the required capability. @@ -511,6 +522,11 @@ impl fmt::Display for BrowserProtocolUseValidationError { Self::ProtocolVersion(error) => error.fmt(formatter), Self::ProtocolKindMismatch { .. } => formatter .write_str("runtime browser protocol kind does not match the pinned adapter kind"), + Self::InvalidAdapterVersion => formatter + .write_str("runtime browser adapter version must be a bounded ASCII metadata token"), + Self::AdapterVersionMismatch => formatter.write_str( + "runtime browser adapter version does not match the pinned adapter version", + ), Self::RuntimeRevision(error) => error.fmt(formatter), Self::Capability(error) => error.fmt(formatter), } @@ -521,7 +537,9 @@ impl std::error::Error for BrowserProtocolUseValidationError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::ProtocolVersion(error) => Some(error), - Self::ProtocolKindMismatch { .. } => None, + Self::ProtocolKindMismatch { .. } + | Self::InvalidAdapterVersion + | Self::AdapterVersionMismatch => None, Self::RuntimeRevision(error) => Some(error), Self::Capability(error) => Some(error), } From d2ef3b505dca28376f57f48080b94f1522c9b5df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:14:25 +0900 Subject: [PATCH 039/313] test(browser): pass runtime adapter version --- .../tests/browser_protocol_use_validation.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs index 8994253ec..31a15238c 100644 --- a/crates/originweave-core/tests/browser_protocol_use_validation.rs +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -33,6 +33,7 @@ fn validated_use_binds_all_required_adapter_metadata() -> Result<(), Box Result<(), Box> { let descriptor = descriptor()?; let error = descriptor.validate_use( ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::ChromeDevToolsProtocol, + "runtime adapter/version", "runtime revision with spaces", "browser/revision", BrowserProtocolCapability::NetworkObservation, @@ -114,6 +117,7 @@ fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box Result<(), Box Date: Wed, 12 Aug 2026 17:14:43 +0900 Subject: [PATCH 040/313] test(evidence): pass validated runtime adapter version --- .../tests/browser_protocol_validation_evidence.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs index 3e8e975db..fdab7d12c 100644 --- a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs +++ b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs @@ -25,6 +25,7 @@ fn records_exact_metadata_from_one_validated_browser_protocol_use() -> Result<() let validated = descriptor.validate_use( ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, PROTOCOL_REVISION, BROWSER_REVISION, BrowserProtocolCapability::SemanticObservation, @@ -50,18 +51,21 @@ fn records_exact_metadata_from_one_validated_browser_protocol_use() -> Result<() #[test] fn evidence_is_owned_audit_metadata_not_reusable_validation_authority() -> Result<(), Box> { + let cdp_adapter_version = "originweave-cdp-v1"; + let cdp_protocol_revision = "cdp-1-3-r1639810"; let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::ChromeDevToolsProtocol, ORIGINWEAVE_PROTOCOL_VERSION, - "originweave-cdp-v1", - "cdp-1-3-r1639810", + cdp_adapter_version, + cdp_protocol_revision, BROWSER_REVISION, &[BrowserProtocolCapability::NetworkObservation], )?; let validated = descriptor.validate_use( ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::ChromeDevToolsProtocol, - "cdp-1-3-r1639810", + cdp_adapter_version, + cdp_protocol_revision, BROWSER_REVISION, BrowserProtocolCapability::NetworkObservation, )?; From 219ff852913cb2c7c4ef13677f30a00c157cbe6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:19:52 +0900 Subject: [PATCH 041/313] style(browser): apply canonical rustfmt --- crates/originweave-core/src/browser_protocol.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index d5d941163..3e30eccfe 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -522,8 +522,9 @@ impl fmt::Display for BrowserProtocolUseValidationError { Self::ProtocolVersion(error) => error.fmt(formatter), Self::ProtocolKindMismatch { .. } => formatter .write_str("runtime browser protocol kind does not match the pinned adapter kind"), - Self::InvalidAdapterVersion => formatter - .write_str("runtime browser adapter version must be a bounded ASCII metadata token"), + Self::InvalidAdapterVersion => formatter.write_str( + "runtime browser adapter version must be a bounded ASCII metadata token", + ), Self::AdapterVersionMismatch => formatter.write_str( "runtime browser adapter version does not match the pinned adapter version", ), From f368a4e4326b25b8825e0b4ec1753ec23de727e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:23:09 +0900 Subject: [PATCH 042/313] docs(changelog): record runtime adapter version binding --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1361033af..2b3f92b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. - Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant browser/Agent authority. +- 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. - 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 2e0404e34cc0088bab18d6790bbaa32003624171 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:36:22 +0900 Subject: [PATCH 043/313] test(core): require same-call browser protocol dispatch validation --- .../browser_protocol_runtime_dispatch.rs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs new file mode 100644 index 000000000..168c25758 --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs @@ -0,0 +1,94 @@ +use std::{cell::Cell, error::Error}; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, +}; + +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() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +#[test] +fn exact_runtime_validation_hands_single_use_proof_to_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let called = Cell::new(false); + + let output = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + |validated| { + called.set(true); + ( + validated.adapter_version().to_owned(), + validated.capability(), + ) + }, + )?; + + assert!(called.get()); + assert_eq!(output.0, ADAPTER_VERSION); + assert_eq!(output.1, BrowserProtocolCapability::Navigation); + Ok(()) +} + +#[test] +fn runtime_mismatch_prevents_dispatch_callback() -> Result<(), Box> { + let descriptor = descriptor()?; + let called = Cell::new(false); + + let result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + |_| { + called.set(true); + "dispatched" + }, + ); + + assert_eq!( + result, + Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) + ); + assert!(!called.get()); + Ok(()) +} + +#[test] +fn adapter_callback_failure_remains_separate_after_validation() -> Result<(), Box> { + let descriptor = descriptor()?; + + let dispatch_result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + |_| Err::<(), _>("adapter-failure"), + )?; + + assert_eq!(dispatch_result, Err("adapter-failure")); + Ok(()) +} From decc241c7fd6311d4eebb5a5f43b3cec281f994b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:40:19 +0900 Subject: [PATCH 044/313] test(core): share dispatch callback monomorphization --- .../browser_protocol_runtime_dispatch.rs | 76 +++++++++++++------ 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs index 168c25758..6208be11b 100644 --- a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs +++ b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs @@ -1,8 +1,9 @@ use std::{cell::Cell, error::Error}; use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, + dispatch_browser_protocol_if_runtime_matches, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolUseValidationError, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -11,6 +12,13 @@ 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<(String, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + fn descriptor() -> Result> { Ok(BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, @@ -22,73 +30,95 @@ fn descriptor() -> Result> { )?) } +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) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok(( + validated.adapter_version().to_owned(), + validated.capability(), + )) +} + +fn failing_dispatch(_: ValidatedBrowserProtocolUse) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Err("adapter-failure") +} + #[test] fn exact_runtime_validation_hands_single_use_proof_to_dispatch() -> Result<(), Box> { let descriptor = descriptor()?; - let called = Cell::new(false); + reset_dispatch_marker(); - let output = descriptor.dispatch_if_runtime_matches( + let dispatch_result = dispatch_browser_protocol_if_runtime_matches( + &descriptor, ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::WebDriverBiDi, ADAPTER_VERSION, PROTOCOL_REVISION, BROWSER_REVISION, BrowserProtocolCapability::Navigation, - |validated| { - called.set(true); - ( - validated.adapter_version().to_owned(), - validated.capability(), - ) - }, + successful_dispatch as DispatchFn, )?; - assert!(called.get()); - assert_eq!(output.0, ADAPTER_VERSION); - assert_eq!(output.1, BrowserProtocolCapability::Navigation); + assert!(dispatch_was_called()); + assert_eq!( + dispatch_result, + Ok(( + ADAPTER_VERSION.to_owned(), + BrowserProtocolCapability::Navigation + )) + ); Ok(()) } #[test] fn runtime_mismatch_prevents_dispatch_callback() -> Result<(), Box> { let descriptor = descriptor()?; - let called = Cell::new(false); + reset_dispatch_marker(); - let result = descriptor.dispatch_if_runtime_matches( + let result = dispatch_browser_protocol_if_runtime_matches( + &descriptor, ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::WebDriverBiDi, "originweave-bidi-v2", PROTOCOL_REVISION, BROWSER_REVISION, BrowserProtocolCapability::Navigation, - |_| { - called.set(true); - "dispatched" - }, + successful_dispatch as DispatchFn, ); assert_eq!( result, Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) ); - assert!(!called.get()); + assert!(!dispatch_was_called()); Ok(()) } #[test] fn adapter_callback_failure_remains_separate_after_validation() -> Result<(), Box> { let descriptor = descriptor()?; + reset_dispatch_marker(); - let dispatch_result = descriptor.dispatch_if_runtime_matches( + let dispatch_result = dispatch_browser_protocol_if_runtime_matches( + &descriptor, ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::WebDriverBiDi, ADAPTER_VERSION, PROTOCOL_REVISION, BROWSER_REVISION, BrowserProtocolCapability::Navigation, - |_| Err::<(), _>("adapter-failure"), + failing_dispatch as DispatchFn, )?; + assert!(dispatch_was_called()); assert_eq!(dispatch_result, Err("adapter-failure")); Ok(()) } From ff5a8015a0174d3d1f011f731c9e1f15a270f2bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:40:55 +0900 Subject: [PATCH 045/313] feat(core): gate protocol dispatch on current runtime metadata --- .../src/browser_protocol_dispatch.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 crates/originweave-core/src/browser_protocol_dispatch.rs diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs new file mode 100644 index 000000000..b3e62954a --- /dev/null +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -0,0 +1,39 @@ +use crate::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +/// Validate current browser-protocol metadata and immediately invoke one dispatch callback. +/// +/// The runtime protocol family, adapter version, protocol revision, and browser revision must be +/// sampled from the trusted adapter that is about to perform the operation. Validation occurs +/// before `dispatch` is invoked, and the callback receives the resulting non-cloneable +/// [`ValidatedBrowserProtocolUse`] by ownership so this boundary does not turn successful +/// validation into reusable ambient authority. +/// +/// A successful callback invocation does not authenticate the adapter process, authorize a browser +/// session, browsing context, origin, destination, secret, or approval, or prove a browser +/// post-condition. Those remain separate higher-level execution boundaries. +pub fn dispatch_browser_protocol_if_runtime_matches( + descriptor: &BrowserProtocolAdapterDescriptor, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_kind: BrowserProtocolKind, + runtime_adapter_version: &str, + runtime_protocol_revision: &str, + runtime_browser_revision: &str, + required_capability: BrowserProtocolCapability, + dispatch: F, +) -> Result +where + F: FnOnce(ValidatedBrowserProtocolUse) -> R, +{ + let validated = descriptor.validate_use( + required_originweave_protocol_version, + runtime_kind, + runtime_adapter_version, + runtime_protocol_revision, + runtime_browser_revision, + required_capability, + )?; + Ok(dispatch(validated)) +} From d6a84594815d1859e9fb3e9b6883bda3c61f3167 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:41:17 +0900 Subject: [PATCH 046/313] feat(core): expose validated protocol dispatch boundary --- crates/originweave-core/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index bf8f2fa5e..99153aa7c 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -9,6 +9,7 @@ #![deny(missing_docs)] mod browser_protocol; +mod browser_protocol_dispatch; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; @@ -21,6 +22,7 @@ pub use browser_protocol::{ BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; +pub use browser_protocol_dispatch::dispatch_browser_protocol_if_runtime_matches; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 37dc70d5d66465af7d60935f3d50cd8c54ffe7fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:42:18 +0900 Subject: [PATCH 047/313] docs: record validated protocol dispatch boundary --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3f92b50..fadbbc8b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. - Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant browser/Agent authority. - 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. - 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 1218808104b609de936de02a7844a39075019135 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:44:49 +0900 Subject: [PATCH 048/313] style(core): apply canonical dispatch test formatting --- .../tests/browser_protocol_runtime_dispatch.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs index 6208be11b..28c575606 100644 --- a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs +++ b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs @@ -1,9 +1,9 @@ use std::{cell::Cell, error::Error}; use originweave_core::{ - dispatch_browser_protocol_if_runtime_matches, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolUseValidationError, - OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + dispatch_browser_protocol_if_runtime_matches, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = From 3c23d10640ba48e416bf1dfcddef419e18ebdeab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:45:50 +0900 Subject: [PATCH 049/313] refactor(core): make validated dispatch an adapter descriptor method --- .../src/browser_protocol_dispatch.rs | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index b3e62954a..d11245f84 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -3,37 +3,39 @@ use crate::{ BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; -/// Validate current browser-protocol metadata and immediately invoke one dispatch callback. -/// -/// The runtime protocol family, adapter version, protocol revision, and browser revision must be -/// sampled from the trusted adapter that is about to perform the operation. Validation occurs -/// before `dispatch` is invoked, and the callback receives the resulting non-cloneable -/// [`ValidatedBrowserProtocolUse`] by ownership so this boundary does not turn successful -/// validation into reusable ambient authority. -/// -/// A successful callback invocation does not authenticate the adapter process, authorize a browser -/// session, browsing context, origin, destination, secret, or approval, or prove a browser -/// post-condition. Those remain separate higher-level execution boundaries. -pub fn dispatch_browser_protocol_if_runtime_matches( - descriptor: &BrowserProtocolAdapterDescriptor, - required_originweave_protocol_version: OriginWeaveProtocolVersion, - runtime_kind: BrowserProtocolKind, - runtime_adapter_version: &str, - runtime_protocol_revision: &str, - runtime_browser_revision: &str, - required_capability: BrowserProtocolCapability, - dispatch: F, -) -> Result -where - F: FnOnce(ValidatedBrowserProtocolUse) -> R, -{ - let validated = descriptor.validate_use( - required_originweave_protocol_version, - runtime_kind, - runtime_adapter_version, - runtime_protocol_revision, - runtime_browser_revision, - required_capability, - )?; - Ok(dispatch(validated)) +impl BrowserProtocolAdapterDescriptor { + /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. + /// + /// The runtime protocol family, adapter version, protocol revision, and browser revision must + /// be sampled from the trusted adapter that is about to perform the operation. Validation + /// occurs before `dispatch` is invoked, and the callback receives the resulting non-cloneable + /// [`ValidatedBrowserProtocolUse`] by ownership so this boundary does not turn successful + /// validation into reusable ambient authority. + /// + /// A successful callback invocation does not authenticate the adapter process, authorize a + /// browser session, browsing context, origin, destination, secret, or approval, or prove a + /// browser post-condition. Those remain separate higher-level execution boundaries. + pub fn dispatch_if_runtime_matches( + &self, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_kind: BrowserProtocolKind, + runtime_adapter_version: &str, + runtime_protocol_revision: &str, + runtime_browser_revision: &str, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse) -> R, + { + let validated = self.validate_use( + required_originweave_protocol_version, + runtime_kind, + runtime_adapter_version, + runtime_protocol_revision, + runtime_browser_revision, + required_capability, + )?; + Ok(dispatch(validated)) + } } From 043ea96a7a5803bb60f9ceab19cc620684a38de7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:46:08 +0900 Subject: [PATCH 050/313] refactor(core): keep dispatch method on adapter descriptor --- crates/originweave-core/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 99153aa7c..70cafa890 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -22,7 +22,6 @@ pub use browser_protocol::{ BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; -pub use browser_protocol_dispatch::dispatch_browser_protocol_if_runtime_matches; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 570b4d055ee9b24e7d9dfa82d824fcf023911912 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:48:21 +0900 Subject: [PATCH 051/313] test(core): dispatch through adapter descriptor method --- .../tests/browser_protocol_runtime_dispatch.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs index 28c575606..a16334fcf 100644 --- a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs +++ b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs @@ -3,7 +3,6 @@ use std::{cell::Cell, error::Error}; use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, - dispatch_browser_protocol_if_runtime_matches, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -56,8 +55,7 @@ fn exact_runtime_validation_hands_single_use_proof_to_dispatch() -> Result<(), B let descriptor = descriptor()?; reset_dispatch_marker(); - let dispatch_result = dispatch_browser_protocol_if_runtime_matches( - &descriptor, + let dispatch_result = descriptor.dispatch_if_runtime_matches( ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::WebDriverBiDi, ADAPTER_VERSION, @@ -83,8 +81,7 @@ fn runtime_mismatch_prevents_dispatch_callback() -> Result<(), Box> { let descriptor = descriptor()?; reset_dispatch_marker(); - let result = dispatch_browser_protocol_if_runtime_matches( - &descriptor, + let result = descriptor.dispatch_if_runtime_matches( ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::WebDriverBiDi, "originweave-bidi-v2", @@ -107,8 +104,7 @@ fn adapter_callback_failure_remains_separate_after_validation() -> Result<(), Bo let descriptor = descriptor()?; reset_dispatch_marker(); - let dispatch_result = dispatch_browser_protocol_if_runtime_matches( - &descriptor, + let dispatch_result = descriptor.dispatch_if_runtime_matches( ORIGINWEAVE_PROTOCOL_VERSION, BrowserProtocolKind::WebDriverBiDi, ADAPTER_VERSION, From 9fd91db4d75dfa0db714605d1248f399d0fc6428 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:53:44 +0900 Subject: [PATCH 052/313] refactor(core): bind dispatch to bounded runtime metadata --- .../src/browser_protocol_dispatch.rs | 56 ++++++++++++++----- crates/originweave-core/src/lib.rs | 1 + .../browser_protocol_runtime_dispatch.rs | 27 ++++----- 3 files changed, 58 insertions(+), 26 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index d11245f84..6664545b3 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -3,14 +3,47 @@ use crate::{ BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; +/// Current runtime metadata sampled from the browser-protocol adapter about to perform I/O. +/// +/// This value is untrusted descriptive input. Constructing it does not validate or authenticate an +/// adapter, browser, or protocol revision and grants no browser or Agent authority. The descriptor +/// validates every field against its reviewed metadata before a dispatch callback can run. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserProtocolRuntimeMetadata<'a> { + kind: BrowserProtocolKind, + adapter_version: &'a str, + protocol_revision: &'a str, + browser_revision: &'a str, +} + +impl<'a> BrowserProtocolRuntimeMetadata<'a> { + /// Build one runtime metadata snapshot for immediate validation and dispatch. + /// + /// String syntax and descriptor equality are intentionally checked later by + /// [`BrowserProtocolAdapterDescriptor::dispatch_if_runtime_matches`], so malformed caller data + /// remains representable as input that the fail-closed boundary can reject deterministically. + pub const fn new( + kind: BrowserProtocolKind, + adapter_version: &'a str, + protocol_revision: &'a str, + browser_revision: &'a str, + ) -> Self { + Self { + kind, + adapter_version, + protocol_revision, + browser_revision, + } + } +} + impl BrowserProtocolAdapterDescriptor { /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. /// - /// The runtime protocol family, adapter version, protocol revision, and browser revision must - /// be sampled from the trusted adapter that is about to perform the operation. Validation - /// occurs before `dispatch` is invoked, and the callback receives the resulting non-cloneable - /// [`ValidatedBrowserProtocolUse`] by ownership so this boundary does not turn successful - /// validation into reusable ambient authority. + /// `runtime_metadata` must be sampled from the trusted adapter that is about to perform the + /// operation. Validation occurs before `dispatch` is invoked, and the callback receives the + /// resulting non-cloneable [`ValidatedBrowserProtocolUse`] by ownership so this boundary does + /// not turn successful validation into reusable ambient authority. /// /// A successful callback invocation does not authenticate the adapter process, authorize a /// browser session, browsing context, origin, destination, secret, or approval, or prove a @@ -18,10 +51,7 @@ impl BrowserProtocolAdapterDescriptor { pub fn dispatch_if_runtime_matches( &self, required_originweave_protocol_version: OriginWeaveProtocolVersion, - runtime_kind: BrowserProtocolKind, - runtime_adapter_version: &str, - runtime_protocol_revision: &str, - runtime_browser_revision: &str, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, required_capability: BrowserProtocolCapability, dispatch: F, ) -> Result @@ -30,10 +60,10 @@ impl BrowserProtocolAdapterDescriptor { { let validated = self.validate_use( required_originweave_protocol_version, - runtime_kind, - runtime_adapter_version, - runtime_protocol_revision, - runtime_browser_revision, + runtime_metadata.kind, + runtime_metadata.adapter_version, + runtime_metadata.protocol_revision, + runtime_metadata.browser_revision, required_capability, )?; Ok(dispatch(validated)) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 70cafa890..8e67d18d6 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -22,6 +22,7 @@ pub use browser_protocol::{ BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; +pub use browser_protocol_dispatch::BrowserProtocolRuntimeMetadata; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs index a16334fcf..0ca669d7d 100644 --- a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs +++ b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs @@ -2,7 +2,8 @@ use std::{cell::Cell, error::Error}; use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -29,6 +30,15 @@ fn descriptor() -> Result> { )?) } +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + fn reset_dispatch_marker() { DISPATCH_CALLED.with(|called| called.set(false)); } @@ -57,10 +67,7 @@ fn exact_runtime_validation_hands_single_use_proof_to_dispatch() -> Result<(), B let dispatch_result = descriptor.dispatch_if_runtime_matches( ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, + runtime_metadata(ADAPTER_VERSION), BrowserProtocolCapability::Navigation, successful_dispatch as DispatchFn, )?; @@ -83,10 +90,7 @@ fn runtime_mismatch_prevents_dispatch_callback() -> Result<(), Box> { let result = descriptor.dispatch_if_runtime_matches( ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - "originweave-bidi-v2", - PROTOCOL_REVISION, - BROWSER_REVISION, + runtime_metadata("originweave-bidi-v2"), BrowserProtocolCapability::Navigation, successful_dispatch as DispatchFn, ); @@ -106,10 +110,7 @@ fn adapter_callback_failure_remains_separate_after_validation() -> Result<(), Bo let dispatch_result = descriptor.dispatch_if_runtime_matches( ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, + runtime_metadata(ADAPTER_VERSION), BrowserProtocolCapability::Navigation, failing_dispatch as DispatchFn, )?; From 8675dc34302e3b623865940a546c9b6af29ee598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:07:06 +0900 Subject: [PATCH 053/313] test(core): require context-bound protocol dispatch --- .../browser_context_protocol_dispatch.rs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 crates/originweave-core/tests/browser_context_protocol_dispatch.rs diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs new file mode 100644 index 000000000..b282ab638 --- /dev/null +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -0,0 +1,157 @@ +use std::{cell::Cell, error::Error}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, + BrowserProtocolUseValidationError, BrowserRegistryError, DocumentEpoch, + 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::Navigation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +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_and_runtime_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")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + session, + context, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!( + result, + Ok((1, BrowserProtocolCapability::Navigation)) + ); + + registry.advance_document(context)?; + reset_dispatch_marker(); + let next = descriptor.dispatch_if_context_current( + ®istry, + session, + context, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + assert!(dispatch_was_called()); + assert_eq!(next, Ok((2, BrowserProtocolCapability::Navigation))); + Ok(()) +} + +#[test] +fn cross_session_context_reuse_fails_before_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + attacker, + context, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + } + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn protocol_mismatch_after_context_validation_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")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + session, + context, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} From bb571f034ff908c4dd56827c5d3c2613b723f92b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:09:17 +0900 Subject: [PATCH 054/313] style(core): apply canonical dispatch test formatting --- .../tests/browser_context_protocol_dispatch.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs index b282ab638..dfd28f300 100644 --- a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -1,10 +1,10 @@ use std::{cell::Cell, error::Error}; use originweave_core::{ - BrowserAuthorityRegistry, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, - BrowserProtocolUseValidationError, BrowserRegistryError, DocumentEpoch, - OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserAuthorityRegistry, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, + DocumentEpoch, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -75,10 +75,7 @@ fn exact_context_and_runtime_metadata_gate_one_dispatch_call() -> Result<(), Box )?; assert!(dispatch_was_called()); - assert_eq!( - result, - Ok((1, BrowserProtocolCapability::Navigation)) - ); + assert_eq!(result, Ok((1, BrowserProtocolCapability::Navigation))); registry.advance_document(context)?; reset_dispatch_marker(); @@ -129,7 +126,8 @@ fn cross_session_context_reuse_fails_before_dispatch() -> Result<(), Box Result<(), Box> { +fn protocol_mismatch_after_context_validation_still_prevents_dispatch() -> Result<(), Box> +{ let descriptor = descriptor()?; let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("webdriver-session")?; From c25a060d4a2779517923749c94a7018ead0e1ef3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:14:03 +0900 Subject: [PATCH 055/313] test(core): cover context dispatch denial boundaries --- .../browser_context_protocol_dispatch.rs | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs index dfd28f300..1ea10a045 100644 --- a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -4,7 +4,8 @@ use originweave_core::{ BrowserAuthorityRegistry, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, - DocumentEpoch, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserSessionId, BrowsingContextId, DocumentEpoch, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -125,6 +126,50 @@ fn cross_session_context_reuse_fails_before_dispatch() -> Result<(), Box 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 unknown_session = BrowserSessionId::new(999)?; + let unknown_context = BrowsingContextId::new(999)?; + + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_current( + ®istry, + unknown_session, + context, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + assert!(!dispatch_was_called()); + + assert_eq!( + descriptor.dispatch_if_context_current( + ®istry, + session, + unknown_context, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + #[test] fn protocol_mismatch_after_context_validation_still_prevents_dispatch() -> Result<(), Box> { @@ -153,3 +198,24 @@ fn protocol_mismatch_after_context_validation_still_prevents_dispatch() -> Resul assert!(!dispatch_was_called()); Ok(()) } + +#[test] +fn context_protocol_dispatch_errors_preserve_typed_sources() { + let authority = BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext, + ); + assert!(authority.source().is_some()); + assert_eq!( + authority.to_string(), + "browser context authority denied protocol dispatch: browsing context is not registered in this authority registry" + ); + + let protocol = BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch, + ); + assert!(protocol.source().is_some()); + assert_eq!( + protocol.to_string(), + "browser protocol validation denied context dispatch: runtime browser protocol adapter version does not match the pinned descriptor version" + ); +} From 15a837a6ba1e960fae2651bd2eafbf973a691a03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:15:10 +0900 Subject: [PATCH 056/313] feat(core): revalidate browser context ownership --- .../originweave-core/src/browser_registry.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 85e4af3aa..d88c1a537 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -118,6 +118,34 @@ impl BrowserAuthorityRegistry { .ok_or(BrowserRegistryError::UnknownBrowsingContext) } + /// Return the current document epoch only when the supplied session owns the context. + /// + /// This is an immediate-use registry check for trusted browser adapters. It proves only that + /// the OriginWeave session/context pair is currently registered together and returns the + /// registry's current document epoch. It does not authenticate a browser process, authorize an + /// origin or action, or make the returned epoch a reusable browser capability. + pub fn current_context_epoch( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Result { + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let expected_session = self + .context_session + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != browser_session { + return Err(BrowserRegistryError::ContextSessionMismatch { + expected: expected_session, + actual: browser_session, + }); + } + self.current_epoch(browsing_context) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. /// /// Call this whenever navigation or document replacement invalidates actionable node identity. From 5c92e6daa01f81c8b919cf6d1b5239a38974444a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:15:43 +0900 Subject: [PATCH 057/313] feat(core): gate protocol dispatch on current context --- .../src/browser_protocol_dispatch.rs | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index 6664545b3..47085c552 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -1,6 +1,9 @@ +use std::fmt; + use crate::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserAuthorityRegistry, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolKind, BrowserProtocolUseValidationError, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; /// Current runtime metadata sampled from the browser-protocol adapter about to perform I/O. @@ -68,4 +71,72 @@ impl BrowserProtocolAdapterDescriptor { )?; Ok(dispatch(validated)) } + + /// Revalidate exact browser session/context ownership and runtime metadata before dispatch. + /// + /// The registry check occurs first and returns its current document epoch. The exact protocol + /// generation, runtime protocol family, adapter version, upstream/browser revisions, and + /// required capability are then validated before `dispatch` can run. The callback receives the + /// non-cloneable protocol-use proof plus the registry epoch sampled for this immediate use. + /// + /// This is a composition prerequisite, not complete browser-action authority. In particular, + /// typed input still requires separate current origin/document/node and deterministic policy + /// authorization, while navigation still requires destination/network/TLS/HTTP authority. + /// The caller remains responsible for sampling runtime metadata from the adapter about to + /// perform I/O and for preventing registry mutation across its larger execution transaction. + pub fn dispatch_if_context_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + 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 + .current_context_epoch(browser_session, browsing_context) + .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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserContextProtocolDispatchError { + /// The supplied browser session/context pair is not current in the authority registry. + BrowserAuthority(BrowserRegistryError), + /// The current browser-protocol metadata or required capability failed validation. + ProtocolValidation(BrowserProtocolUseValidationError), +} + +impl fmt::Display for BrowserContextProtocolDispatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BrowserAuthority(error) => { + write!(formatter, "browser context authority denied protocol dispatch: {error}") + } + Self::ProtocolValidation(error) => { + write!(formatter, "browser protocol validation denied context dispatch: {error}") + } + } + } +} + +impl std::error::Error for BrowserContextProtocolDispatchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::BrowserAuthority(error) => Some(error), + Self::ProtocolValidation(error) => Some(error), + } + } } From e33bf68ae1cd2faa6dba8d5c0e39e41f028c90b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:15:55 +0900 Subject: [PATCH 058/313] feat(core): export context protocol dispatch error --- 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 8e67d18d6..b78f76917 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -22,7 +22,9 @@ pub use browser_protocol::{ BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; -pub use browser_protocol_dispatch::BrowserProtocolRuntimeMetadata; +pub use browser_protocol_dispatch::{ + BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, +}; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 4a42f03129630b57aba4fcb81afd03471c912427 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:17:34 +0900 Subject: [PATCH 059/313] style(core): apply canonical protocol dispatch formatting --- .../originweave-core/src/browser_protocol_dispatch.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index 47085c552..c2d3682c2 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -123,10 +123,16 @@ impl fmt::Display for BrowserContextProtocolDispatchError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::BrowserAuthority(error) => { - write!(formatter, "browser context authority denied protocol dispatch: {error}") + write!( + formatter, + "browser context authority denied protocol dispatch: {error}" + ) } Self::ProtocolValidation(error) => { - write!(formatter, "browser protocol validation denied context dispatch: {error}") + write!( + formatter, + "browser protocol validation denied context dispatch: {error}" + ) } } } From 3e44bdaa5b8514e6e147b01dd32400fa44eec7f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:18:29 +0900 Subject: [PATCH 060/313] docs(changelog): record context-bound protocol dispatch --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fadbbc8b2..28d0ef113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant browser/Agent authority. - 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. - 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 955373a6811d5464bdfe801803411e6b808673e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:21:38 +0900 Subject: [PATCH 061/313] test(core): match canonical adapter-version error text --- .../originweave-core/tests/browser_context_protocol_dispatch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs index 1ea10a045..9367607fc 100644 --- a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -216,6 +216,6 @@ fn context_protocol_dispatch_errors_preserve_typed_sources() { assert!(protocol.source().is_some()); assert_eq!( protocol.to_string(), - "browser protocol validation denied context dispatch: runtime browser protocol adapter version does not match the pinned descriptor version" + "browser protocol validation denied context dispatch: runtime browser adapter version does not match the pinned adapter version" ); } From aba4a7c21dab96bab6a2396a5b43b9bee239fe40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:24:27 +0900 Subject: [PATCH 062/313] test(core): require bounded context dispatch target value --- .../browser_context_protocol_dispatch.rs | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs index 9367607fc..f9b671e8a 100644 --- a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -1,7 +1,7 @@ use std::{cell::Cell, error::Error}; use originweave_core::{ - BrowserAuthorityRegistry, BrowserContextProtocolDispatchError, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, OriginWeaveProtocolVersion, @@ -41,6 +41,13 @@ fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> ) } +fn target( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, +) -> BrowserContextDispatchTarget { + BrowserContextDispatchTarget::new(browser_session, browsing_context) +} + fn reset_dispatch_marker() { DISPATCH_CALLED.with(|called| called.set(false)); } @@ -57,6 +64,17 @@ fn successful_dispatch( Ok((current_epoch.value(), validated.capability())) } +#[test] +fn context_dispatch_target_preserves_requested_ids_without_granting_authority() -> Result<(), Box> { + let session = BrowserSessionId::new(7)?; + let context = BrowsingContextId::new(11)?; + let target = target(session, context); + + assert_eq!(target.browser_session(), session); + assert_eq!(target.browsing_context(), context); + Ok(()) +} + #[test] fn exact_context_and_runtime_metadata_gate_one_dispatch_call() -> Result<(), Box> { let descriptor = descriptor()?; @@ -67,8 +85,7 @@ fn exact_context_and_runtime_metadata_gate_one_dispatch_call() -> Result<(), Box let result = descriptor.dispatch_if_context_current( ®istry, - session, - context, + target(session, context), ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata(ADAPTER_VERSION), BrowserProtocolCapability::Navigation, @@ -82,8 +99,7 @@ fn exact_context_and_runtime_metadata_gate_one_dispatch_call() -> Result<(), Box reset_dispatch_marker(); let next = descriptor.dispatch_if_context_current( ®istry, - session, - context, + target(session, context), ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata(ADAPTER_VERSION), BrowserProtocolCapability::Navigation, @@ -105,8 +121,7 @@ fn cross_session_context_reuse_fails_before_dispatch() -> Result<(), Box Result<(), Box Result<(), Box Resul let result = descriptor.dispatch_if_context_current( ®istry, - session, - context, + target(session, context), ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata("originweave-bidi-v2"), BrowserProtocolCapability::Navigation, From 58e0b5cc2ba3fb425f4ea1096990ad54ae25e4bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:09:18 +0900 Subject: [PATCH 063/313] feat(core): add bounded browser context dispatch target --- .../src/browser_protocol_dispatch.rs | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs index c2d3682c2..97c7777da 100644 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -40,6 +40,44 @@ impl<'a> BrowserProtocolRuntimeMetadata<'a> { } } +/// Exact OriginWeave browser session/context requested for one immediate protocol dispatch. +/// +/// This value only keeps the two identifiers together so a caller cannot accidentally reorder or +/// independently substitute them at the dispatch boundary. Constructing or copying it does not +/// prove that either identifier is registered, current, or authorized; the authority registry must +/// validate the pair immediately before protocol metadata validation and callback invocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextDispatchTarget { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, +} + +impl BrowserContextDispatchTarget { + /// Group one OriginWeave browser session and browsing context for immediate dispatch checking. + #[must_use] + pub const fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Self { + Self { + browser_session, + browsing_context, + } + } + + /// Return the OriginWeave browser session requested for this dispatch. + #[must_use] + pub const fn browser_session(self) -> BrowserSessionId { + self.browser_session + } + + /// Return the OriginWeave browsing context requested for this dispatch. + #[must_use] + pub const fn browsing_context(self) -> BrowsingContextId { + self.browsing_context + } +} + impl BrowserProtocolAdapterDescriptor { /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. /// @@ -87,8 +125,7 @@ impl BrowserProtocolAdapterDescriptor { pub fn dispatch_if_context_current( &self, authority_registry: &BrowserAuthorityRegistry, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, + target: BrowserContextDispatchTarget, required_originweave_protocol_version: OriginWeaveProtocolVersion, runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, required_capability: BrowserProtocolCapability, @@ -98,7 +135,7 @@ impl BrowserProtocolAdapterDescriptor { F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, { let current_epoch = authority_registry - .current_context_epoch(browser_session, browsing_context) + .current_context_epoch(target.browser_session(), target.browsing_context()) .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; self.dispatch_if_runtime_matches( required_originweave_protocol_version, From 9ec83fdff008f4218816380257556ed62c34fd95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:09:36 +0900 Subject: [PATCH 064/313] feat(core): export browser context dispatch target --- crates/originweave-core/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b78f76917..05acea258 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -23,7 +23,7 @@ pub use browser_protocol::{ OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; pub use browser_protocol_dispatch::{ - BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, + BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From beef39976949976551e5aebd445f38089162a0ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:10:12 +0900 Subject: [PATCH 065/313] style(core): apply canonical context dispatch formatting --- .../tests/browser_context_protocol_dispatch.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs index f9b671e8a..333b28701 100644 --- a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -65,7 +65,8 @@ fn successful_dispatch( } #[test] -fn context_dispatch_target_preserves_requested_ids_without_granting_authority() -> Result<(), Box> { +fn context_dispatch_target_preserves_requested_ids_without_granting_authority() +-> Result<(), Box> { let session = BrowserSessionId::new(7)?; let context = BrowsingContextId::new(11)?; let target = target(session, context); From 3850bf075318b54d893ff6ae67e24ce6ea53ccc0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:12:26 +0900 Subject: [PATCH 066/313] style(core): apply canonical dispatch export formatting --- 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 05acea258..19c2c59a9 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -23,7 +23,8 @@ pub use browser_protocol::{ OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, }; pub use browser_protocol_dispatch::{ - BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, + BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolRuntimeMetadata, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From 48ec0a38e0fb2eed9d1eb5ee4cbae715130a6991 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:21:52 +0900 Subject: [PATCH 067/313] test(core): require browser context origin binding --- .../tests/browser_context_origin_binding.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 crates/originweave-core/tests/browser_context_origin_binding.rs diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs new file mode 100644 index 000000000..a277bc7c7 --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -0,0 +1,87 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, + DocumentEpoch, Origin, +}; + +fn first_origin() -> Result> { + Ok(Origin::parse("http://127.0.0.1:43127")?) +} + +fn second_origin() -> Result> { + Ok(Origin::parse("http://localhost:43127")?) +} + +#[test] +fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = first_origin()?; + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!(epoch, DocumentEpoch::new(1)?); + assert_eq!( + registry.bind_context_origin(session, context, &origin)?, + epoch + ); + + let node = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_eq!(node.document_epoch(), epoch); + assert_eq!(node.origin(), &origin); + Ok(()) +} + +#[test] +fn context_origin_change_requires_document_rotation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.bind_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch, DocumentEpoch::new(2)?); + assert_eq!( + registry.bind_context_origin(session, context, &second)?, + next_epoch + ); + Ok(()) +} + +#[test] +fn context_origin_binding_rejects_cross_session_and_unknown_authority() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + assert_eq!( + registry.bind_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + + let unknown_session = BrowserSessionId::new(999)?; + assert_eq!( + registry.bind_context_origin(unknown_session, context, &origin), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let unknown_context = BrowsingContextId::new(999)?; + assert_eq!( + registry.bind_context_origin(owner, unknown_context, &origin), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} From 34993ea8f5bc5ee31147e6fb9fa86e55e601fc64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:23:46 +0900 Subject: [PATCH 068/313] style(core): apply canonical context origin test formatting --- .../originweave-core/tests/browser_context_origin_binding.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs index a277bc7c7..1bc13e668 100644 --- a/crates/originweave-core/tests/browser_context_origin_binding.rs +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -57,7 +57,8 @@ fn context_origin_change_requires_document_rotation() -> Result<(), Box Result<(), Box> { +fn context_origin_binding_rejects_cross_session_and_unknown_authority() -> Result<(), Box> +{ let mut registry = BrowserAuthorityRegistry::new(); let owner = registry.register_session("owner-session")?; let attacker = registry.register_session("attacker-session")?; From 2e799eea7c2b1e98d87bd8d1c12be00fc209fd71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:27:48 +0900 Subject: [PATCH 069/313] feat(core): bind current browser context origin --- .../originweave-core/src/browser_registry.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index d88c1a537..bf76f79c3 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -146,6 +146,35 @@ impl BrowserAuthorityRegistry { self.current_epoch(browsing_context) } + /// Bind the canonical origin observed for the exact current browser document. + /// + /// This boundary lets a trusted browser adapter establish current document-origin state before + /// semantic-node discovery begins. The supplied session must own the context. Rebinding the + /// same canonical origin in the same document epoch is idempotent, while a different origin + /// fails closed until [`Self::advance_document`] rotates the document epoch and clears the old + /// binding. The returned epoch is descriptive immediate-use state, not reusable capability. + /// + /// This method does not authenticate the adapter, derive an origin from Chromium, authorize a + /// destination or action, or prove that any browser I/O occurred. + pub fn bind_context_origin( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => {} + None => { + self.context_origin.insert(browsing_context, origin.clone()); + } + } + Ok(epoch) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. /// /// Call this whenever navigation or document replacement invalidates actionable node identity. From 4f5e9b0f81df48fb178eaafb7cf68a925071ce39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:35:13 +0900 Subject: [PATCH 070/313] test(core): make controlled origin parsing error-compatible --- .../tests/browser_context_origin_binding.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs index 1bc13e668..9d404ff3c 100644 --- a/crates/originweave-core/tests/browser_context_origin_binding.rs +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -1,4 +1,5 @@ use std::error::Error; +use std::io; use originweave_core::{ BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, @@ -6,11 +7,13 @@ use originweave_core::{ }; fn first_origin() -> Result> { - Ok(Origin::parse("http://127.0.0.1:43127")?) + Origin::parse("http://127.0.0.1:43127") + .map_err(|_error| io::Error::other("controlled first origin must be valid").into()) } fn second_origin() -> Result> { - Ok(Origin::parse("http://localhost:43127")?) + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid").into()) } #[test] From 05c8834072783f7f53be342b441712c1eeb9c736 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:43:03 +0900 Subject: [PATCH 071/313] test(core): require current browser context origin --- .../browser_context_origin_revalidation.rs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 crates/originweave-core/tests/browser_context_origin_revalidation.rs diff --git a/crates/originweave-core/tests/browser_context_origin_revalidation.rs b/crates/originweave-core/tests/browser_context_origin_revalidation.rs new file mode 100644 index 000000000..ca9109b5c --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_revalidation.rs @@ -0,0 +1,95 @@ +use std::error::Error; +use std::io; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; + +fn first_origin() -> Result { + Origin::parse("http://127.0.0.1:43127") + .map_err(|_error| io::Error::other("controlled first origin must be valid")) +} + +fn second_origin() -> Result { + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid")) +} + +#[test] +fn current_context_origin_must_be_bound_before_revalidation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = first_origin()?; + + assert_eq!( + registry.require_context_origin(session, context, &origin), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!( + registry.require_context_origin(session, context, &origin), + Ok(epoch) + ); + Ok(()) +} + +#[test] +fn current_context_origin_revalidation_fails_closed_on_mismatch() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + assert!(registry + .require_context_origin(session, context, &first) + .is_ok()); + Ok(()) +} + +#[test] +fn document_rotation_requires_fresh_origin_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + let next_epoch = registry.advance_document(context)?; + assert_eq!( + registry.require_context_origin(session, context, &first), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + registry.bind_context_origin(session, context, &second)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Ok(next_epoch) + ); + Ok(()) +} + +#[test] +fn context_origin_revalidation_preserves_session_ownership() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + registry.bind_context_origin(owner, context, &origin)?; + assert_eq!( + registry.require_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + Ok(()) +} From dae6566fc19fe4a30a77b8361bc24a7071af9a8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:45:24 +0900 Subject: [PATCH 072/313] test(core): apply canonical origin revalidation formatting --- .../tests/browser_context_origin_revalidation.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/browser_context_origin_revalidation.rs b/crates/originweave-core/tests/browser_context_origin_revalidation.rs index ca9109b5c..7f35fab1d 100644 --- a/crates/originweave-core/tests/browser_context_origin_revalidation.rs +++ b/crates/originweave-core/tests/browser_context_origin_revalidation.rs @@ -46,9 +46,11 @@ fn current_context_origin_revalidation_fails_closed_on_mismatch() -> Result<(), registry.require_context_origin(session, context, &second), Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) ); - assert!(registry - .require_context_origin(session, context, &first) - .is_ok()); + assert!( + registry + .require_context_origin(session, context, &first) + .is_ok() + ); Ok(()) } From 61485d0ceb2b22dabadc9f5297eacbbb296c5f67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:50:26 +0900 Subject: [PATCH 073/313] feat(core): revalidate current browser context origin --- .../originweave-core/src/browser_registry.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index bf76f79c3..7be75ec6e 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -175,6 +175,34 @@ impl BrowserAuthorityRegistry { Ok(epoch) } + /// Revalidate the canonical origin bound to the exact current browser document. + /// + /// This read-only immediate-use boundary lets a trusted browser adapter prove that the exact + /// OriginWeave session/context still has the expected canonical origin in its current document + /// epoch. It fails closed when the current document has no origin binding, including directly + /// after [`Self::advance_document`], and rejects a different origin without mutating registry + /// state. The returned epoch is descriptive current state, not a reusable capability. + /// + /// This method does not authenticate the adapter or browser process, derive the current origin + /// from Chromium, authorize a destination or action, perform browser I/O, or attest that the + /// caller-supplied origin came from the running browser. + pub fn require_context_origin( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + let expected_origin = self + .context_origin + .get(&browsing_context) + .ok_or(BrowserRegistryError::ContextOriginNotBound)?; + if expected_origin != origin { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Ok(epoch) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. /// /// Call this whenever navigation or document replacement invalidates actionable node identity. @@ -271,6 +299,8 @@ pub enum BrowserRegistryError { /// Session supplied by the current caller. actual: BrowserSessionId, }, + /// The current document has no canonical origin bound to the browsing context. + ContextOriginNotBound, /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, /// The registry exhausted one of its monotonic internal identifier spaces. @@ -299,6 +329,8 @@ impl fmt::Display for BrowserRegistryError { expected.value(), actual.value() ), + Self::ContextOriginNotBound => formatter + .write_str("browsing context has no canonical origin bound for the current document"), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), Self::IdentifierSpaceExhausted => { @@ -588,6 +620,7 @@ mod tests { expected: expected_values[0], actual: actual_values[0], }, + BrowserRegistryError::ContextOriginNotBound, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, BrowserRegistryError::IdentifierSpaceExhausted, BrowserRegistryError::DocumentEpochExhausted, From 1eae12991eb5a2f91ce2d1486e9008c9ac3663e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:53:37 +0900 Subject: [PATCH 074/313] style(core): apply canonical context-origin formatting --- crates/originweave-core/src/browser_registry.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 7be75ec6e..b9c494002 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -329,8 +329,9 @@ impl fmt::Display for BrowserRegistryError { expected.value(), actual.value() ), - Self::ContextOriginNotBound => formatter - .write_str("browsing context has no canonical origin bound for the current document"), + Self::ContextOriginNotBound => formatter.write_str( + "browsing context has no canonical origin bound for the current document", + ), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), Self::IdentifierSpaceExhausted => { From a726f18b4057686cef92ca91da6b6bc07d8d5afd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:13:13 +0900 Subject: [PATCH 075/313] 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 076/313] 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 077/313] 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 078/313] 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 079/313] 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 080/313] 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 081/313] 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 082/313] 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 083/313] 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 084/313] 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 085/313] 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 086/313] 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 087/313] 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 088/313] 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 089/313] 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 090/313] 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 091/313] 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 092/313] 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 2ed5dfb3ce9adf999991b6eb86e851480c9da7f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:23:15 +0900 Subject: [PATCH 093/313] fix(core): preserve browser authority retirement on stack alignment --- .../originweave-core/src/browser_registry.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index d88c1a537..a45da9478 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -107,6 +107,58 @@ impl BrowserAuthorityRegistry { }) } + /// Retire one browsing context and all registry-local authority derived from it. + /// + /// Retirement removes external lookup state, the current document epoch and origin, and every + /// node binding owned by the context. Monotonic context and node identifiers are never reused. + /// This revokes only OriginWeave registry-local authority; it does not prove that an external + /// browser context or process has terminated. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + if self.context_session.remove(&browsing_context).is_none() { + return Err(BrowserRegistryError::UnknownBrowsingContext); + } + self.context_by_external + .retain(|_key, context| *context != browsing_context); + self.context_epoch.remove(&browsing_context); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + Ok(()) + } + + /// Retire one browser session and every registered context and node binding beneath it. + /// + /// Retirement removes only registry-local authority and external lookup state. Session, + /// context, and node identifiers remain strictly monotonic so a later registration of the same + /// opaque browser identifier cannot revive stale authority. External process termination is a + /// separate adapter responsibility. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.remove(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + self.session_by_external + .retain(|_external, session| *session != browser_session); + self.context_by_external + .retain(|(session, _external), _context| *session != browser_session); + self.context_session + .retain(|_context, session| *session != browser_session); + + let live_contexts = &self.context_session; + self.context_epoch + .retain(|context, _epoch| live_contexts.contains_key(context)); + self.context_origin + .retain(|context, _origin| live_contexts.contains_key(context)); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); + Ok(()) + } + /// Return the currently active document epoch for a known browsing context. pub fn current_epoch( &self, From 993529296d9e95e360571ed6cd7c11dea9b8a70f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:22:31 +0900 Subject: [PATCH 094/313] 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 095/313] 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 096/313] 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 097/313] 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 098/313] 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 099/313] 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 100/313] 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 101/313] 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 102/313] 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 103/313] 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 104/313] 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 105/313] 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 106/313] 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 107/313] 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 108/313] 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 109/313] 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 110/313] 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 111/313] 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 112/313] 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 113/313] 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 114/313] 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 115/313] 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 116/313] 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 117/313] 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 118/313] 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 119/313] 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 120/313] 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 121/313] 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 122/313] 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 123/313] 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 124/313] 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 125/313] 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 126/313] 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 127/313] 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 128/313] 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 129/313] 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 130/313] 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 131/313] 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 132/313] 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 133/313] 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 134/313] 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 135/313] 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 136/313] 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 137/313] 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 138/313] 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 139/313] 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 140/313] 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 193f3a3843f7bbb92b8d720ba8e127a82f2dcb47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:16:08 +0900 Subject: [PATCH 141/313] docs(changelog): restore capability requirement after stack alignment --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2f9e968..f98e29765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - 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, and grants no browser, action, network, or secret authority by protocol kind alone. +- 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 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. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. From 0bd1878bdc1fc5592aa86422c277b8fca2136c57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:09:40 +0900 Subject: [PATCH 142/313] 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 143/313] 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 144/313] 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 145/313] 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 146/313] 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 147/313] 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 148/313] 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 149/313] 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 150/313] 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 151/313] 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 152/313] 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 153/313] 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 154/313] 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 155/313] 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 156/313] 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 157/313] 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 158/313] 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 159/313] 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 160/313] 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 161/313] 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 162/313] 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 163/313] 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 164/313] 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 165/313] 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 166/313] 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 167/313] 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 168/313] 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 169/313] 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 170/313] 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 171/313] 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 172/313] 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 173/313] 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 174/313] 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 175/313] 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 176/313] 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 177/313] 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 178/313] 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 179/313] 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 180/313] 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 181/313] 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 182/313] 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 183/313] 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 184/313] 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 185/313] 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 186/313] 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 187/313] 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 188/313] 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 189/313] 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 190/313] 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 191/313] 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 192/313] 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 193/313] 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 194/313] 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 195/313] 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 196/313] 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 197/313] 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 198/313] 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 199/313] 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 200/313] 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 201/313] 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 202/313] 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 203/313] 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 204/313] 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 205/313] 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 206/313] 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 207/313] 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 208/313] 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 209/313] 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 210/313] 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 211/313] 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 212/313] 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 213/313] 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 214/313] 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 215/313] 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 216/313] 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 217/313] 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 218/313] 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 219/313] 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 220/313] 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 221/313] 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 222/313] 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 223/313] 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 224/313] 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 225/313] 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 226/313] 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 227/313] 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 228/313] 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 229/313] 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 230/313] 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 231/313] 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 232/313] 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 233/313] 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 234/313] 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 235/313] 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 236/313] 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 237/313] 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 238/313] 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 239/313] 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 240/313] 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 241/313] 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 242/313] 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 243/313] 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 244/313] 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 245/313] 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 246/313] 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 247/313] 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 248/313] 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 249/313] 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 250/313] 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 251/313] 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 252/313] 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 253/313] 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 254/313] 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 255/313] 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 256/313] 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 257/313] 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 258/313] 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 259/313] 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 260/313] 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 261/313] 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 262/313] 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 263/313] 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 264/313] 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 265/313] 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 266/313] 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 267/313] 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 268/313] 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 269/313] 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 270/313] 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 271/313] 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 272/313] 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 273/313] 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 274/313] 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 275/313] 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 276/313] 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 277/313] 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 278/313] 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 279/313] 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 280/313] 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 281/313] 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 282/313] 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 283/313] 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 284/313] 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 285/313] 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 286/313] 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 287/313] 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 288/313] 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 289/313] 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 290/313] 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 291/313] 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 292/313] 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 293/313] 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 294/313] 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 295/313] 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 296/313] 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 297/313] 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 298/313] 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 299/313] 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 300/313] 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 301/313] 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 d7cc599ad5658a4f6656b70da301e4215d03b761 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:19:54 +0900 Subject: [PATCH 302/313] docs: record browser context origin binding --- 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..9c8155383 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. +- 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. - 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..07ddea2d5 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_binding_is_recorded_in_the_changelog(self) -> None: + """The public origin-binding boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("BrowserAuthorityRegistry::bind_context_origin", 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 889659e9798bd2d365ff7378376529d31e799dc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:22:59 +0900 Subject: [PATCH 303/313] 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 6996e6fe9915d7f270ce0265d148358d141aa21f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:24:05 +0900 Subject: [PATCH 304/313] docs: record context origin revalidation --- 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..0ab2cb7b9 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. +- 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. - 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..1bd436c8d 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_revalidation_is_recorded_in_the_changelog(self) -> None: + """The public origin-revalidation boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("BrowserAuthorityRegistry::require_context_origin", 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 c9915c1e8a02013e2b7158fd85dbe2d59972b900 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:35:42 +0900 Subject: [PATCH 305/313] 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 306/313] 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 415b8862ba5210a7bc55c38da00c87f512fbd932 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:42:19 +0900 Subject: [PATCH 307/313] docs: record runtime revision boundary --- CHANGELOG.md | 1 + tests/test_repository_contract.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bedf905a5..63d231262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. +- Public `require_runtime_revisions` validation that fails closed when caller-supplied runtime protocol or browser revision evidence is malformed or differs from the descriptor's pinned revisions, preserving typed malformed-versus-drift errors without authenticating or attesting the adapter process. - 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. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..4c529bd21 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_runtime_revision_boundary_is_recorded_in_the_changelog(self) -> None: + """The public runtime-revision boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("require_runtime_revisions", 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 7f482887142d0c8be21c7e16f59535ddbe2e42f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 07:08:56 +0900 Subject: [PATCH 308/313] chore: retrigger exact-head checks From ca3dcfca2432d1540a703d8a35f158df6fe9ae1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:21:37 -0700 Subject: [PATCH 309/313] test(core): reconstruct capability requirement on live parent --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 52 +- Cargo.lock | 31 + Cargo.toml | 1 + README.md | 8 +- crates/originweave-bap/Cargo.toml | 12 + crates/originweave-bap/src/lib.rs | 329 ++++++++ .../originweave-bap/tests/task_lifecycle.rs | 253 +++++++ .../tests/task_lifecycle_recovery.rs | 142 ++++ crates/originweave-core/Cargo.toml | 4 + .../src/browser_authority_registry.rs | 163 ---- .../originweave-core/src/browser_protocol.rs | 419 +--------- .../src/browser_protocol_dispatch.rs | 365 --------- .../src/browser_protocol_operation.rs | 610 --------------- .../originweave-core/src/browser_registry.rs | 476 +----------- .../src/browser_registry_coverage.rs | 73 +- crates/originweave-core/src/contracts.rs | 7 +- crates/originweave-core/src/lib.rs | 104 +-- crates/originweave-core/src/mcp.rs | 230 ++++++ .../src/release_acceptance.rs | 368 +++++++++ crates/originweave-core/src/root.rs | 31 + .../src/webdriver_bidi_command.rs | 422 ----------- .../src/webdriver_bidi_error_code.rs | 170 ----- .../src/webdriver_bidi_response_document.rs | 98 --- ...iver_bidi_response_document_correlation.rs | 269 ------- .../locate_nodes_result_document.rs | 714 ------------------ .../src/webdriver_bidi_response_envelope.rs | 625 --------------- .../src/webdriver_bidi_result.rs | 188 ----- ...webdriver_bidi_websocket_connect_target.rs | 206 ----- .../src/webdriver_bidi_websocket_endpoint.rs | 327 -------- .../tests/browser_authority_registry.rs | 303 ++++---- .../tests/browser_context_origin_binding.rs | 158 ---- ..._context_origin_epoch_protocol_dispatch.rs | 214 ------ ...rowser_context_origin_protocol_dispatch.rs | 180 ----- .../browser_context_origin_revalidation.rs | 97 --- .../browser_context_protocol_dispatch.rs | 234 ------ .../tests/browser_protocol_adapter.rs | 79 +- ...rowser_protocol_runtime_adapter_version.rs | 94 --- .../browser_protocol_runtime_dispatch.rs | 121 --- .../browser_protocol_runtime_revision.rs | 96 --- .../tests/browser_protocol_use_validation.rs | 190 ----- .../tests/browser_registry_cross_instance.rs | 88 +++ ...owser_typed_operation_protocol_dispatch.rs | 150 ---- .../tests/mcp_tools_list_cache.rs | 221 ++++++ .../originweave-core/tests/node_retirement.rs | 39 + .../tests/origin_port_syntax.rs | 18 + .../tests/protocol_version_parsing.rs | 59 -- .../protocol_version_runtime_coverage.rs | 12 - .../tests/release_acceptance.rs | 397 ++++++++++ .../release_acceptance_canonical_text.rs | 116 +++ ...elease_acceptance_meaningful_limitation.rs | 46 ++ .../release_acceptance_resource_bounds.rs | 98 +++ .../tests/release_acceptance_unicode17.rs | 121 +++ .../webdriver_bidi_accessibility_query.rs | 195 ----- .../webdriver_bidi_locate_nodes_admission.rs | 289 ------- .../webdriver_bidi_locate_nodes_atomicity.rs | 88 --- .../webdriver_bidi_locate_nodes_command.rs | 121 --- ..._bidi_locate_nodes_response_correlation.rs | 80 -- ...ver_bidi_locate_nodes_response_document.rs | 100 --- ...ver_bidi_locate_nodes_response_envelope.rs | 141 ---- ...iver_bidi_locate_nodes_result_admission.rs | 297 -------- ...webdriver_bidi_locate_nodes_wire_result.rs | 209 ----- ...driver_bidi_protocol_error_preservation.rs | 44 -- .../webdriver_bidi_protocol_kind_admission.rs | 75 -- .../webdriver_bidi_query_nodes_admission.rs | 365 --------- .../webdriver_bidi_remote_node_reference.rs | 110 --- ...webdriver_bidi_response_document_budget.rs | 98 --- ...er_bidi_response_envelope_failure_edges.rs | 72 -- ...ver_bidi_response_envelope_hostile_json.rs | 107 --- ...webdriver_bidi_response_envelope_parser.rs | 254 ------- .../webdriver_bidi_response_error_code.rs | 130 ---- ...river_bidi_response_error_code_evidence.rs | 35 - ...webdriver_bidi_socket_peer_verification.rs | 107 --- ...webdriver_bidi_websocket_connect_target.rs | 99 --- .../webdriver_bidi_websocket_endpoint.rs | 206 ----- ...iver_bidi_websocket_session_correlation.rs | 92 --- .../webdriver_bidi_wire_authority_binding.rs | 156 ---- crates/originweave-destination/src/lib.rs | 3 +- crates/originweave-destination/src/proxy.rs | 3 + .../originweave-destination/src/resolution.rs | 236 ++++++ .../tests/proxy_port_syntax.rs | 29 + .../tests/resolution_freshness.rs | 235 ++++++ .../resolution_post_expiry_revalidation.rs | 78 ++ .../src/extraction_schema.rs | 297 ++++++++ crates/originweave-evidence/src/lib.rs | 84 +-- .../src/sensitive_access.rs | 5 +- .../src/sensitive_handle_lifecycle.rs | 144 ++++ .../browser_protocol_validation_evidence.rs | 83 -- .../tests/extraction_normalization.rs | 77 ++ .../tests/extraction_schema.rs | 326 ++++++++ .../tests/extraction_schema_error_contract.rs | 48 ++ .../tests/extraction_source_channel_set.rs | 40 + .../tests/sensitive_handle_access_binding.rs | 114 +++ .../sensitive_handle_lifecycle_evidence.rs | 142 ++++ crates/originweave-network/src/lib.rs | 20 +- .../src/webdriver_bidi_connection.rs | 254 ------- .../src/webdriver_bidi_connection/error.rs | 134 ---- .../src/webdriver_bidi_connection/tests.rs | 361 --------- .../src/webdriver_bidi_websocket_handshake.rs | 155 ---- .../tests/webdriver_bidi_tcp_connection.rs | 94 --- .../webdriver_bidi_websocket_handshake.rs | 202 ----- .../tests/extension_mutation_isolation.rs | 349 +++++++++ .../tests/extension_policy_isolation.rs | 221 ++++++ .../tests/extension_secret_isolation.rs | 102 +++ crates/originweave-resource/src/lib.rs | 15 + .../tests/error_contract.rs | 21 + crates/originweave-tls/src/lib.rs | 2 + crates/originweave-tls/src/revocation.rs | 174 +++++ crates/originweave-tls/src/trust.rs | 1 + .../originweave-tls/tests/policy_contract.rs | 2 +- .../tests/revocation_freshness.rs | 119 +++ docs/API_CONTRACT.md | 2 - docs/README.md | 8 + ...10-session-context-bound-node-authority.md | 1 - docs/adr/0016-bap-task-lifecycle-authority.md | 123 +++ docs/adr/0106-provenance-evidence-model.md | 20 +- .../0107-browser-protocol-adapter-strategy.md | 12 +- docs/adr/README.md | 10 + docs/doctoring.md | 24 +- docs/doctoring/browser-agent-protocols.md | 39 +- docs/product-roadmap.md | 1 - docs/product-technical-gap-baseline.md | 91 ++- docs/traceability/mcp-authority-route.md | 31 +- tests/fixtures/agent_task_basic/index.html | 42 ++ tests/test_agent_task_fixture_contract.py | 137 ++++ tests/test_doctoring_reference_contract.py | 28 + ...cumentation_active_pr_evidence_contract.py | 16 +- ...test_gap_snapshot_inventory_consistency.py | 58 ++ tests/test_product_completion_gap_contract.py | 20 +- tests/test_product_documentation_contract.py | 12 +- tests/test_repository_contract.py | 32 +- ...ebdriver_bidi_connect_target_governance.py | 35 - 132 files changed, 6104 insertions(+), 12108 deletions(-) create mode 100644 crates/originweave-bap/Cargo.toml create mode 100644 crates/originweave-bap/src/lib.rs create mode 100644 crates/originweave-bap/tests/task_lifecycle.rs create mode 100644 crates/originweave-bap/tests/task_lifecycle_recovery.rs delete mode 100644 crates/originweave-core/src/browser_authority_registry.rs delete mode 100644 crates/originweave-core/src/browser_protocol_dispatch.rs delete mode 100644 crates/originweave-core/src/browser_protocol_operation.rs create mode 100644 crates/originweave-core/src/release_acceptance.rs create mode 100644 crates/originweave-core/src/root.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_command.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_error_code.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_response_document.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_response_envelope.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_result.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs delete mode 100644 crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs delete mode 100644 crates/originweave-core/tests/browser_context_origin_binding.rs delete mode 100644 crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs delete mode 100644 crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs delete mode 100644 crates/originweave-core/tests/browser_context_origin_revalidation.rs delete mode 100644 crates/originweave-core/tests/browser_context_protocol_dispatch.rs delete mode 100644 crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs delete mode 100644 crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs delete mode 100644 crates/originweave-core/tests/browser_protocol_runtime_revision.rs delete mode 100644 crates/originweave-core/tests/browser_protocol_use_validation.rs create mode 100644 crates/originweave-core/tests/browser_registry_cross_instance.rs delete mode 100644 crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs create mode 100644 crates/originweave-core/tests/mcp_tools_list_cache.rs create mode 100644 crates/originweave-core/tests/node_retirement.rs create mode 100644 crates/originweave-core/tests/origin_port_syntax.rs delete mode 100644 crates/originweave-core/tests/protocol_version_parsing.rs delete mode 100644 crates/originweave-core/tests/protocol_version_runtime_coverage.rs create mode 100644 crates/originweave-core/tests/release_acceptance.rs create mode 100644 crates/originweave-core/tests/release_acceptance_canonical_text.rs create mode 100644 crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs create mode 100644 crates/originweave-core/tests/release_acceptance_resource_bounds.rs create mode 100644 crates/originweave-core/tests/release_acceptance_unicode17.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_response_error_code.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs delete mode 100644 crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs create mode 100644 crates/originweave-destination/tests/proxy_port_syntax.rs create mode 100644 crates/originweave-destination/tests/resolution_freshness.rs create mode 100644 crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs create mode 100644 crates/originweave-evidence/src/extraction_schema.rs create mode 100644 crates/originweave-evidence/src/sensitive_handle_lifecycle.rs delete mode 100644 crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs create mode 100644 crates/originweave-evidence/tests/extraction_normalization.rs create mode 100644 crates/originweave-evidence/tests/extraction_schema.rs create mode 100644 crates/originweave-evidence/tests/extraction_schema_error_contract.rs create mode 100644 crates/originweave-evidence/tests/extraction_source_channel_set.rs create mode 100644 crates/originweave-evidence/tests/sensitive_handle_access_binding.rs create mode 100644 crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs delete mode 100644 crates/originweave-network/src/webdriver_bidi_connection.rs delete mode 100644 crates/originweave-network/src/webdriver_bidi_connection/error.rs delete mode 100644 crates/originweave-network/src/webdriver_bidi_connection/tests.rs delete mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs delete mode 100644 crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs delete mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs create mode 100644 crates/originweave-policy/tests/extension_mutation_isolation.rs create mode 100644 crates/originweave-policy/tests/extension_policy_isolation.rs create mode 100644 crates/originweave-policy/tests/extension_secret_isolation.rs create mode 100644 crates/originweave-resource/tests/error_contract.rs create mode 100644 crates/originweave-tls/src/revocation.rs create mode 100644 crates/originweave-tls/tests/revocation_freshness.rs create mode 100644 docs/adr/0016-bap-task-lifecycle-authority.md create mode 100644 tests/fixtures/agent_task_basic/index.html create mode 100644 tests/test_agent_task_fixture_contract.py create mode 100644 tests/test_doctoring_reference_contract.py create mode 100644 tests/test_gap_snapshot_inventory_consistency.py delete mode 100644 tests/test_webdriver_bidi_connect_target_governance.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a60ca799..fe287389b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,7 +167,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 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. +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. ## 8. Action lifecycle diff --git a/CHANGELOG.md b/CHANGELOG.md index e30cd837b..31768c2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,63 +4,45 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. + ### Added +- Active PR #40 adds the `BrowserAuthorityRegistry`, `BrowserProtocolAdapterDescriptor`, and Agent-Task-bound extension grant/request API; it requires exact `AgentTaskId` authority for extension access and rejects bare `0x` browser-special numeric host spellings. This remains active-PR evidence, not protected-main shipment. +- Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 126-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. +- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge. +- Added `originweave_core::release_acceptance`, a deterministic fail-closed benchmark release-decision contract that requires one authoritative result for every mandatory suite, bounds explicit buyer-visible limitations, rejects duplicate limitation claim identities, and rejects non-canonical surrounding whitespace rather than normalizing it into an alternate claim spelling. - Refreshed the product and technical gap baseline with the 2026-08-24 live inventory: 158 open pull requests (44 ready, 114 draft), refreshed exact base/head evidence for the #208–#222 release, enterprise-approval, BAP, and WARC/PROV chains, the governance issue additions #212 and #215, and a required-check provider-failure record for the fail-closed Strix re-dispatches on #208/#218/#220. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. -- Added a bounded browser-protocol authority registry that maps opaque session, browsing-context, and node identifiers to registry-local identities, rotates document epochs, and revalidates live node handles before actions. -- 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. -- Bound explicit extension-to-Agent grants to a nonzero host-assigned Agent Task identity, so a grant that otherwise matches extension, session, browsing context, origin, expiry, and capability fails closed when reused by a different task. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - 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. -- Public `require_runtime_revisions` validation that fails closed when caller-supplied runtime protocol or browser revision evidence is malformed or differs from the descriptor's pinned revisions, preserving typed malformed-versus-drift errors without authenticating or attesting the adapter process. -- Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant browser/Agent authority. -- 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. -- 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. -- 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. -- 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. -- 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. -- 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. -- Active PR #168 adds deterministic MCP `2026-07-28` stateless tool-routing foundations with bounded names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. This is active-PR evidence only; the complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned until separately integrated on protected `main`. +- Protected main now contains deterministic MCP `2026-07-28` stateless `tools/call` routing with bounded method/tool names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. The complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned. +- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from that protected-main catalog, with `resultType = complete`, zero freshness, private cache scope, no continuation cursor, per-request protocol/client-capability admission, and bounded protocol-version and method metadata validated before cross-field comparison. This remains active-PR evidence only and grants no browser, network, secret, approval, or Agent authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. +- Bounded resolution-freshness authority with trusted monotonic approval time, capped non-zero validity, half-open use windows, non-expanding revalidation, and credential-free authorization timestamps. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. +- Deterministic TLS revocation-material freshness authority with a strict signed `thisUpdate`→`nextUpdate` half-open window and typed invalid-window, not-yet-valid, and stale failures, without claiming OCSP/CRL acquisition, cryptographic validation, or certificate revocation status. - Credential-free TLS evidence containing canonical origin, TCP peers, reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. +- Credential-free sensitive-handle lifecycle evidence binds issuance, exclusive expiry, bounded uses, observed resolution count, and revocation to the exact credential-free `OpaqueHandleOnly` sensitive-access receipt, preserving tenant, actor, task, field set, purpose, destination, classification, policy version, and decision time without storing opaque handle tokens or protected values. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. -- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable. +- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, TLS, and resource-budget failures, including preserved destination-policy, rustls, and operating-system sources where applicable. - Real loopback TCP integration proof plus deterministic timeout, refusal, retry, peer-inspection, peer-mismatch, canonicalization, IPv6 metadata, and single-use replay tests. - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. +- Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, fail-closed schema validation, and deterministic `Display`/`std::error::Error` contracts for public schema failures. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. +- Resumable BAP lifecycle restoration with monotonic sequence recovery and fail-closed sequence exhaustion. - Authoritative product documentation graph spanning PRD, TRD, ADR lifecycle/index, product-wide UML, conceptual ERD, requirement/decision traceability, threat modeling, product-wide test strategy, operability, API/protocol, release/rollback, and current primary-source standards doctoring, with machine-checkable repository contracts that keep conversation-derived future work distinct from protected-main implementation claims. - Purpose-bound data-governance and privacy baseline that rejects both blanket masking and ambient raw-value propagation, defines field-scoped just-in-time disclosure, opaque-handle/trusted-broker boundaries, model/provider/region policy, retention/deletion/residency/break-glass controls, truthful CSAP/SOC 2 readiness language, and machine-checkable documentation contracts without inventing an OriginWeave-owned production database. - Proposed product-wide target-architecture ADRs for the Rust control plane, isolated execution modes, typed actions, semantic observation/stale-node authority, prompt-injection and secret separation, resource-governor priority, provenance evidence, browser/protocol adapters, crawler policy, and hourly automation operational closure; these remain Proposed rather than shipped claims until protected review and merge. @@ -89,14 +71,13 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security -- Registry-issued node validation authenticates registry-instance issuance before session or context lookup, so caller-constructed and cross-registry handles cannot probe whether numeric browser-session identifiers are currently registered. +- Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. - State-changing actions are same-origin by default. - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. -- Bare hexadecimal-prefix host labels such as `0x`, including terminal `.0x` and `.0X` spellings, are rejected as browser-special numeric hosts rather than admitted as DNS authorities. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. - Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. @@ -110,6 +91,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. - DNS TLS identity requires an applicable subjectAltName and never falls back to Common Name; literal IPv4 and IPv6 origins require exact IP subjectAltName entries. - TLS uses an explicit immutable trust-root bundle and fixed verification time, and permits only TLS 1.2 and TLS 1.3. +- TLS trust-bundle policy identifiers must contain at least one ASCII alphanumeric character; punctuation-only labels are rejected while `.`, `_`, `:`, and `-` remain permitted. - TLS resumption, 0-RTT, secret extraction, key logging, client certificates, certificate compression, and dangerous custom verifier hooks are disabled in the first slice. - The operating-system peer is rechecked before, during, and after the deadline-bound TLS handshake. - ALPN selection is restricted to the caller's bounded allow-list, while absence is either explicitly recorded or rejected by policy. @@ -121,4 +103,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 +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..848cb7320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,9 +263,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "originweave-bap" +version = "0.1.0" + [[package]] name = "originweave-core" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "originweave-destination" @@ -554,6 +561,21 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" @@ -566,6 +588,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..0d5ab469c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", "crates/originweave-evidence", diff --git a/README.md b/README.md index a956ff60b..0942976cf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OriginWeave is a Chromium-compatible, Rust-first control plane for governed AI agents on the web. It is designed to let an agent observe, extract, and act without turning untrusted page content into authority, exposing secrets to a model, connecting to an unapproved network destination, accepting an unauthenticated web service, or losing the evidence required to explain what happened. -> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #168 implements only a bounded MCP `2026-07-28` stateless tool-routing and typed-action/policy foundation; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. +> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, authenticated TLS service-identity, and bounded MCP `2026-07-28` stateless `tools/call` routing/policy foundations. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #170 implements only conservative `tools/list` discovery metadata on top of the protected-main MCP catalog; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. ## Why OriginWeave @@ -40,7 +40,7 @@ The repository is organized as independently consumable Rust crates: - `originweave-resource`: task-level RAM, VRAM, thread, and frame-time budgets with cumulative mitigation plans. - `originweave-evidence`: universally value-redacted network evidence and source-bound provenance records. -Active PR #168 additionally carries a non-shipped `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That foundation validates and maps an explicit tool name to an existing typed action; it does not implement transport parsing, `tools/list`, OAuth, browser control, secret materialization, persistence, or ambient authority. +Protected main additionally contains an `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That shipped foundation validates and maps an explicit tool name to an existing typed action while preserving normal OriginWeave policy. Active PR #170 adds non-shipped conservative `tools/list` discovery metadata derived from the same reviewed catalog. Neither boundary implements transport parsing, OAuth, browser control, secret materialization, persistence, or ambient authority. See [ARCHITECTURE.md](ARCHITECTURE.md) and the [architecture decision records](docs/adr/) for binding design decisions. @@ -99,7 +99,7 @@ isolated Chromium session → redacted provenance bundle ``` -Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the active routing foundation, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). +Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the protected-main `tools/call` foundation and active `tools/list` refinement, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). ## Hourly product-development loop @@ -111,4 +111,4 @@ Read [AGENTS.md](AGENTS.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SECURITY.m ## License -Apache License 2.0. See [LICENSE](LICENSE). +Apache License 2.0. See [LICENSE](LICENSE). \ No newline at end of file diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml new file mode 100644 index 000000000..39e8e38f7 --- /dev/null +++ b/crates/originweave-bap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "originweave-bap" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[lints] +workspace = true diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs new file mode 100644 index 000000000..404a88c10 --- /dev/null +++ b/crates/originweave-bap/src/lib.rs @@ -0,0 +1,329 @@ +//! Stable internal Browser Agent Protocol lifecycle contracts. +//! +//! This crate intentionally owns no transport, browser, network, model, secret, +//! approval, or persistence authority. External protocol adapters may project +//! these states, but protocol metadata cannot mint or change OriginWeave task +//! authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +/// Durable logical state of one governed BAP task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskState { + /// The task record exists but has not entered admission control. + Created, + /// Admission control accepted the task but execution has not started. + Admitted, + /// The task is actively executing governed work. + Running, + /// Execution is suspended until an approval decision is available. + WaitingForApproval, + /// Execution is suspended until required external input is available. + WaitingForExternalInput, + /// Execution is suspended at a compatible recoverable checkpoint. + Checkpointed, + /// Execution is suspended until an explicit reconciliation decision is recorded. + /// + /// The lifecycle state does not itself persist or authenticate reconciliation + /// evidence. A durable owner must preserve the complete evidence that caused + /// the task to enter this state before resolution is considered. + ReconciliationRequired, + /// The declared post-condition completed successfully. + Succeeded, + /// The task reached a terminal execution failure. + Failed, + /// Cancellation completed and the task cannot resume. + Cancelled, + /// The task exceeded its allowed lifetime and cannot resume. + Expired, + /// The task was terminally removed from automatic execution after governed handling. + /// + /// Durable dead-letter evidence remains the responsibility of the persistence + /// boundary; this in-memory marker must not be treated as the evidence itself. + DeadLettered, +} + +impl BapTaskState { + /// Return whether this state is final and must never transition again. + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired | Self::DeadLettered + ) + } +} + +/// One requested task-lifecycle event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskEvent { + /// Admit a newly created task. + Admit, + /// Start an admitted task. + Start, + /// Suspend a running task until approval is available. + WaitForApproval, + /// Suspend a running task until external input is available. + WaitForExternalInput, + /// Suspend a running task at a recoverable checkpoint. + Checkpoint, + /// Resume a normal suspended task into governed execution. + Resume, + /// Suspend a running task because its external outcome requires reconciliation. + RequireReconciliation, + /// Explicitly resolve a reconciliation hold and return the task to governed execution. + ResolveReconciliation, + /// Terminally remove a running or reconciliation-held task from automatic execution. + DeadLetter, + /// Record successful completion after the declared post-condition is verified. + Succeed, + /// Record terminal task failure. + Fail, + /// Record terminal cancellation. + Cancel, + /// Record terminal expiry. + Expire, +} + +/// A fail-closed lifecycle transition failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskTransitionError { + /// The requested event is not valid from the current non-terminal state. + InvalidTransition { + /// Current state that rejected the event. + from: BapTaskState, + /// Event that was rejected. + event: BapTaskEvent, + }, + /// The lifecycle sequence reached its maximum representable value. + SequenceExhausted, + /// A terminal task cannot be reopened or mutated by lifecycle events. + TerminalState { + /// Final state that rejected all further events. + state: BapTaskState, + }, +} + +impl std::fmt::Display for BapTaskTransitionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidTransition { from, event } => { + write!( + formatter, + "BAP task event {event:?} is invalid from state {from:?}" + ) + } + Self::SequenceExhausted => { + write!(formatter, "BAP task transition sequence is exhausted") + } + Self::TerminalState { state } => { + write!(formatter, "BAP task state {state:?} is terminal") + } + } + } +} + +impl std::error::Error for BapTaskTransitionError {} + +/// A fail-closed lifecycle recovery failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskRestoreError { + /// The supplied state and transition sequence cannot arise from this state machine. + InvalidSnapshot { + /// Logical state supplied by the durable recovery boundary. + state: BapTaskState, + /// Last accepted transition sequence supplied by the durable recovery boundary. + transition_sequence: u64, + }, +} + +impl std::fmt::Display for BapTaskRestoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSnapshot { + state, + transition_sequence, + } => write!( + formatter, + "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" + ), + } + } +} + +impl std::error::Error for BapTaskRestoreError {} + +/// Immutable receipt for one accepted in-memory lifecycle transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskTransition { + previous_state: BapTaskState, + current_state: BapTaskState, + sequence: u64, +} + +impl BapTaskTransition { + /// Return the state before the accepted transition. + #[must_use] + pub const fn previous_state(self) -> BapTaskState { + self.previous_state + } + + /// Return the state after the accepted transition. + #[must_use] + pub const fn current_state(self) -> BapTaskState { + self.current_state + } + + /// Return the monotonic transition sequence for this lifecycle instance. + #[must_use] + pub const fn sequence(self) -> u64 { + self.sequence + } +} + +/// Deterministic fail-closed BAP task-lifecycle kernel. +/// +/// This value is intentionally an in-memory state-transition primitive. A +/// durable repository must persist accepted transitions and impose its own +/// bounded sequence/retention contract before commercial task recovery can be +/// claimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskLifecycle { + state: BapTaskState, + transition_sequence: u64, +} + +impl Default for BapTaskLifecycle { + fn default() -> Self { + Self::new() + } +} + +impl BapTaskLifecycle { + /// Create one lifecycle in the `created` state with no accepted transitions. + #[must_use] + pub const fn new() -> Self { + Self { + state: BapTaskState::Created, + transition_sequence: 0, + } + } + + /// Restore a lifecycle state and its last accepted transition sequence. + /// + /// Recovery accepts only state/sequence pairs that are reachable through + /// this exact state machine. This prevents corrupt or stale durable metadata + /// from manufacturing an impossible execution state. + pub const fn restore( + state: BapTaskState, + transition_sequence: u64, + ) -> Result { + if !reachable_snapshot(state, transition_sequence) { + return Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence, + }); + } + Ok(Self { + state, + transition_sequence, + }) + } + + /// Return the current logical task state. + #[must_use] + pub const fn state(self) -> BapTaskState { + self.state + } + + /// Return the number of accepted lifecycle transitions. + #[must_use] + pub const fn transition_sequence(self) -> u64 { + self.transition_sequence + } + + /// Apply one reviewed lifecycle event without granting execution authority. + /// + /// Rejected events leave both state and sequence unchanged. Terminal states + /// reject every later event before evaluating any normal transition rule. + /// Reconciliation cannot use the generic `Resume` event: it requires the + /// explicit `ResolveReconciliation` event so ambiguous external outcomes + /// cannot silently re-enter execution. + pub fn apply( + &mut self, + event: BapTaskEvent, + ) -> Result { + if self.state.is_terminal() { + return Err(BapTaskTransitionError::TerminalState { state: self.state }); + } + + let next_state = match (self.state, event) { + (BapTaskState::Created, BapTaskEvent::Admit) => BapTaskState::Admitted, + (BapTaskState::Admitted, BapTaskEvent::Start) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::WaitForApproval) => { + BapTaskState::WaitingForApproval + } + (BapTaskState::Running, BapTaskEvent::WaitForExternalInput) => { + BapTaskState::WaitingForExternalInput + } + (BapTaskState::Running, BapTaskEvent::Checkpoint) => BapTaskState::Checkpointed, + ( + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed, + BapTaskEvent::Resume, + ) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { + BapTaskState::ReconciliationRequired + } + (BapTaskState::ReconciliationRequired, BapTaskEvent::ResolveReconciliation) => { + BapTaskState::Running + } + ( + BapTaskState::Running | BapTaskState::ReconciliationRequired, + BapTaskEvent::DeadLetter, + ) => BapTaskState::DeadLettered, + (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, + (_, BapTaskEvent::Fail) => BapTaskState::Failed, + (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, + (_, BapTaskEvent::Expire) => BapTaskState::Expired, + (from, event) => { + return Err(BapTaskTransitionError::InvalidTransition { from, event }); + } + }; + + let Some(sequence) = self.transition_sequence.checked_add(1) else { + return Err(BapTaskTransitionError::SequenceExhausted); + }; + let previous_state = self.state; + self.state = next_state; + self.transition_sequence = sequence; + Ok(BapTaskTransition { + previous_state, + current_state: next_state, + sequence, + }) + } +} + +const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { + match state { + BapTaskState::Created => transition_sequence == 0, + BapTaskState::Admitted => transition_sequence == 1, + BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed + | BapTaskState::ReconciliationRequired => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Succeeded => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { + transition_sequence >= 1 + } + BapTaskState::DeadLettered => transition_sequence >= 3, + } +} diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs new file mode 100644 index 000000000..01013682a --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -0,0 +1,253 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; + +#[test] +fn default_starts_a_new_created_lifecycle() { + assert_eq!(BapTaskLifecycle::default(), BapTaskLifecycle::new()); +} + +#[test] +fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { + let mut task = BapTaskLifecycle::new(); + assert_eq!(task.state(), BapTaskState::Created); + assert!(!task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 0); + + let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); + assert_eq!(admitted.previous_state(), BapTaskState::Created); + assert_eq!(admitted.current_state(), BapTaskState::Admitted); + assert_eq!(admitted.sequence(), 1); + + task.apply(BapTaskEvent::Start).expect("start"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + assert_eq!(task.state(), BapTaskState::WaitingForApproval); + + task.apply(BapTaskEvent::Resume).expect("resume approval"); + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + assert_eq!(task.state(), BapTaskState::Checkpointed); + + task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); + let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); + assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); + assert!(task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 7); +} + +#[test] +fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { + let mut task = running_task(); + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait for input"); + + let error = task + .apply(BapTaskEvent::Succeed) + .expect_err("waiting task must not skip resume and post-condition work"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::WaitingForExternalInput, + event: BapTaskEvent::Succeed, + } + ); + assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::Resume).expect("resume input"); + assert_eq!(task.state(), BapTaskState::Running); +} + +#[test] +fn invalid_transition_is_fail_closed_and_does_not_advance_history() { + let mut task = BapTaskLifecycle::new(); + + let error = task + .apply(BapTaskEvent::Start) + .expect_err("created task must be admitted first"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + } + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn terminal_task_never_reopens_or_advances_history() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + ] { + let mut task = if terminal_event == BapTaskEvent::Succeed { + running_task() + } else { + BapTaskLifecycle::new() + }; + task.apply(terminal_event).expect("enter terminal state"); + let terminal_state = task.state(); + let terminal_sequence = task.transition_sequence(); + + for later_event in [ + BapTaskEvent::Admit, + BapTaskEvent::Start, + BapTaskEvent::Resume, + BapTaskEvent::Cancel, + ] { + assert_eq!( + task.apply(later_event), + Err(BapTaskTransitionError::TerminalState { + state: terminal_state, + }) + ); + assert_eq!(task.state(), terminal_state); + assert_eq!(task.transition_sequence(), terminal_sequence); + } + } +} + +#[test] +fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { + for state in [ + BapTaskState::Created, + BapTaskState::Admitted, + BapTaskState::Running, + BapTaskState::WaitingForApproval, + BapTaskState::WaitingForExternalInput, + BapTaskState::Checkpointed, + BapTaskState::ReconciliationRequired, + ] { + for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { + let mut task = task_in_state(state); + assert_eq!(task.state(), state); + task.apply(terminal_event).expect("terminal interruption"); + assert!(task.state().is_terminal()); + } + } +} + +#[test] +fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { + let mut task = running_task(); + let required = task + .apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + assert_eq!(required.previous_state(), BapTaskState::Running); + assert_eq!( + required.current_state(), + BapTaskState::ReconciliationRequired + ); + assert!(!task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Resume, + }) + ); + assert_eq!( + task.apply(BapTaskEvent::Succeed), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Succeed, + }) + ); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::ResolveReconciliation) + .expect("resolve reconciliation"); + assert_eq!(task.state(), BapTaskState::Running); + + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation again"); + let dead_lettered = task + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter unresolved task"); + assert_eq!(dead_lettered.current_state(), BapTaskState::DeadLettered); + assert!(task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::DeadLettered, + }) + ); +} + +#[test] +fn running_task_may_dead_letter_but_pre_dispatch_task_may_not() { + let mut running = running_task(); + let transition = running + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter running task"); + assert_eq!(transition.previous_state(), BapTaskState::Running); + assert_eq!(transition.current_state(), BapTaskState::DeadLettered); + assert_eq!(transition.sequence(), 3); + assert!(running.state().is_terminal()); + + let mut created = BapTaskLifecycle::new(); + assert_eq!( + created.apply(BapTaskEvent::DeadLetter), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::DeadLetter, + }) + ); + assert_eq!(created.state(), BapTaskState::Created); + assert_eq!(created.transition_sequence(), 0); +} + +fn running_task() -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit"); + task.apply(BapTaskEvent::Start).expect("start"); + task +} + +fn task_in_state(target: BapTaskState) -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + if target == BapTaskState::Created { + return task; + } + + task.apply(BapTaskEvent::Admit).expect("admit"); + if target == BapTaskState::Admitted { + return task; + } + + task.apply(BapTaskEvent::Start).expect("start"); + match target { + BapTaskState::Running => {} + BapTaskState::WaitingForApproval => { + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait approval"); + } + BapTaskState::WaitingForExternalInput => { + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait external"); + } + BapTaskState::Checkpointed => { + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + } + BapTaskState::ReconciliationRequired => { + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + } + BapTaskState::Created + | BapTaskState::Admitted + | BapTaskState::Succeeded + | BapTaskState::Failed + | BapTaskState::Cancelled + | BapTaskState::Expired + | BapTaskState::DeadLettered => { + unreachable!("task_in_state only constructs non-terminal lifecycle states") + } + } + task +} diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs new file mode 100644 index 000000000..67deae949 --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -0,0 +1,142 @@ +#![allow(clippy::expect_used)] + +use std::error::Error as _; + +use originweave_bap::{ + BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, +}; + +#[test] +fn restored_lifecycle_preserves_state_and_monotonic_sequence() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41) + .expect("valid checkpoint snapshot"); + + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), 41); + + let resumed = task + .apply(BapTaskEvent::Resume) + .expect("resume restored task"); + assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); + assert_eq!(resumed.current_state(), BapTaskState::Running); + assert_eq!(resumed.sequence(), 42); +} + +#[test] +fn impossible_restored_snapshots_fail_closed() { + for (state, sequence) in [ + (BapTaskState::Created, 1), + (BapTaskState::Admitted, 0), + (BapTaskState::Admitted, 2), + (BapTaskState::Running, 1), + (BapTaskState::Running, 3), + (BapTaskState::WaitingForApproval, 2), + (BapTaskState::WaitingForApproval, 4), + (BapTaskState::WaitingForExternalInput, 2), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 2), + (BapTaskState::Checkpointed, 4), + (BapTaskState::ReconciliationRequired, 2), + (BapTaskState::ReconciliationRequired, 4), + (BapTaskState::Succeeded, 2), + (BapTaskState::Succeeded, 4), + (BapTaskState::Failed, 0), + (BapTaskState::Cancelled, 0), + (BapTaskState::Expired, 0), + (BapTaskState::DeadLettered, 2), + ] { + assert_eq!( + BapTaskLifecycle::restore(state, sequence), + Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence: sequence, + }), + "state={state:?}, sequence={sequence}", + ); + } +} + +#[test] +fn valid_restored_snapshot_classes_remain_accepted() { + for (state, sequence) in [ + (BapTaskState::Created, 0), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::Running, 4), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 5), + (BapTaskState::Checkpointed, 7), + (BapTaskState::ReconciliationRequired, 3), + (BapTaskState::Succeeded, 3), + (BapTaskState::Failed, 1), + (BapTaskState::Cancelled, 2), + (BapTaskState::Expired, 4), + (BapTaskState::DeadLettered, 3), + (BapTaskState::DeadLettered, 4), + ] { + let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); + assert_eq!(task.state(), state); + assert_eq!(task.transition_sequence(), sequence); + } +} + +#[test] +fn exhausted_sequence_fails_closed_without_mutating_state() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX) + .expect("valid exhausted checkpoint snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::SequenceExhausted), + ); + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), u64::MAX); +} + +#[test] +fn restored_terminal_lifecycle_remains_terminal() { + let mut task = + BapTaskLifecycle::restore(BapTaskState::Succeeded, 9).expect("valid terminal snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::Succeeded, + }), + ); + assert_eq!(task.transition_sequence(), 9); +} + +#[test] +fn lifecycle_failures_use_the_standard_rust_error_contract() { + let mut created = BapTaskLifecycle::new(); + let invalid_transition = created + .apply(BapTaskEvent::Start) + .expect_err("created task must reject start"); + assert_eq!( + invalid_transition.to_string(), + "BAP task event Start is invalid from state Created" + ); + assert!(invalid_transition.source().is_none()); + + let exhausted = BapTaskTransitionError::SequenceExhausted; + assert_eq!( + exhausted.to_string(), + "BAP task transition sequence is exhausted" + ); + assert!(exhausted.source().is_none()); + + let terminal = BapTaskTransitionError::TerminalState { + state: BapTaskState::Cancelled, + }; + assert_eq!(terminal.to_string(), "BAP task state Cancelled is terminal"); + assert!(terminal.source().is_none()); + + let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) + .expect_err("unreachable snapshot must fail"); + assert_eq!( + restore.to_string(), + "BAP task snapshot state Created with transition sequence 1 is unreachable" + ); + assert!(restore.source().is_none()); +} diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index 35c83b19b..dcda2a6c4 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -10,7 +10,11 @@ repository.workspace = true homepage.workspace = true publish = false +[lib] +path = "src/root.rs" + [dependencies] +unicode-normalization = "=0.1.25" [lints] workspace = true diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs deleted file mode 100644 index 3af93cb07..000000000 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ /dev/null @@ -1,163 +0,0 @@ -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 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, -} - -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) - } - - /// 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, - 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 batch of adapter-local node identifiers to current browser authority. - /// - /// 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_identifiers: &[&str], - ) -> Result, BrowserRegistryError> { - self.inner.bind_nodes( - browser_session, - browsing_context, - origin, - external_identifiers, - ) - } -} - -impl Default for BrowserAuthorityRegistry { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 3e30eccfe..3421b27b3 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -1,94 +1,8 @@ -use std::{fmt, str::FromStr}; +use std::fmt; /// Maximum UTF-8 byte length for browser protocol adapter metadata tokens. pub const MAX_BROWSER_PROTOCOL_METADATA_BYTES: usize = 128; -/// One OriginWeave Protocol generation. -/// -/// This value identifies the OriginWeave contract spoken by an adapter. It is -/// deliberately independent from the upstream WebDriver BiDi/CDP revision and -/// from the browser build. Constructing a version does not make that version -/// supported; callers must compare it with the exact version required by the -/// surrounding OriginWeave protocol boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct OriginWeaveProtocolVersion { - major: u16, - minor: u16, -} - -impl OriginWeaveProtocolVersion { - /// Construct an OriginWeave Protocol generation identifier. - #[must_use] - pub const fn new(major: u16, minor: u16) -> Self { - Self { major, minor } - } - - /// Return the protocol major version. - #[must_use] - pub const fn major(self) -> u16 { - self.major - } - - /// Return the protocol minor version. - #[must_use] - pub const fn minor(self) -> u16 { - self.minor - } -} - -impl fmt::Display for OriginWeaveProtocolVersion { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "originweave/{}.{}", self.major, self.minor) - } -} - -impl FromStr for OriginWeaveProtocolVersion { - type Err = OriginWeaveProtocolVersionParseError; - - fn from_str(value: &str) -> Result { - let Some(remainder) = value.strip_prefix("originweave/") else { - return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); - }; - let Some((major_text, minor_text)) = remainder.split_once('.') else { - return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); - }; - if minor_text.contains('.') { - return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); - } - let Ok(major) = major_text.parse::() else { - return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); - }; - let Ok(minor) = minor_text.parse::() else { - return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); - }; - - let version = Self::new(major, minor); - if version.to_string() != value { - return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); - } - Ok(version) - } -} - -/// Failure to parse a canonical serialized OriginWeave Protocol generation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OriginWeaveProtocolVersionParseError { - /// The value did not use the exact canonical `originweave/.` syntax. - InvalidFormat, -} - -impl fmt::Display for OriginWeaveProtocolVersionParseError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidFormat => formatter.write_str( - "OriginWeave protocol version must use canonical originweave/. syntax", - ), - } - } -} - -impl std::error::Error for OriginWeaveProtocolVersionParseError {} - /// Browser automation protocol family used by one versioned adapter. /// /// The protocol family is descriptive metadata only. Selecting a kind does not @@ -119,13 +33,12 @@ pub enum BrowserProtocolCapability { /// /// This value is deliberately not browser authority. It contains no browser /// session, context, origin, node handle, action grant, credential, or network -/// permission. Higher layers may use it to fail closed when the adapter targets -/// the wrong OriginWeave Protocol generation or lacks a required browser -/// capability, while all OriginWeave authority remains separately validated. +/// permission. Higher layers may use it to fail closed when a required adapter +/// capability is absent, while all OriginWeave authority remains separately +/// validated. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BrowserProtocolAdapterDescriptor { kind: BrowserProtocolKind, - originweave_protocol_version: OriginWeaveProtocolVersion, adapter_version: String, protocol_revision: String, browser_revision: String, @@ -135,16 +48,14 @@ pub struct BrowserProtocolAdapterDescriptor { impl BrowserProtocolAdapterDescriptor { /// Construct one explicit adapter descriptor. /// - /// The OriginWeave Protocol generation, adapter version, upstream protocol - /// revision, and browser revision are distinct metadata. This prevents an - /// OriginWeave contract version from being mistaken for the WebDriver - /// BiDi/CDP revision or the pinned browser build it was validated against. - /// The declared capability list must be non-empty and duplicate-free and is - /// normalized into one stable order so caller ordering cannot change - /// descriptor identity. + /// Adapter version, upstream protocol revision, and browser revision are + /// separate bounded ASCII metadata tokens. This prevents an OriginWeave + /// adapter release from being mistaken for the WebDriver BiDi/CDP revision + /// or the pinned browser build it was validated against. The declared + /// capability list must be non-empty and duplicate-free and is normalized + /// into one stable order so caller ordering cannot change descriptor identity. pub fn new( kind: BrowserProtocolKind, - originweave_protocol_version: OriginWeaveProtocolVersion, adapter_version: &str, protocol_revision: &str, browser_revision: &str, @@ -174,7 +85,6 @@ impl BrowserProtocolAdapterDescriptor { Ok(Self { kind, - originweave_protocol_version, adapter_version: adapter_version.to_owned(), protocol_revision: protocol_revision.to_owned(), browser_revision: browser_revision.to_owned(), @@ -188,12 +98,6 @@ impl BrowserProtocolAdapterDescriptor { self.kind } - /// Return the exact OriginWeave Protocol generation implemented by this adapter. - #[must_use] - pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { - self.originweave_protocol_version - } - /// Return the bounded OriginWeave adapter-version metadata token. #[must_use] pub fn adapter_version(&self) -> &str { @@ -223,168 +127,6 @@ impl BrowserProtocolAdapterDescriptor { pub fn supports(&self, capability: BrowserProtocolCapability) -> bool { self.capabilities.contains(&capability) } - - /// Require one exact OriginWeave Protocol generation before later adapter use. - /// - /// Pre-alpha compatibility is deliberately exact at this boundary. A caller - /// may add a separately reviewed compatibility transform later, but this - /// descriptor never silently treats a different major or minor generation - /// as equivalent. - pub fn require_originweave_protocol_version( - &self, - required: OriginWeaveProtocolVersion, - ) -> Result<(), BrowserProtocolVersionRequirementError> { - if self.originweave_protocol_version == required { - Ok(()) - } else { - Err( - BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { - required, - actual: self.originweave_protocol_version, - }, - ) - } - } - - /// Require exact runtime browser-protocol and browser revisions before use. - /// - /// The caller must derive both values from the trusted runtime adapter that - /// is about to perform browser work. This deterministic comparison does not - /// authenticate or attest that caller. It only prevents a descriptor pinned - /// to one validated upstream-protocol/browser pair from being silently used - /// when the supplied runtime evidence is malformed or has drifted. - pub fn require_runtime_revisions( - &self, - protocol_revision: &str, - browser_revision: &str, - ) -> Result<(), BrowserProtocolRuntimeRequirementError> { - if !metadata_token_is_valid(protocol_revision) { - return Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision); - } - if !metadata_token_is_valid(browser_revision) { - return Err(BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision); - } - if self.protocol_revision != protocol_revision { - return Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch); - } - if self.browser_revision != browser_revision { - return Err(BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch); - } - Ok(()) - } - - /// Require one explicitly declared adapter capability before later use. - /// - /// This method never infers support from the browser protocol family. An - /// absent capability fails closed with a typed error so a caller cannot - /// silently fall back to another upstream protocol or a raw browser escape - /// hatch merely because the selected adapter lacks the requested surface. - pub fn require_capability( - &self, - capability: BrowserProtocolCapability, - ) -> Result<(), BrowserProtocolCapabilityRequirementError> { - if self.supports(capability) { - Ok(()) - } else { - Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability(capability)) - } - } - - /// Validate all adapter metadata prerequisites for one immediate browser operation. - /// - /// Validation is intentionally ordered and fail closed: the exact - /// OriginWeave Protocol generation is checked first, then the caller-supplied - /// runtime protocol family, runtime adapter version, protocol/browser - /// revisions, and finally the required adapter capability. Success returns - /// a non-cloneable value that a later trusted transport can consume as proof - /// that these metadata prerequisites were checked together. It is not - /// browser or Agent authority and does not authenticate or attest the caller - /// supplying runtime metadata. - pub fn validate_use( - &self, - required_originweave_protocol_version: OriginWeaveProtocolVersion, - runtime_kind: BrowserProtocolKind, - runtime_adapter_version: &str, - runtime_protocol_revision: &str, - runtime_browser_revision: &str, - required_capability: BrowserProtocolCapability, - ) -> Result { - self.require_originweave_protocol_version(required_originweave_protocol_version) - .map_err(BrowserProtocolUseValidationError::ProtocolVersion)?; - if self.kind != runtime_kind { - return Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { - descriptor_kind: self.kind, - runtime_kind, - }); - } - if !metadata_token_is_valid(runtime_adapter_version) { - return Err(BrowserProtocolUseValidationError::InvalidAdapterVersion); - } - if self.adapter_version != runtime_adapter_version { - return Err(BrowserProtocolUseValidationError::AdapterVersionMismatch); - } - self.require_runtime_revisions(runtime_protocol_revision, runtime_browser_revision) - .map_err(BrowserProtocolUseValidationError::RuntimeRevision)?; - self.require_capability(required_capability) - .map_err(BrowserProtocolUseValidationError::Capability)?; - - Ok(ValidatedBrowserProtocolUse { - descriptor: self.clone(), - capability: required_capability, - }) - } -} - -/// Snapshot proving that one descriptor passed all browser-protocol metadata checks for one use. -/// -/// Only [`BrowserProtocolAdapterDescriptor::validate_use`] can construct this -/// value. It intentionally does not implement `Clone`: a future trusted browser -/// transport can consume the value by ownership at the operation boundary -/// rather than treating it as reusable ambient authority. The value still does -/// not authenticate an adapter or attest that supplied runtime metadata came -/// from the running browser process. -#[derive(Debug, PartialEq, Eq)] -pub struct ValidatedBrowserProtocolUse { - descriptor: BrowserProtocolAdapterDescriptor, - capability: BrowserProtocolCapability, -} - -impl ValidatedBrowserProtocolUse { - /// Return the validated browser protocol family. - #[must_use] - pub const fn kind(&self) -> BrowserProtocolKind { - self.descriptor.kind - } - - /// Return the validated OriginWeave Protocol generation. - #[must_use] - pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { - self.descriptor.originweave_protocol_version - } - - /// Return the validated bounded adapter-version metadata token. - #[must_use] - pub fn adapter_version(&self) -> &str { - &self.descriptor.adapter_version - } - - /// Return the validated bounded upstream protocol-revision metadata token. - #[must_use] - pub fn protocol_revision(&self) -> &str { - &self.descriptor.protocol_revision - } - - /// Return the validated bounded browser-revision metadata token. - #[must_use] - pub fn browser_revision(&self) -> &str { - &self.descriptor.browser_revision - } - - /// Return the exact adapter capability validated for this use. - #[must_use] - pub const fn capability(&self) -> BrowserProtocolCapability { - self.capability - } } const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { @@ -396,15 +138,6 @@ const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { } } -fn capability_name(capability: BrowserProtocolCapability) -> &'static str { - match capability { - BrowserProtocolCapability::Navigation => "navigation", - BrowserProtocolCapability::SemanticObservation => "semantic-observation", - BrowserProtocolCapability::TypedInput => "typed-input", - BrowserProtocolCapability::NetworkObservation => "network-observation", - } -} - fn metadata_token_is_valid(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_BROWSER_PROTOCOL_METADATA_BYTES @@ -415,138 +148,6 @@ fn metadata_token_is_valid(value: &str) -> bool { && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) } -/// Failure to require one exact OriginWeave Protocol generation from an adapter. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserProtocolVersionRequirementError { - /// The adapter targets a different OriginWeave Protocol generation. - ProtocolVersionMismatch { - /// Exact OriginWeave Protocol generation required by the caller. - required: OriginWeaveProtocolVersion, - /// Exact OriginWeave Protocol generation declared by the adapter. - actual: OriginWeaveProtocolVersion, - }, -} - -impl fmt::Display for BrowserProtocolVersionRequirementError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ProtocolVersionMismatch { required, actual } => write!( - formatter, - "browser protocol adapter targets {actual} but {required} is required" - ), - } - } -} - -impl std::error::Error for BrowserProtocolVersionRequirementError {} - -/// Failure to require exact pinned runtime revision evidence from an adapter. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserProtocolRuntimeRequirementError { - /// The runtime upstream-protocol revision token was malformed. - InvalidProtocolRevision, - /// The runtime browser revision token was malformed. - InvalidBrowserRevision, - /// The runtime upstream-protocol revision differs from the pinned descriptor. - ProtocolRevisionMismatch, - /// The runtime browser revision differs from the pinned descriptor. - BrowserRevisionMismatch, -} - -impl fmt::Display for BrowserProtocolRuntimeRequirementError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidProtocolRevision => formatter.write_str( - "runtime browser protocol revision must be a bounded ASCII metadata token", - ), - Self::InvalidBrowserRevision => formatter - .write_str("runtime browser revision must be a bounded ASCII metadata token"), - Self::ProtocolRevisionMismatch => formatter.write_str( - "runtime browser protocol revision does not match the pinned adapter revision", - ), - Self::BrowserRevisionMismatch => formatter.write_str( - "runtime browser revision does not match the pinned adapter browser revision", - ), - } - } -} - -impl std::error::Error for BrowserProtocolRuntimeRequirementError {} - -/// Failure to require one browser protocol capability from an adapter. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserProtocolCapabilityRequirementError { - /// The adapter did not explicitly declare the required capability. - UnsupportedCapability(BrowserProtocolCapability), -} - -impl fmt::Display for BrowserProtocolCapabilityRequirementError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnsupportedCapability(capability) => write!( - formatter, - "browser protocol adapter does not declare required {} capability", - capability_name(*capability) - ), - } - } -} - -impl std::error::Error for BrowserProtocolCapabilityRequirementError {} - -/// Failure to validate all browser-protocol metadata prerequisites for one use. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserProtocolUseValidationError { - /// The descriptor targets the wrong OriginWeave Protocol generation. - ProtocolVersion(BrowserProtocolVersionRequirementError), - /// The runtime transport reports a different protocol family than the descriptor. - ProtocolKindMismatch { - /// Browser protocol family pinned by the adapter descriptor. - descriptor_kind: BrowserProtocolKind, - /// Browser protocol family reported by the runtime transport. - runtime_kind: BrowserProtocolKind, - }, - /// The runtime adapter-version token was malformed. - InvalidAdapterVersion, - /// The runtime adapter version differs from the pinned descriptor version. - AdapterVersionMismatch, - /// The supplied runtime protocol or browser revision is invalid or has drifted. - RuntimeRevision(BrowserProtocolRuntimeRequirementError), - /// The descriptor does not explicitly declare the required capability. - Capability(BrowserProtocolCapabilityRequirementError), -} - -impl fmt::Display for BrowserProtocolUseValidationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ProtocolVersion(error) => error.fmt(formatter), - Self::ProtocolKindMismatch { .. } => formatter - .write_str("runtime browser protocol kind does not match the pinned adapter kind"), - Self::InvalidAdapterVersion => formatter.write_str( - "runtime browser adapter version must be a bounded ASCII metadata token", - ), - Self::AdapterVersionMismatch => formatter.write_str( - "runtime browser adapter version does not match the pinned adapter version", - ), - Self::RuntimeRevision(error) => error.fmt(formatter), - Self::Capability(error) => error.fmt(formatter), - } - } -} - -impl std::error::Error for BrowserProtocolUseValidationError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::ProtocolVersion(error) => Some(error), - Self::ProtocolKindMismatch { .. } - | Self::InvalidAdapterVersion - | Self::AdapterVersionMismatch => None, - Self::RuntimeRevision(error) => Some(error), - Self::Capability(error) => Some(error), - } - } -} - /// Failure to construct canonical browser protocol adapter metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolDescriptorError { diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs deleted file mode 100644 index 14bafb3d6..000000000 --- a/crates/originweave-core/src/browser_protocol_dispatch.rs +++ /dev/null @@ -1,365 +0,0 @@ -use std::fmt; - -use crate::{ - BrowserAuthorityRegistry, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, - BrowserProtocolKind, BrowserProtocolUseValidationError, BrowserRegistryError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, - ValidatedBrowserProtocolUse, -}; - -/// Current runtime metadata sampled from the browser-protocol adapter about to perform I/O. -/// -/// This value is untrusted descriptive input. Constructing it does not validate or authenticate an -/// adapter, browser, or protocol revision and grants no browser or Agent authority. The descriptor -/// validates every field against its reviewed metadata before a dispatch callback can run. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct BrowserProtocolRuntimeMetadata<'a> { - kind: BrowserProtocolKind, - adapter_version: &'a str, - protocol_revision: &'a str, - browser_revision: &'a str, -} - -impl<'a> BrowserProtocolRuntimeMetadata<'a> { - /// Build one runtime metadata snapshot for immediate validation and dispatch. - /// - /// String syntax and descriptor equality are intentionally checked later by - /// [`BrowserProtocolAdapterDescriptor::dispatch_if_runtime_matches`], so malformed caller data - /// remains representable as input that the fail-closed boundary can reject deterministically. - pub const fn new( - kind: BrowserProtocolKind, - adapter_version: &'a str, - protocol_revision: &'a str, - browser_revision: &'a str, - ) -> Self { - Self { - kind, - adapter_version, - protocol_revision, - browser_revision, - } - } -} - -/// Exact OriginWeave browser session/context requested for one immediate protocol dispatch. -/// -/// This value only keeps the two identifiers together so a caller cannot accidentally reorder or -/// independently substitute them at the dispatch boundary. Constructing or copying it does not -/// prove that either identifier is registered, current, or authorized; the authority registry must -/// validate the pair immediately before protocol metadata validation and callback invocation. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct BrowserContextDispatchTarget { - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, -} - -impl BrowserContextDispatchTarget { - /// Group one OriginWeave browser session and browsing context for immediate dispatch checking. - #[must_use] - pub const fn new( - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - ) -> Self { - Self { - browser_session, - browsing_context, - } - } - - /// Return the OriginWeave browser session requested for this dispatch. - #[must_use] - pub const fn browser_session(self) -> BrowserSessionId { - self.browser_session - } - - /// Return the OriginWeave browsing context requested for this dispatch. - #[must_use] - pub const fn browsing_context(self) -> BrowsingContextId { - self.browsing_context - } -} - -/// 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 - } -} - -/// 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. - /// - /// `runtime_metadata` must be sampled from the trusted adapter that is about to perform the - /// operation. Validation occurs before `dispatch` is invoked, and the callback receives the - /// resulting non-cloneable [`ValidatedBrowserProtocolUse`] by ownership so this boundary does - /// not turn successful validation into reusable ambient authority. - /// - /// A successful callback invocation does not authenticate the adapter process, authorize a - /// browser session, browsing context, origin, destination, secret, or approval, or prove a - /// browser post-condition. Those remain separate higher-level execution boundaries. - pub fn dispatch_if_runtime_matches( - &self, - required_originweave_protocol_version: OriginWeaveProtocolVersion, - runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, - required_capability: BrowserProtocolCapability, - dispatch: F, - ) -> Result - where - F: FnOnce(ValidatedBrowserProtocolUse) -> R, - { - let validated = self.validate_use( - required_originweave_protocol_version, - runtime_metadata.kind, - runtime_metadata.adapter_version, - runtime_metadata.protocol_revision, - runtime_metadata.browser_revision, - required_capability, - )?; - Ok(dispatch(validated)) - } - - /// Revalidate exact browser session/context ownership and runtime metadata before dispatch. - /// - /// The registry check occurs first and returns its current document epoch. The exact protocol - /// generation, runtime protocol family, adapter version, upstream/browser revisions, and - /// required capability are then validated before `dispatch` can run. The callback receives the - /// non-cloneable protocol-use proof plus the registry epoch sampled for this immediate use. - /// - /// This is a composition prerequisite, not complete browser-action authority. In particular, - /// typed input still requires separate current origin/document/node and deterministic policy - /// authorization, while navigation still requires destination/network/TLS/HTTP authority. - /// The caller remains responsible for sampling runtime metadata from the adapter about to - /// perform I/O and for preventing registry mutation across its larger execution transaction. - pub fn dispatch_if_context_current( - &self, - authority_registry: &BrowserAuthorityRegistry, - target: BrowserContextDispatchTarget, - 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 - .current_context_epoch(target.browser_session(), target.browsing_context()) - .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) - } - - /// Revalidate exact browser session/context/origin authority and protocol metadata before I/O. - /// - /// 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 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: BrowserContextOriginDispatchTarget<'_>, - required_originweave_protocol_version: OriginWeaveProtocolVersion, - runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, - required_capability: BrowserProtocolCapability, - dispatch: F, - ) -> Result - where - F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, - { - let context = target.context(); - let current_epoch = authority_registry - .require_context_origin( - context.browser_session(), - context.browsing_context(), - target.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) - } - - /// 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. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -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), -} - -impl fmt::Display for BrowserContextProtocolDispatchError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::BrowserAuthority(error) => { - write!( - formatter, - "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, - "browser protocol validation denied context dispatch: {error}" - ) - } - } - } -} - -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), - } - } -} diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs deleted file mode 100644 index 280be51c1..000000000 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ /dev/null @@ -1,610 +0,0 @@ -use std::error::Error; -use std::fmt::{Display, Formatter}; - -use crate::{ - BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, - BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, - BrowserRegistryError, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, - 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; -/// 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)] -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 accessibility role contained whitespace, a control, or a Unicode format character. - InvalidRole, - /// The accessible name contained a control, Unicode format 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. - InvalidNodeCount, - /// The untrusted adapter returned more nodes than the reviewed request budget allowed. - ResultNodeCountExceeded, -} - -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::InvalidRole => { - "accessibility query role must not contain whitespace, control, or Unicode format characters" - } - Self::InvalidName => { - "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", - Self::ResultNodeCountExceeded => { - "accessibility query result exceeds the requested node 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. Roles are exact tokens and -/// 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, -/// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a -/// 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 -/// 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. 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>, - max_node_count: u16, - ) -> Result { - if role.is_some_and(str::is_empty) { - return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); - } - 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) { - return Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong); - } - if name.is_some_and(str::is_empty) { - return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); - } - if name.is_some_and(|value| { - crate::contains_disallowed_protocol_text(value, true) - || 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); - } - 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 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> { - 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 - } - - /// 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(()) - } - - /// Admit one untrusted `locateNodes` result against the exact current document authority. - /// - /// 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 - /// this query's budget. Each item must be an exact `node` remote value with a usable shared - /// 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 - /// 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.kind() != BrowserProtocolKind::WebDriverBiDi { - return Err( - WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), - ); - } - 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 - .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 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) - } -} - -/// 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 { - /// 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), - /// The consumed protocol-use proof came from a different browser protocol family. - UnsupportedProtocolKind(BrowserProtocolKind), - /// The consumed protocol-use proof was not SemanticObservation. - UnsupportedCapability(BrowserProtocolCapability), -} - -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}" - ) - } - 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", - BrowserProtocolCapability::SemanticObservation => "SemanticObservation", - BrowserProtocolCapability::TypedInput => "TypedInput", - BrowserProtocolCapability::NetworkObservation => "NetworkObservation", - }; - write!( - formatter, - "locateNodes admission requires a SemanticObservation protocol-use proof, not {name}" - ) - } - } - } -} - -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), - Self::UnsupportedProtocolKind(_) | Self::UnsupportedCapability(_) => None, - } - } -} - -/// 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, contained control, whitespace, or Unicode format text, or exceeded the local 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, contains control, whitespace, or Unicode format characters, 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 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 -/// 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 - || crate::contains_disallowed_protocol_text(shared_id, false) - { - 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, -/// 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, - /// 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::QueryNodes | Self::WaitForState => BrowserProtocolCapability::SemanticObservation, - Self::ClickNode | Self::TypeText => 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), - ) - } - - /// Admit one untrusted `locateNodes` result only after QueryNodes protocol proof. - /// - /// 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 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 - /// 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> { - 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(validated, authority_registry, target, items) - .map_err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes) - } -} diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index b29f23076..f6cc7c4a0 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -8,35 +8,6 @@ use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, /// 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; @@ -45,8 +16,8 @@ const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; /// [`ObservedNodeHandle::new`] creates a structurally valid but unregistered observation. Such a /// value is useful for parsing and fail-closed validation but cannot become live browser authority /// merely by reproducing session, context, origin, epoch, and node identifiers. Handles returned -/// by the protocol-admission path additionally carry an unforgeable in-process registry-instance -/// token. That token is never serialized or exposed through the public API. +/// by [`BrowserAuthorityRegistry::bind_node`] additionally carry an unforgeable in-process +/// registry-instance token. That token is never serialized or exposed through the public API. #[derive(Debug, Clone)] pub struct ObservedNodeHandle { observed: NodeTuple, @@ -57,7 +28,7 @@ impl ObservedNodeHandle { /// Create one structurally valid, unregistered observed node handle. /// /// Directly constructed handles deliberately carry no registry issuance authority and are - /// rejected by the protocol-admission path when used as live browser authority. + /// rejected by [`BrowserAuthorityRegistry::validate_node_handle`]. pub fn new( browser_session: BrowserSessionId, browsing_context: BrowsingContextId, @@ -145,7 +116,6 @@ impl ObservedNodeHandle { ) } - #[cfg(test)] fn belongs_to(&self, registry_authority: &Arc<()>) -> bool { self.registry_authority .as_ref() @@ -337,111 +307,6 @@ impl BrowserAuthorityRegistry { .ok_or(BrowserRegistryError::UnknownBrowsingContext) } - /// Return the current document epoch only when the supplied session owns the context. - /// - /// This is an immediate-use registry check for trusted browser adapters. It proves only that - /// the OriginWeave session/context pair is currently registered together and returns the - /// registry's current document epoch. It does not authenticate a browser process, authorize an - /// origin or action, or make the returned epoch a reusable browser capability. - pub fn current_context_epoch( - &self, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - ) -> Result { - if !self.known_sessions.contains(&browser_session) { - return Err(BrowserRegistryError::UnknownBrowserSession); - } - let expected_session = self - .context_session - .get(&browsing_context) - .copied() - .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - if expected_session != browser_session { - return Err(BrowserRegistryError::ContextSessionMismatch { - expected: expected_session, - actual: browser_session, - }); - } - 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 - /// semantic-node discovery begins. The supplied session must own the context. Rebinding the - /// same canonical origin in the same document epoch is idempotent, while a different origin - /// fails closed until [`Self::advance_document`] rotates the document epoch and clears the old - /// binding. The returned epoch is descriptive immediate-use state, not reusable capability. - /// - /// This method does not authenticate the adapter, derive an origin from Chromium, authorize a - /// destination or action, or prove that any browser I/O occurred. - pub fn bind_context_origin( - &mut self, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: &Origin, - ) -> Result { - let epoch = self.current_context_epoch(browser_session, browsing_context)?; - match self.context_origin.get(&browsing_context) { - Some(expected_origin) if expected_origin != origin => { - return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); - } - Some(_expected_origin) => {} - None => { - self.context_origin.insert(browsing_context, origin.clone()); - } - } - Ok(epoch) - } - - /// Revalidate the canonical origin bound to the exact current browser document. - /// - /// This read-only immediate-use boundary lets a trusted browser adapter prove that the exact - /// OriginWeave session/context still has the expected canonical origin in its current document - /// epoch. It fails closed when the current document has no origin binding, including directly - /// after [`Self::advance_document`], and rejects a different origin without mutating registry - /// state. The returned epoch is descriptive current state, not a reusable capability. - /// - /// This method does not authenticate the adapter or browser process, derive the current origin - /// from Chromium, authorize a destination or action, perform browser I/O, or attest that the - /// caller-supplied origin came from the running browser. - pub fn require_context_origin( - &self, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: &Origin, - ) -> Result { - let epoch = self.current_context_epoch(browser_session, browsing_context)?; - let expected_origin = self - .context_origin - .get(&browsing_context) - .ok_or(BrowserRegistryError::ContextOriginNotBound)?; - if expected_origin != origin { - return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); - } - Ok(epoch) - } - /// Advance a browsing context to the next document epoch and invalidate old node bindings. /// /// Call this whenever navigation or document replacement invalidates actionable node identity. @@ -545,7 +410,6 @@ impl BrowserAuthorityRegistry { /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node /// identifier is never reused. - #[cfg(test)] pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { self.validate_node_handle(handle)?; let node_id = handle.node_id(); @@ -562,7 +426,6 @@ impl BrowserAuthorityRegistry { /// handle to have been issued by this exact registry instance, and resolves the node through a /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. - #[cfg(test)] pub fn validate_node_handle( &self, handle: &ObservedNodeHandle, @@ -595,46 +458,6 @@ impl BrowserAuthorityRegistry { } Ok(()) } - - /// 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 { @@ -646,7 +469,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, whitespace, or Unicode format text, or exceeded the reviewed byte bound. + /// An external identifier was empty or exceeded the reviewed byte bound. InvalidExternalIdentifier, /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, @@ -659,10 +482,6 @@ 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. OriginChangedWithoutDocumentAdvance, /// The observed node handle is not a current node binding owned by this registry. @@ -678,8 +497,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 without control, whitespace, or Unicode format characters", + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") @@ -693,12 +513,6 @@ 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", - ), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), Self::UnknownNodeAuthority => formatter @@ -719,10 +533,7 @@ 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 - || contains_disallowed_protocol_text(identifier, false) - { + if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) @@ -1112,277 +923,6 @@ mod tests { ); } - #[test] - fn registry_reports_all_resource_and_authority_failures() { - let known_sessions = values(BrowserSessionId::new(1)); - let unknown_contexts = values(BrowsingContextId::new(1)); - let initial_epochs = values(DocumentEpoch::new(1)); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(known_sessions.len(), 1); - assert_eq!(unknown_contexts.len(), 1); - assert_eq!(initial_epochs.len(), 1); - assert_eq!(origins.len(), 1); - let known_session = known_sessions[0]; - let unknown_context = unknown_contexts[0]; - let initial_epoch = initial_epochs[0]; - let origin = &origins[0]; - - let mut limited_registry = BrowserAuthorityRegistry::with_identifier_limit(1); - let limited_sessions = values(limited_registry.register_session("session-one")); - assert_eq!(limited_sessions.len(), 1); - let limited_session = limited_sessions[0]; - assert_eq!( - limited_registry.register_session("session-two"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - let limited_contexts = - values(limited_registry.register_context(limited_session, "context-one")); - assert_eq!(limited_contexts.len(), 1); - let limited_context = limited_contexts[0]; - assert_eq!( - limited_registry.register_context(limited_session, "context-two"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - assert!( - limited_registry - .bind_node(limited_session, limited_context, origin, "node-one") - .is_ok() - ); - assert_eq!( - limited_registry.bind_node(limited_session, limited_context, origin, "node-two"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - - let mut registry = BrowserAuthorityRegistry::default(); - assert_eq!( - registry.current_epoch(unknown_context), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - assert_eq!( - registry.advance_document(unknown_context), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - assert_eq!( - registry.bind_node(known_session, unknown_context, origin, "node"), - Err(BrowserRegistryError::UnknownBrowserSession) - ); - - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - 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); - registry.context_epoch.insert(context, maximum_epochs[0]); - assert_eq!( - registry.advance_document(context), - Err(BrowserRegistryError::DocumentEpochExhausted) - ); - registry.context_epoch.insert(context, initial_epoch); - - let unknown_sessions = values(BrowserSessionId::new(999)); - 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) - ); - assert_eq!( - registry.bind_node(session, unknown_contexts[0], origin, "node"), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - } - - #[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(); - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); - let second_contexts = values(registry.register_context(session, "context-two")); - assert_eq!(contexts.len(), 1); - assert_eq!(second_contexts.len(), 1); - let context = contexts[0]; - let second_context = second_contexts[0]; - assert_eq!(registry.register_context(session, "context"), Ok(context)); - - let first_origins = values(Origin::parse("http://127.0.0.1:43127")); - let second_origins = values(Origin::parse("http://localhost:43127")); - assert_eq!(first_origins.len(), 1); - assert_eq!(second_origins.len(), 1); - let first_origin = &first_origins[0]; - let second_origin = &second_origins[0]; - assert!( - registry - .bind_node(session, context, first_origin, "node-a") - .is_ok() - ); - assert!( - registry - .bind_node(session, second_context, first_origin, "node-b") - .is_ok() - ); - assert_eq!( - registry.bind_node(session, context, second_origin, "node-a"), - Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) - ); - assert_eq!(registry.node_by_external.len(), 2); - assert!(registry.advance_document(context).is_ok()); - assert_eq!(registry.node_by_external.len(), 1); - assert!( - registry - .bind_node(session, context, second_origin, "node-a") - .is_ok() - ); - } - - #[test] - fn invalid_node_and_context_inputs_are_rejected() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - assert_eq!( - registry.register_context(session, ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - assert_eq!( - registry.register_context( - session, - &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), - ), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - let contexts = values(registry.register_context(session, "context")); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(contexts.len(), 1); - assert_eq!(origins.len(), 1); - assert_eq!( - registry.bind_node(session, contexts[0], &origins[0], ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - assert_eq!( - registry.bind_node( - session, - contexts[0], - &origins[0], - &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), - ), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - } - - #[test] - fn browser_registry_errors_have_non_sensitive_deterministic_text() { - let expected_values = values(BrowserSessionId::new(1)); - let actual_values = values(BrowserSessionId::new(2)); - assert_eq!(expected_values.len(), 1); - assert_eq!(actual_values.len(), 1); - let errors = [ - BrowserRegistryError::InvalidExternalIdentifier, - BrowserRegistryError::UnknownBrowserSession, - BrowserRegistryError::UnknownBrowsingContext, - BrowserRegistryError::ContextSessionMismatch { - expected: expected_values[0], - actual: actual_values[0], - }, - BrowserRegistryError::ContextExternalIdentifierMismatch, - BrowserRegistryError::ContextOriginNotBound, - BrowserRegistryError::OriginChangedWithoutDocumentAdvance, - BrowserRegistryError::IdentifierSpaceExhausted, - BrowserRegistryError::DocumentEpochExhausted, - BrowserRegistryError::InternalAuthorityInvariant, - ]; - for error in errors { - let text = error.to_string(); - assert!(!text.is_empty()); - assert!(!text.contains("webdriver-session")); - } - } - #[test] fn maximum_identifier_limit_is_clamped_without_wrapping() { let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 4101ccc39..8834d7062 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -1,5 +1,7 @@ -use crate::browser_registry::{BrowserAuthorityRegistry, ObservedNodeHandle}; -use crate::{BrowserRegistryError, BrowserSessionId, DocumentEpoch, Origin}; +use crate::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + ObservedNodeHandle, Origin, +}; fn values(result: Result) -> Vec { result.into_iter().collect() @@ -24,16 +26,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { assert_eq!(origins.len(), 1); let origin = &origins[0]; - let first = registry - .bind_nodes(session, context, origin, &["unit-node"]) - .into_iter() - .flatten() - .collect::>(); - let repeated = registry - .bind_nodes(session, context, origin, &["unit-node"]) - .into_iter() - .flatten() - .collect::>(); + let first = values(registry.bind_node(session, context, origin, "unit-node")); + let repeated = values(registry.bind_node(session, context, origin, "unit-node")); assert_eq!(first.len(), 1); assert_eq!(repeated.len(), 1); assert_eq!(first[0], repeated[0]); @@ -222,7 +216,7 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() { let context = contexts[0]; assert_eq!( - registry.bind_nodes(attacker, context, &origins[0], &["unit-node"]), + registry.bind_node(attacker, context, &origins[0], "unit-node"), Err(BrowserRegistryError::ContextSessionMismatch { expected: owner, actual: attacker, @@ -321,59 +315,6 @@ fn unit_cfg_allocation_rotation_and_retirement_edges_are_exercised() { ); } -#[test] -fn failed_node_allocation_does_not_bind_context_origin() { - let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); - let session = values(registry.register_session("allocation-session"))[0]; - let exhausted_context = values(registry.register_context(session, "exhausted-context"))[0]; - let clean_context = values(registry.register_context(session, "clean-context"))[0]; - let first_origin = values(Origin::parse("http://127.0.0.1:43127"))[0].clone(); - let second_origin = values(Origin::parse("http://localhost:43127"))[0].clone(); - - assert_eq!( - values(registry.bind_node(session, exhausted_context, &first_origin, "node-one")).len(), - 1 - ); - assert_eq!( - values(registry.bind_node(session, exhausted_context, &first_origin, "node-two")).len(), - 1 - ); - assert_eq!( - registry.bind_node(session, clean_context, &first_origin, "node-three"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - assert_eq!( - registry.bind_node(session, clean_context, &second_origin, "node-three"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); -} - -#[test] -fn node_handles_cannot_cross_registry_instances() { - let origin = values(Origin::parse("http://127.0.0.1:43127"))[0].clone(); - let mut first = BrowserAuthorityRegistry::new(); - let first_session = values(first.register_session("first-session"))[0]; - let first_context = values(first.register_context(first_session, "first-context"))[0]; - let first_handle = - values(first.bind_node(first_session, first_context, &origin, "first-node"))[0].clone(); - - let mut second = BrowserAuthorityRegistry::new(); - let second_session = values(second.register_session("second-session"))[0]; - let second_context = values(second.register_context(second_session, "second-context"))[0]; - let second_handle = - values(second.bind_node(second_session, second_context, &origin, "second-node"))[0].clone(); - - assert_eq!(first_session, second_session); - assert_eq!(first_context, second_context); - assert_eq!(first_handle.node_id(), second_handle.node_id()); - assert_ne!(first_handle, second_handle); - assert_eq!( - second.validate_node_handle(&first_handle), - Err(BrowserRegistryError::UnknownNodeAuthority) - ); - assert_eq!(second.validate_node_handle(&second_handle), Ok(())); -} - #[test] fn unit_cfg_adapter_surface_exercises_accessors_equality_default_and_errors() { let mut registry = BrowserAuthorityRegistry::default(); diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index bd4705de2..865f627a8 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -127,7 +127,6 @@ fn parse_authority(authority: &str) -> Result<(String, Option, bool), Origi validate_dns_host(&host)?; Ok((host.clone(), port, host == "localhost")) } - fn looks_like_browser_ipv4_host(host: &str) -> bool { host.rsplit('.') .next() @@ -165,6 +164,9 @@ fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), } fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } let port = port_text .parse::() .map_err(|_error| OriginError::InvalidPort)?; @@ -257,7 +259,6 @@ impl BrowserSessionId { /// A nonzero identity for one independently navigable browser context. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct BrowsingContextId(u64); - impl BrowsingContextId { /// Validate one adapter-supplied browsing-context identifier. pub const fn new(value: u64) -> Result { @@ -387,7 +388,6 @@ impl ObservedNodeHandle { Ok(()) } } - /// A failure to construct or reuse an authority- and document-bound node handle safely. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NodeHandleError { @@ -907,7 +907,6 @@ impl PolicyContext { pub const fn approval(&self) -> &ApprovalEvidence { &self.approval } - /// Replace approval evidence after a user or enterprise decision. pub fn set_approval(&mut self, approval: ApprovalEvidence) { self.approval = approval; diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 7edd8d261..f5791fb5c 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,114 +1,40 @@ //! Shared security and governance contracts for OriginWeave. //! //! This crate keeps the long-lived value contracts in `contracts`, the -//! browser protocol/identifier boundaries and extension authority 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>(()) -//! ``` +//! browser protocol/identifier boundaries and extension authority in focused +//! modules so browser adapters can evolve without turning raw CDP or WebDriver +//! metadata into OriginWeave authority. #![forbid(unsafe_code)] #![deny(missing_docs)] -mod browser_authority_registry; mod browser_protocol; -mod browser_protocol_dispatch; -mod browser_protocol_operation; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contract_errors; mod contracts; mod extension_authority; -/// Stateless MCP routing validation that maps only explicit tools to typed actions. -pub mod mcp; -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; -mod webdriver_bidi_result; -mod webdriver_bidi_websocket_connect_target; -mod webdriver_bidi_websocket_endpoint; -pub use browser_authority_registry::BrowserAuthorityRegistry; pub use browser_protocol::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, - BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, - BrowserProtocolRuntimeRequirementError, BrowserProtocolUseValidationError, - BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, - OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; -pub use browser_protocol_dispatch::{ - BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, - BrowserProtocolRuntimeMetadata, -}; -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_NODE_REMOTE_VALUE_TYPE, - WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, - WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReference, - WebDriverBiDiRemoteNodeReferenceError, -}; -pub(crate) use browser_registry::contains_disallowed_protocol_text; pub use browser_registry::{ - BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, - UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + ObservedNodeHandle as RegistryObservedNodeHandle, }; pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, - ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, - NodeHandleError, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, - SessionMode, + ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant, ExtensionId, ExtensionIdError, InstructionSource, NodeHandleError, + ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, + SecretDelivery, SessionMode, evaluate_extension_access, }; pub use extension_authority::{ - AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentGrant, evaluate_extension_access, -}; -pub use webdriver_bidi_command::{ - CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, - ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiCommandResponseKind, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, - WebDriverBiDiLocateNodesResponseCorrelationError, - WebDriverBiDiLocateNodesResponseEnvelopeError, -}; -pub use webdriver_bidi_error_code::WebDriverBiDiErrorCode; -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, -}; -pub use webdriver_bidi_result::{ - ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, -}; -pub use webdriver_bidi_websocket_connect_target::{ - VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiSocketPeerVerificationError, - WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketConnectTargetError, -}; -pub use webdriver_bidi_websocket_endpoint::{ - CorrelatedWebDriverBiDiWebSocketEndpoint, MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, - WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointAdmissionError, - WebDriverBiDiWebSocketEndpointCorrelationError, + AgentTaskId, AgentTaskIdError, ExtensionAccessDecision as AuthorityExtensionAccessDecision, + ExtensionAccessRequest as AuthorityExtensionAccessRequest, + ExtensionAgentGrant as AuthorityExtensionAgentGrant, + evaluate_extension_access as evaluate_extension_authority_access, }; diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index b026d5e61..c7200e327 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -17,6 +17,9 @@ pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; /// The only MCP method that can enter the typed action-routing boundary. pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; +/// The MCP discovery method accepted by the typed tools-list boundary. +pub const MCP_TOOLS_LIST_METHOD: &str = "tools/list"; + /// Maximum accepted MCP method-name length in bytes. pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; @@ -119,6 +122,233 @@ pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { MCP_TOOL_CATALOG } +/// Protocol disposition carried by a typed MCP result. +/// +/// OriginWeave currently constructs only terminal results at this boundary. A transport adapter +/// must serialize [`Self::Complete`] as MCP's `"complete"` result type and must not omit or +/// reinterpret the required protocol field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpResultType { + /// The request completed and this value contains the final result. + Complete, +} + +/// Cache-sharing scope for an MCP cacheable list result. +/// +/// OriginWeave currently exposes only the conservative private scope. A transport adapter must +/// serialize this as MCP's `"private"` cache scope and must not widen it without a separately +/// reviewed policy that proves the returned catalog is safe to share across callers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpCacheScope { + /// The result may be cached only for the current caller's private context. + Private, +} + +/// One typed MCP `tools/list` page derived from the reviewed tool catalog. +/// +/// This value is discovery metadata only. It does not grant any tool capability or action +/// authority. The initial contract is deliberately one complete private page with zero freshness +/// so adapters cannot omit MCP's required result disposition or accidentally share or reuse +/// discovery metadata beyond the current request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolsListPage { + result_type: McpResultType, + tools: &'static [McpToolCatalogEntry], + ttl_ms: u64, + cache_scope: McpCacheScope, + next_cursor: Option<&'static str>, +} + +impl McpToolsListPage { + /// Return the mandatory MCP result disposition for this list page. + #[must_use] + pub const fn result_type(&self) -> McpResultType { + self.result_type + } + + /// Return the deterministic reviewed tool entries in this page. + #[must_use] + pub const fn tools(&self) -> &'static [McpToolCatalogEntry] { + self.tools + } + + /// Return the MCP freshness lifetime in milliseconds. + /// + /// The current conservative contract is zero, so clients must treat the result as + /// immediately stale rather than reusing it for a later request. + #[must_use] + pub const fn ttl_ms(&self) -> u64 { + self.ttl_ms + } + + /// Return the MCP cache-sharing scope for this page. + #[must_use] + pub const fn cache_scope(&self) -> McpCacheScope { + self.cache_scope + } + + /// Return the opaque continuation cursor when another page exists. + /// + /// The current fixed catalog is emitted as one complete page, so this is always `None`. + #[must_use] + pub const fn next_cursor(&self) -> Option<&'static str> { + self.next_cursor + } +} + +/// Build the conservative typed MCP `tools/list` result for the reviewed catalog. +/// +/// This function does not perform transport serialization, authorization, or pagination. It +/// binds the catalog to the mandatory complete result disposition plus explicit zero-TTL/private +/// cache hints so adapters cannot invent broader protocol or cache semantics independently from +/// this reviewed boundary. +#[must_use] +pub const fn mcp_tools_list_page() -> McpToolsListPage { + McpToolsListPage { + result_type: McpResultType::Complete, + tools: MCP_TOOL_CATALOG, + ttl_ms: 0, + cache_scope: McpCacheScope::Private, + next_cursor: None, + } +} + +/// A deterministic failure while validating one MCP `tools/list` request envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolsListBoundaryError { + /// The transport request omitted the required MCP protocol-version header. + MissingProtocolVersionHeader, + /// The structured request metadata omitted the required MCP protocol version. + MissingProtocolVersionMetadata, + /// The transport protocol version disagrees with the structured request metadata. + ProtocolVersionHeaderBodyMismatch, + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// The structured request metadata omitted the required client-capabilities object. + MissingClientCapabilities, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, + /// MCP routing method metadata disagrees with the method in the request body. + MethodHeaderBodyMismatch, + /// The request method is not the supported `tools/list` operation. + UnsupportedMethod, + /// The request supplied a cursor that this fixed single-page catalog never issued. + UnsupportedCursor, +} + +impl fmt::Display for McpToolsListBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingProtocolVersionHeader => { + formatter.write_str("MCP protocol version header is required") + } + Self::MissingProtocolVersionMetadata => { + formatter.write_str("MCP request metadata protocol version is required") + } + Self::ProtocolVersionHeaderBodyMismatch => { + formatter.write_str("MCP protocol version header does not match request metadata") + } + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::MissingClientCapabilities => { + formatter.write_str("MCP request metadata client capabilities are required") + } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } + Self::MethodHeaderBodyMismatch => { + formatter.write_str("MCP method header does not match the request body") + } + Self::UnsupportedMethod => { + formatter.write_str("only MCP tools/list requests can enter the discovery boundary") + } + Self::UnsupportedCursor => { + formatter.write_str("MCP tools/list cursor was not issued by this fixed catalog") + } + } + } +} + +impl std::error::Error for McpToolsListBoundaryError {} + +/// An MCP `tools/list` request whose protocol, required metadata, and routing envelope were +/// validated. +/// +/// This boundary is deliberately narrower than a general transport or pagination implementation. +/// A trusted structured parser must prove whether the required per-request client-capabilities +/// object was present; this type never accepts its contents as authority. The current reviewed +/// catalog returns one complete page and emits no continuation cursor, so no non-null cursor can +/// be a value previously issued by OriginWeave. A transport adapter must not silently ignore or +/// reinterpret a supplied cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolsListRequest { + method: &'static str, +} + +impl ValidatedMcpToolsListRequest { + /// Validate the stateless request envelope for the current fixed `tools/list` catalog. + /// + /// Both the required transport protocol-version header and structured request `_meta` + /// protocol version must be present, individually bounded to the exact supported-version + /// length before cross-field comparison, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A + /// trusted structured parser must also attest that the required `_meta` client-capabilities + /// object was present; its contents grant no OriginWeave authority. Each untrusted method + /// value is shape-validated before comparison. The routing/body method must then agree exactly. + /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation + /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. + pub fn new( + protocol_version_header: Option<&str>, + protocol_version_metadata: Option<&str>, + client_capabilities_present: bool, + routing_method: &str, + body_method: &str, + cursor: Option<&str>, + ) -> Result { + let protocol_version_header = protocol_version_header + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionHeader)?; + let protocol_version_metadata = protocol_version_metadata + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionMetadata)?; + + if protocol_version_header.len() > MCP_PROTOCOL_VERSION.len() + || protocol_version_metadata.len() > MCP_PROTOCOL_VERSION.len() + { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if protocol_version_header != protocol_version_metadata { + return Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch); + } + if protocol_version_metadata != MCP_PROTOCOL_VERSION { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if !client_capabilities_present { + return Err(McpToolsListBoundaryError::MissingClientCapabilities); + } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolsListBoundaryError::InvalidMethod); + } + if routing_method != body_method { + return Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_LIST_METHOD { + return Err(McpToolsListBoundaryError::UnsupportedMethod); + } + if cursor.is_some() { + return Err(McpToolsListBoundaryError::UnsupportedCursor); + } + + Ok(Self { + method: MCP_TOOLS_LIST_METHOD, + }) + } + + /// Return the canonical MCP method validated by this request. + #[must_use] + pub const fn method(&self) -> &'static str { + self.method + } +} + /// A deterministic failure while validating untrusted MCP routing metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpToolBoundaryError { diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs new file mode 100644 index 000000000..a3655de52 --- /dev/null +++ b/crates/originweave-core/src/release_acceptance.rs @@ -0,0 +1,368 @@ +//! Deterministic fail-closed release acceptance for commercial benchmark evidence. +//! +//! This module aggregates only explicit mandatory-suite outcomes and bounded, +//! buyer-visible limitations. It does not execute benchmarks, infer missing +//! evidence, authenticate artifacts, or grant release authority. + +use std::fmt; + +use unicode_normalization::is_nfc; + +/// Maximum UTF-8 byte length retained for either buyer-visible limitation field. +pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; + +/// Maximum number of buyer-visible limitations retained in one release report. +pub const MAX_DECLARED_RELEASE_LIMITATIONS: usize = 64; + +/// One mandatory benchmark suite in the release acceptance contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BenchmarkSuite { + /// Controlled local fixtures with deterministic post-condition oracles. + ControlledDeterministic, + /// Stable web compatibility tasks for the declared support profile. + WebCompatibility, + /// Hostile security cases that measure unauthorized authority or disclosure. + SecurityAdversarial, + /// Crash, timeout, retry, reconciliation, cleanup, and restore behavior. + ReliabilityRecovery, + /// Enterprise isolation, identity, policy, audit, and operator controls. + EnterpriseOperability, +} + +impl BenchmarkSuite { + /// Every mandatory benchmark suite in canonical release-report order. + pub const ALL: [Self; 5] = [ + Self::ControlledDeterministic, + Self::WebCompatibility, + Self::SecurityAdversarial, + Self::ReliabilityRecovery, + Self::EnterpriseOperability, + ]; + + /// Return the stable snake-case suite identifier used by benchmark evidence. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ControlledDeterministic => "controlled_deterministic_suite", + Self::WebCompatibility => "web_compatibility_suite", + Self::SecurityAdversarial => "security_adversarial_suite", + Self::ReliabilityRecovery => "reliability_recovery_suite", + Self::EnterpriseOperability => "enterprise_operability_suite", + } + } + + const fn index(self) -> usize { + match self { + Self::ControlledDeterministic => 0, + Self::WebCompatibility => 1, + Self::SecurityAdversarial => 2, + Self::ReliabilityRecovery => 3, + Self::EnterpriseOperability => 4, + } + } +} + +/// Evaluated outcome for one mandatory benchmark suite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchmarkSuiteOutcome { + /// Every threshold required for the declared profile passed. + Passed, + /// At least one mandatory threshold is known to have failed. + Failed, + /// Evidence is insufficient to establish either pass or threshold failure. + Inconclusive, +} + +/// One explicit narrowed release claim and its buyer-visible consequence. +/// +/// An accepted-with-limitations decision cannot be produced from an opaque +/// boolean. Every limitation must name the unsupported claim and state the +/// consequence that a buyer must account for in the declared support profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredLimitation { + unsupported_claim: String, + buyer_consequence: String, +} + +impl DeclaredLimitation { + /// Construct one explicit buyer-visible release limitation. + /// + /// Empty/whitespace-only or punctuation-only values, surrounding whitespace, + /// non-NFC Unicode, fields exceeding the fixed UTF-8 byte budget, and ambiguous + /// presentation characters fail closed because they cannot safely represent one + /// canonical, resource-bounded buyer-visible release limitation. Accepted text + /// is retained byte-for-byte; this constructor never normalizes caller input + /// implicitly. + pub fn new( + unsupported_claim: impl Into, + buyer_consequence: impl Into, + ) -> Result { + Self::from_owned_text(unsupported_claim.into(), buyer_consequence.into()) + } + + fn from_owned_text( + unsupported_claim: String, + buyer_consequence: String, + ) -> Result { + if unsupported_claim.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationClaim); + } + if unsupported_claim.trim() != unsupported_claim { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationClaimTooLong); + } + if !is_nfc(&unsupported_claim) { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim + .chars() + .any(disallowed_release_limitation_character) + || !unsupported_claim.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if buyer_consequence.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationConsequence); + } + if buyer_consequence.trim() != buyer_consequence { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationConsequenceTooLong); + } + if !is_nfc(&buyer_consequence) { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence + .chars() + .any(disallowed_release_limitation_character) + || !buyer_consequence.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + Ok(Self { + unsupported_claim, + buyer_consequence, + }) + } + + /// Return the exact unsupported or narrowed release claim. + #[must_use] + pub fn unsupported_claim(&self) -> &str { + &self.unsupported_claim + } + + /// Return the exact consequence exposed to buyers and operators. + #[must_use] + pub fn buyer_consequence(&self) -> &str { + &self.buyer_consequence + } +} + +fn disallowed_release_limitation_character(character: char) -> bool { + let code_point = character as u32; + character.is_control() + || matches!( + code_point, + 0x00ad + | 0x034f + | 0x061c + | 0x115f..=0x1160 + | 0x17b4..=0x17b5 + | 0x180b..=0x180f + | 0x200b..=0x200f + | 0x2028..=0x202e + | 0x2060..=0x206f + | 0x3164 + | 0xfe00..=0xfe0f + | 0xfeff + | 0xffa0 + | 0xfff0..=0xfff8 + | 0x1bca0..=0x1bca3 + | 0x1d173..=0x1d17a + | 0xe0000..=0xe0fff + ) +} + +/// Deterministic release decision produced from mandatory suite evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecision { + /// Every mandatory suite passed for the full declared support profile. + Accepted, + /// Every mandatory suite passed after buyer-visible limitations were declared. + AcceptedWithDeclaredLimitations, + /// At least one mandatory suite is known to have failed its threshold. + Rejected, + /// No known threshold failure exists, but mandatory evidence is incomplete. + Inconclusive, +} + +/// Fail-closed input error while constructing a release decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecisionError { + /// A declared limitation did not identify the unsupported release claim. + EmptyLimitationClaim, + /// A declared limitation claim exceeded the fixed UTF-8 byte budget. + LimitationClaimTooLong, + /// A declared limitation claim was not canonical NFC text or was presentation-unsafe. + InvalidLimitationClaim, + /// A declared limitation did not state the buyer-visible consequence. + EmptyLimitationConsequence, + /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. + LimitationConsequenceTooLong, + /// A limitation consequence was not canonical NFC text or was presentation-unsafe. + InvalidLimitationConsequence, + /// One release report supplied more buyer-visible limitations than the fixed resource budget. + TooManyDeclaredLimitations, + /// More than one limitation used the same unsupported claim identity. + DuplicateLimitationClaim, + /// The same suite appeared more than once instead of one authoritative result. + DuplicateSuite(BenchmarkSuite), +} + +impl fmt::Display for ReleaseDecisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyLimitationClaim => { + formatter.write_str("declared release limitation must name an unsupported claim") + } + Self::LimitationClaimTooLong => { + formatter.write_str("declared release limitation claim exceeds the byte budget") + } + Self::InvalidLimitationClaim => formatter.write_str( + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + Self::EmptyLimitationConsequence => formatter + .write_str("declared release limitation must state a buyer-visible consequence"), + Self::LimitationConsequenceTooLong => formatter + .write_str("declared release limitation consequence exceeds the byte budget"), + Self::InvalidLimitationConsequence => formatter.write_str( + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + Self::TooManyDeclaredLimitations => formatter + .write_str("benchmark release decision contains too many declared limitations"), + Self::DuplicateLimitationClaim => formatter + .write_str("benchmark release decision contains duplicate limitation claim"), + Self::DuplicateSuite(suite) => write!( + formatter, + "benchmark release evidence contains duplicate suite: {}", + suite.as_str() + ), + } + } +} + +impl std::error::Error for ReleaseDecisionError {} + +/// Release decision together with exact mandatory-suite evidence gaps and failures. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseDecisionReport { + decision: ReleaseDecision, + failed_suites: Vec, + inconclusive_suites: Vec, + missing_suites: Vec, + declared_limitations: Vec, +} + +impl ReleaseDecisionReport { + /// Return the deterministic release decision. + #[must_use] + pub const fn decision(&self) -> ReleaseDecision { + self.decision + } + + /// Return suites with a known mandatory-threshold failure. + #[must_use] + pub fn failed_suites(&self) -> &[BenchmarkSuite] { + &self.failed_suites + } + + /// Return suites whose supplied evidence was explicitly inconclusive. + #[must_use] + pub fn inconclusive_suites(&self) -> &[BenchmarkSuite] { + &self.inconclusive_suites + } + + /// Return mandatory suites for which no outcome was supplied. + #[must_use] + pub fn missing_suites(&self) -> &[BenchmarkSuite] { + &self.missing_suites + } + + /// Return the exact buyer-visible limitations retained with this decision. + #[must_use] + pub fn declared_limitations(&self) -> &[DeclaredLimitation] { + &self.declared_limitations + } +} + +/// Produce one deterministic release decision from mandatory suite outcomes. +/// +/// Duplicate suite evidence, duplicate buyer-visible limitation claim identities, +/// and excessive declared-limitation cardinality fail closed rather than selecting +/// or retaining ambiguous or attacker-controlled release metadata. A known +/// mandatory-threshold failure is always rejected, even when other suites are +/// missing or inconclusive; all such evidence gaps remain in the returned report. +/// Without a known failure, missing or inconclusive evidence is never promoted to +/// acceptance. Accepted-with-limitations requires at least one validated +/// [`DeclaredLimitation`], so the decision cannot be detached from the exact +/// narrowed claim and buyer-visible consequence. +pub fn decide_release( + results: I, + declared_limitations: &[DeclaredLimitation], +) -> Result +where + I: IntoIterator, +{ + if declared_limitations.len() > MAX_DECLARED_RELEASE_LIMITATIONS { + return Err(ReleaseDecisionError::TooManyDeclaredLimitations); + } + + let mut limitation_claims = std::collections::BTreeSet::new(); + for limitation in declared_limitations { + if !limitation_claims.insert(limitation.unsupported_claim()) { + return Err(ReleaseDecisionError::DuplicateLimitationClaim); + } + } + + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; + for (suite, outcome) in results { + let slot = &mut outcomes[suite.index()]; + if slot.is_some() { + return Err(ReleaseDecisionError::DuplicateSuite(suite)); + } + *slot = Some(outcome); + } + + let mut failed_suites = Vec::new(); + let mut inconclusive_suites = Vec::new(); + let mut missing_suites = Vec::new(); + for suite in BenchmarkSuite::ALL { + match outcomes[suite.index()] { + Some(BenchmarkSuiteOutcome::Passed) => {} + Some(BenchmarkSuiteOutcome::Failed) => failed_suites.push(suite), + Some(BenchmarkSuiteOutcome::Inconclusive) => inconclusive_suites.push(suite), + None => missing_suites.push(suite), + } + } + + let decision = if !failed_suites.is_empty() { + ReleaseDecision::Rejected + } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { + ReleaseDecision::Inconclusive + } else if declared_limitations.is_empty() { + ReleaseDecision::Accepted + } else { + ReleaseDecision::AcceptedWithDeclaredLimitations + }; + + Ok(ReleaseDecisionReport { + decision, + failed_suites, + inconclusive_suites, + missing_suites, + declared_limitations: declared_limitations.to_vec(), + }) +} diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs new file mode 100644 index 000000000..933641bba --- /dev/null +++ b/crates/originweave-core/src/root.rs @@ -0,0 +1,31 @@ +//! Shared security and governance contracts for OriginWeave. +//! +//! The historical core contracts remain source-compatible while adapter-specific +//! boundaries can live in focused modules without changing their authority model. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +#[path = "lib.rs"] +mod core_contracts; +use core_contracts as contracts; + +pub use core_contracts::{ + ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, AgentTaskId, + AgentTaskIdError, ApprovalEvidence, ApprovalScope, + AuthorityExtensionAccessDecision as ExtensionAccessDecision, + AuthorityExtensionAccessRequest as ExtensionAccessRequest, + AuthorityExtensionAgentGrant as ExtensionAgentGrant, BrowserAuthorityRegistry, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, + DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, + InstructionSource, MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + NodeHandleError, Origin, OriginError, PolicyContext, + RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_authority_access as evaluate_extension_access, +}; + +/// Stateless MCP routing validation that maps only explicit tools to typed actions. +pub mod mcp; +/// Deterministic fail-closed release benchmark acceptance aggregation. +pub mod release_acceptance; diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs deleted file mode 100644 index b73ae90ab..000000000 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ /dev/null @@ -1,422 +0,0 @@ -use std::error::Error; -use std::fmt::{Display, Formatter}; - -use crate::{ - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, - contains_disallowed_protocol_text, -}; - -/// 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 {} - -/// 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 {} - -/// 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, - /// A correlated error envelope cannot be converted into success response evidence. - CorrelatedErrorResponse, - /// 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::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}" - ), - } - } -} - -impl Error for WebDriverBiDiLocateNodesResponseEnvelopeError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Correlation(error) => Some(error), - Self::MissingResponseId - | Self::UncorrelatableErrorResponse - | Self::CorrelatedErrorResponse => None, - } - } -} - -/// Non-cloneable evidence that one `locateNodes` response matched the exact command id. -/// -/// 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 -/// 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 { - /// 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 - } - - /// 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. -/// -/// This value deliberately keeps success and error envelopes distinguishable after exact response -/// 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. -#[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() - } - - /// 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. -/// -/// 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, -/// 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, - max_node_count: u16, - 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(), - max_node_count: query.max_node_count(), - 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 - } - - /// 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. 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(crate) 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, - max_node_count: self.max_node_count, - }) - } - - /// 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 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 - /// 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) { - output.push('"'); - for character in value.chars() { - match character { - '"' => output.push_str("\\\""), - '\\' => output.push_str("\\\\"), - character => output.push(character), - } - } - output.push('"'); -} diff --git a/crates/originweave-core/src/webdriver_bidi_error_code.rs b/crates/originweave-core/src/webdriver_bidi_error_code.rs deleted file mode 100644 index bd336cf2d..000000000 --- a/crates/originweave-core/src/webdriver_bidi_error_code.rs +++ /dev/null @@ -1,170 +0,0 @@ -/// 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 - .iter() - .find_map(|(raw, code)| (*raw == value).then_some(*code)) -} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document.rs deleted file mode 100644 index 3bb7a42c4..000000000 --- a/crates/originweave-core/src/webdriver_bidi_response_document.rs +++ /dev/null @@ -1,98 +0,0 @@ -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 raw response is not valid UTF-8. - InvalidUtf8, - /// 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::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", - ), - } - } -} - -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(), - }) - } - - /// 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 { - &self.raw - } -} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs deleted file mode 100644 index 068bdced5..000000000 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs +++ /dev/null @@ -1,269 +0,0 @@ -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, -}; -use crate::{ - BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, - ValidatedBrowserProtocolUse, WebDriverBiDiErrorCode, WebDriverBiDiLocateNodesAdmissionError, -}; - -/// 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. - InvalidResultNodes, - /// The correlated success result repeated the decoded `nodes` field. - DuplicateResultNodes, - /// One in-budget `nodes` array item was not a JSON object. - InvalidResultNode, - /// One in-budget node object repeated decoded `type` or `sharedId` authority-relevant metadata. - DuplicateResultNodeField, - /// One in-budget node object omitted its required WebDriver BiDi remote-value `type` field. - MissingResultNodeType, - /// One in-budget node object's `type` field was not a JSON string. - InvalidResultNodeType, - /// 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), - /// 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, -} - -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}" - ), - 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") - } - 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::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", - ), - } - } -} - -impl Error for WebDriverBiDiLocateNodesResponseDocumentError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Parse(error) => Some(error), - Self::Envelope(error) => Some(error), - Self::ResultAdmission(error) => Some(error), - Self::NodeBinding(error) => Some(error), - Self::ProtocolError(_) - | Self::MissingResultNodes - | Self::InvalidResultNodes - | Self::DuplicateResultNodes - | Self::InvalidResultNode - | Self::DuplicateResultNodeField - | Self::MissingResultNodeType - | Self::InvalidResultNodeType - | Self::InvalidResultNodeSharedId - | Self::ResultParserInvariant => None, - } - } -} - -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) - } - - /// 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-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 - /// 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< - ValidatedWebDriverBiDiLocateNodesResult, - WebDriverBiDiLocateNodesResponseDocumentError, - > { - let parsed = document - .parse_command_response() - .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; - 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 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) - .collect::>(); - validated - .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)] -mod tests { - use super::WebDriverBiDiLocateNodesResponseDocumentError; - use super::locate_nodes_result_document::{ - parse_wire_locate_nodes_result, parse_wire_locate_nodes_result_bounded, - }; - - #[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)); - } - - #[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_eq!( - result.err(), - Some(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant) - ); - } -} 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 deleted file mode 100644 index 409d0f6f8..000000000 --- a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs +++ /dev/null @@ -1,714 +0,0 @@ -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()) - } - - 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, - 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( - 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(); - let mut over_budget = false; - if self.peek_byte() == Some(b']') { - self.position += 1; - return Ok(nodes); - } - - loop { - 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',') => { - 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 = 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 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( - WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, - ); - } - _ => { - self.position += 1; - } - } - } - } - - fn parse_escape( - &mut self, - decoded: &mut String, - ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { - let escaped = self - .peek_byte() - .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; - self.position += 1; - match escaped { - 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), - } - Ok(()) - } - - fn parse_unicode_escape( - &mut self, - decoded: &mut String, - ) -> 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 { - u32::from(first) - }; - let character = char::from_u32(scalar) - .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; - decoded.push(character); - 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.as_bytes().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\",", - "\"utf8\":\"é\",", - "\"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, - ), - (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 { - assert_eq!(parse_wire_locate_nodes_result(raw).err(), Some(expected)); - } - } - - #[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("{}"); - 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 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)); - } - - #[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 mut control = ResultParser::new("\"\u{0001}\""); - assert_eq!(control.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""#, - "\"\\uD83D\\u12", - r#""\uDE00""#, - r#""\u12""#, - "\"\\u12", - r#""\u00G0""#, - ] { - let mut parser = ResultParser::new(raw); - assert_eq!(parser.parse_string(), Err(INVARIANT)); - } - } -} diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs deleted file mode 100644 index cf22c762c..000000000 --- a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs +++ /dev/null @@ -1,625 +0,0 @@ -use std::{error::Error, fmt}; - -use crate::{ - BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, - WebDriverBiDiCommandResponseKind, - webdriver_bidi_error_code::{WebDriverBiDiErrorCode, parse_webdriver_bidi_error_code}, -}; - -/// 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, - /// The error response uses a string outside the current WebDriver BiDi `ErrorCode` vocabulary. - UnexpectedErrorCode, -} - -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" - } - Self::UnexpectedErrorCode => "WebDriver BiDi response error code is not recognized", - }) - } -} - -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, 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 { - /// 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 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 { - 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, 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 - { - let parsed = ResponseEnvelopeParser::new(self.as_str()).parse()?; - Ok(ParsedWebDriverBiDiCommandResponseEnvelope { - document: self, - kind: parsed.kind, - response_id: parsed.response_id, - error_code: parsed.error_code, - }) - } -} - -#[derive(Debug, PartialEq, Eq)] -enum ParsedJsonValue { - Object, - Array, - String(Vec), - Number(String), - Boolean, - Null, -} - -struct ParsedEnvelopeFields { - kind: WebDriverBiDiCommandResponseKind, - response_id: Option, - error_code: 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(); - // The bounded-document constructor proves the first non-whitespace byte is `{`. - self.position += 1; - 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'}') => { - self.position += 1; - break; - } - _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), - } - } - } else { - self.position += 1; - } - - 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)?; - let error_code = - Self::validate_required_payload(kind, result, error_code, message, stacktrace)?; - - Ok(ParsedEnvelopeFields { - kind, - response_id, - error_code, - }) - } - - 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, - ); - } - Ok(None) - } - WebDriverBiDiCommandResponseKind::Error => { - let error_code = error_code - .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; - let message = message - .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; - let ParsedJsonValue::String(error_code) = error_code else { - return Err( - WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, - ); - }; - if !matches!(message, ParsedJsonValue::String(_)) { - return Err( - WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, - ); - } - let error_code = parse_webdriver_bidi_error_code(&error_code) - .ok_or(WebDriverBiDiResponseEnvelopeParseError::UnexpectedErrorCode)?; - if let Some(stacktrace) = stacktrace - && !matches!(stacktrace, ParsedJsonValue::String(_)) - { - return Err( - WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, - ); - } - Ok(Some(error_code)) - } - } - } - - 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.position += 1; - 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.position += 1; - 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> { - // 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'"'), - 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 { - // 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); - 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() - } -} diff --git a/crates/originweave-core/src/webdriver_bidi_result.rs b/crates/originweave-core/src/webdriver_bidi_result.rs deleted file mode 100644 index ba3288269..000000000 --- a/crates/originweave-core/src/webdriver_bidi_result.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::error::Error; -use std::fmt::{Display, Formatter}; - -use crate::{ - BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserProtocolCapability, - BrowserProtocolKind, ObservedNodeHandle, ValidatedBrowserProtocolUse, - ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiAccessibilityQueryError, - WebDriverBiDiLocateNodesAdmissionError, 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 - } - - /// 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 { - /// 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, - }) - } -} diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs deleted file mode 100644 index 5002731db..000000000 --- a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! 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. When explicit trusted name resolution is needed, -//! the typed error preserves the correlated endpoint instead of discarding its session evidence. -//! 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, - 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 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, - 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 - } - - /// 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. - /// - /// 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. 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 { - 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 { - correlated_endpoint: self, - }, - ); - }; - - 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, PartialEq, Eq)] -pub enum WebDriverBiDiWebSocketConnectTargetError { - /// The admitted endpoint used a host name and therefore requires explicit trusted resolution. - 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( - "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution", - ), - } - } -} - -impl std::error::Error for WebDriverBiDiWebSocketConnectTargetError {} diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs deleted file mode 100644 index 83d2e9b04..000000000 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ /dev/null @@ -1,327 +0,0 @@ -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, -} - -/// 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 admitted session 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 { - 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.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_id(session_id) { - return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId); - } - - Ok(Self { - endpoint: value.to_owned(), - secure, - host, - port, - session_id: session_id.to_owned(), - }) - } - - /// 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_id(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 { - &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 exact canonical session identifier admitted from the WebDriver endpoint. - #[must_use] - pub fn session_id(&self) -> &str { - &self.session_id - } -} - -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_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(); - 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, - } -} - -/// 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 an admitted canonical W3C/ChromeDriver 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 {} - -/// 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 an admitted canonical W3C/ChromeDriver 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 an admitted canonical representation" - } - Self::SessionIdMismatch => { - "WebDriver BiDi WebSocket endpoint session id does not match the expected session" - } - }; - f.write_str(message) - } -} - -impl std::error::Error for WebDriverBiDiWebSocketEndpointCorrelationError {} diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 81311b5e7..7a53e62c2 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -1,80 +1,12 @@ -#![allow(clippy::expect_used)] - use std::error::Error; use originweave_core::{ - BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, - ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiRemoteNodeReferenceError, + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, ObservedNodeHandle, Origin, }; -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, - )) +fn loopback_origin() -> Result> { + Ok(Origin::parse("http://127.0.0.1:43127")?) } #[test] @@ -112,7 +44,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, whitespace, or Unicode format characters".to_owned(), + "external browser identifier must contain 1 to 512 UTF-8 bytes".to_owned(), ), ( BrowserRegistryError::UnknownBrowserSession, @@ -162,10 +94,10 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; - 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")?; + let first = registry.bind_node(session, context, &origin, "backend-node-17")?; + let same = registry.bind_node(session, context, &origin, "backend-node-17")?; assert_eq!(first.node_id(), same.node_id()); let next_epoch = registry.advance_document(context)?; @@ -178,7 +110,7 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< }) ); - let rebound = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; assert_eq!(rebound.document_epoch(), next_epoch); assert_ne!(first.node_id(), rebound.node_id()); Ok(()) @@ -189,9 +121,8 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let owner = registry.register_session("owner-session")?; let attacker = registry.register_session("attacker-session")?; let context = registry.register_context(owner, "shared-looking-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; assert_eq!( - bind_observed_node(&mut registry, attacker, context, &origin, "node"), - Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( - BrowserRegistryError::ContextSessionMismatch { - expected: owner, - actual: attacker, - } - )) + registry.bind_node(attacker, context, &origin, "node"), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) ); Ok(()) } @@ -280,28 +202,37 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); + let session = registry.register_session("webdriver-session")?; + let exhausted_context = registry.register_context(session, "exhaustion-source")?; + let clean_context = registry.register_context(session, "clean-context")?; + let first_origin = loopback_origin()?; + let second_origin = Origin::parse("http://localhost:43127")?; + + registry.bind_node(session, exhausted_context, &first_origin, "node-one")?; + registry.bind_node(session, exhausted_context, &first_origin, "node-two")?; + + assert_eq!( + registry.bind_node(session, clean_context, &first_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert_eq!( + registry.bind_node(session, clean_context, &second_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted), + "a failed allocation must not leave behind origin authority" ); Ok(()) } @@ -322,43 +253,6 @@ 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) - ); - 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")?; - let origin = loopback_origin(); - assert_eq!( - bind_observed_node( - &mut registry, - session, - context, - &origin, - "backend-node-17\n", - ), - Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( - WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId - )) - ); Ok(()) } @@ -377,13 +271,15 @@ fn authority_identifier_capacity_is_bounded_and_testable() -> Result<(), Box Result<(), Bo let known = registry.register_session("known-session")?; let context = registry.register_context(known, "known-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; + assert_eq!( + registry.bind_node(unknown, context, &origin, "node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + Ok(()) +} + +#[test] +fn registry_revalidates_live_node_authority_and_rejects_forged_or_retired_handles() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let other = registry.register_session("other-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = loopback_origin()?; + let live = registry.bind_node(owner, context, &origin, "backend-node-17")?; + + assert_eq!(registry.validate_node_handle(&live), Ok(())); + + let forged_node = ObservedNodeHandle::new( + owner, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_eq!( + registry.validate_node_handle(&forged_node), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let wrong_session = ObservedNodeHandle::new( + other, + context, + origin.clone(), + live.document_epoch(), + live.node_id(), + )?; + assert_eq!( + registry.validate_node_handle(&wrong_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_context = registry.register_context(owner, "unbound-context")?; + let synthetic_unbound = ObservedNodeHandle::new( + owner, + unbound_context, + origin.clone(), + DocumentEpoch::new(1)?, + 777, + )?; + assert_eq!( + registry.validate_node_handle(&synthetic_unbound), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch.value(), 2); + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let replacement = registry.bind_node(owner, context, &origin, "backend-node-17")?; + assert_eq!(registry.validate_node_handle(&replacement), Ok(())); + + registry.remove_context(context)?; assert_eq!( - bind_observed_node(&mut registry, unknown, context, &origin, "node"), - Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( - BrowserRegistryError::UnknownBrowserSession - )) + registry.validate_node_handle(&replacement), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let session_context = registry.register_context(owner, "session-retirement")?; + let session_handle = registry.bind_node(owner, session_context, &origin, "session-node")?; + registry.remove_session(owner)?; + assert_eq!( + registry.validate_node_handle(&session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) ); Ok(()) } diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs deleted file mode 100644 index 9f79cb652..000000000 --- a/crates/originweave-core/tests/browser_context_origin_binding.rs +++ /dev/null @@ -1,158 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::error::Error; -use std::io; - -use originweave_core::{ - 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()) -} - -fn second_origin() -> Result> { - Origin::parse("http://localhost:43127") - .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(); - let session = registry.register_session("webdriver-session")?; - let context = registry.register_context(session, "top-level-context")?; - let origin = first_origin()?; - - let epoch = registry.bind_context_origin(session, context, &origin)?; - assert_eq!(epoch, DocumentEpoch::new(1)?); - assert_eq!( - registry.bind_context_origin(session, context, &origin)?, - epoch - ); - - let node = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; - assert_eq!(node.document_epoch(), epoch); - assert_eq!(node.origin(), &origin); - Ok(()) -} - -#[test] -fn context_origin_change_requires_document_rotation() -> Result<(), Box> { - let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("webdriver-session")?; - let context = registry.register_context(session, "top-level-context")?; - let first = first_origin()?; - let second = second_origin()?; - - registry.bind_context_origin(session, context, &first)?; - assert_eq!( - registry.bind_context_origin(session, context, &second), - Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) - ); - - let next_epoch = registry.advance_document(context)?; - assert_eq!(next_epoch, DocumentEpoch::new(2)?); - assert_eq!( - registry.bind_context_origin(session, context, &second)?, - next_epoch - ); - Ok(()) -} - -#[test] -fn context_origin_binding_rejects_cross_session_and_unknown_authority() -> Result<(), Box> -{ - let mut registry = BrowserAuthorityRegistry::new(); - let owner = registry.register_session("owner-session")?; - let attacker = registry.register_session("attacker-session")?; - let context = registry.register_context(owner, "top-level-context")?; - let origin = first_origin()?; - - assert_eq!( - registry.bind_context_origin(attacker, context, &origin), - Err(BrowserRegistryError::ContextSessionMismatch { - expected: owner, - actual: attacker, - }) - ); - - let unknown_session = BrowserSessionId::new(999)?; - assert_eq!( - registry.bind_context_origin(unknown_session, context, &origin), - Err(BrowserRegistryError::UnknownBrowserSession) - ); - - let unknown_context = BrowsingContextId::new(999)?; - assert_eq!( - registry.bind_context_origin(owner, unknown_context, &origin), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - Ok(()) -} 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 deleted file mode 100644 index da3bf51db..000000000 --- a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs +++ /dev/null @@ -1,214 +0,0 @@ -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(); - - 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!( - 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(()) -} diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs deleted file mode 100644 index 986d4cdd4..000000000 --- a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs +++ /dev/null @@ -1,180 +0,0 @@ -use std::{cell::Cell, error::Error, io}; - -use originweave_core::{ - BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - 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(|_| { - 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_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)?; - reset_dispatch_marker(); - - let result = descriptor.dispatch_if_context_origin_current( - ®istry, - BrowserContextOriginDispatchTarget::new( - 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)?; - - reset_dispatch_marker(); - assert_eq!( - descriptor.dispatch_if_context_origin_current( - ®istry, - BrowserContextOriginDispatchTarget::new( - 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, - BrowserContextOriginDispatchTarget::new( - 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)?; - reset_dispatch_marker(); - - assert_eq!( - descriptor.dispatch_if_context_origin_current( - ®istry, - BrowserContextOriginDispatchTarget::new( - 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(()) -} diff --git a/crates/originweave-core/tests/browser_context_origin_revalidation.rs b/crates/originweave-core/tests/browser_context_origin_revalidation.rs deleted file mode 100644 index 7f35fab1d..000000000 --- a/crates/originweave-core/tests/browser_context_origin_revalidation.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::error::Error; -use std::io; - -use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; - -fn first_origin() -> Result { - Origin::parse("http://127.0.0.1:43127") - .map_err(|_error| io::Error::other("controlled first origin must be valid")) -} - -fn second_origin() -> Result { - Origin::parse("http://localhost:43127") - .map_err(|_error| io::Error::other("controlled second origin must be valid")) -} - -#[test] -fn current_context_origin_must_be_bound_before_revalidation() -> Result<(), Box> { - let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("webdriver-session")?; - let context = registry.register_context(session, "top-level-context")?; - let origin = first_origin()?; - - assert_eq!( - registry.require_context_origin(session, context, &origin), - Err(BrowserRegistryError::ContextOriginNotBound) - ); - - let epoch = registry.bind_context_origin(session, context, &origin)?; - assert_eq!( - registry.require_context_origin(session, context, &origin), - Ok(epoch) - ); - Ok(()) -} - -#[test] -fn current_context_origin_revalidation_fails_closed_on_mismatch() -> Result<(), Box> { - let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("webdriver-session")?; - let context = registry.register_context(session, "top-level-context")?; - let first = first_origin()?; - let second = second_origin()?; - - registry.bind_context_origin(session, context, &first)?; - assert_eq!( - registry.require_context_origin(session, context, &second), - Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) - ); - assert!( - registry - .require_context_origin(session, context, &first) - .is_ok() - ); - Ok(()) -} - -#[test] -fn document_rotation_requires_fresh_origin_binding() -> Result<(), Box> { - let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("webdriver-session")?; - let context = registry.register_context(session, "top-level-context")?; - let first = first_origin()?; - let second = second_origin()?; - - registry.bind_context_origin(session, context, &first)?; - let next_epoch = registry.advance_document(context)?; - assert_eq!( - registry.require_context_origin(session, context, &first), - Err(BrowserRegistryError::ContextOriginNotBound) - ); - - registry.bind_context_origin(session, context, &second)?; - assert_eq!( - registry.require_context_origin(session, context, &second), - Ok(next_epoch) - ); - Ok(()) -} - -#[test] -fn context_origin_revalidation_preserves_session_ownership() -> Result<(), Box> { - let mut registry = BrowserAuthorityRegistry::new(); - let owner = registry.register_session("owner-session")?; - let attacker = registry.register_session("attacker-session")?; - let context = registry.register_context(owner, "top-level-context")?; - let origin = first_origin()?; - - registry.bind_context_origin(owner, context, &origin)?; - assert_eq!( - registry.require_context_origin(attacker, context, &origin), - Err(BrowserRegistryError::ContextSessionMismatch { - expected: owner, - actual: attacker, - }) - ); - Ok(()) -} diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs deleted file mode 100644 index 333b28701..000000000 --- a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs +++ /dev/null @@ -1,234 +0,0 @@ -use std::{cell::Cell, error::Error}; - -use originweave_core::{ - BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, - BrowserSessionId, BrowsingContextId, DocumentEpoch, 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::Navigation], - )?) -} - -fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { - BrowserProtocolRuntimeMetadata::new( - BrowserProtocolKind::WebDriverBiDi, - adapter_version, - PROTOCOL_REVISION, - BROWSER_REVISION, - ) -} - -fn target( - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, -) -> BrowserContextDispatchTarget { - BrowserContextDispatchTarget::new(browser_session, browsing_context) -} - -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 context_dispatch_target_preserves_requested_ids_without_granting_authority() --> Result<(), Box> { - let session = BrowserSessionId::new(7)?; - let context = BrowsingContextId::new(11)?; - let target = target(session, context); - - assert_eq!(target.browser_session(), session); - assert_eq!(target.browsing_context(), context); - Ok(()) -} - -#[test] -fn exact_context_and_runtime_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")?; - reset_dispatch_marker(); - - let result = descriptor.dispatch_if_context_current( - ®istry, - target(session, context), - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(ADAPTER_VERSION), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - )?; - - assert!(dispatch_was_called()); - assert_eq!(result, Ok((1, BrowserProtocolCapability::Navigation))); - - registry.advance_document(context)?; - reset_dispatch_marker(); - let next = descriptor.dispatch_if_context_current( - ®istry, - target(session, context), - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(ADAPTER_VERSION), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - )?; - assert!(dispatch_was_called()); - assert_eq!(next, Ok((2, BrowserProtocolCapability::Navigation))); - Ok(()) -} - -#[test] -fn cross_session_context_reuse_fails_before_dispatch() -> Result<(), Box> { - let descriptor = descriptor()?; - let mut registry = BrowserAuthorityRegistry::new(); - let owner = registry.register_session("owner-session")?; - let attacker = registry.register_session("attacker-session")?; - let context = registry.register_context(owner, "top-level-context")?; - reset_dispatch_marker(); - - let result = descriptor.dispatch_if_context_current( - ®istry, - target(attacker, context), - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(ADAPTER_VERSION), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - ); - - assert_eq!( - result, - Err(BrowserContextProtocolDispatchError::BrowserAuthority( - BrowserRegistryError::ContextSessionMismatch { - expected: owner, - actual: attacker, - } - )) - ); - assert!(!dispatch_was_called()); - Ok(()) -} - -#[test] -fn unknown_session_or_context_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 unknown_session = BrowserSessionId::new(999)?; - let unknown_context = BrowsingContextId::new(999)?; - - reset_dispatch_marker(); - assert_eq!( - descriptor.dispatch_if_context_current( - ®istry, - target(unknown_session, context), - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(ADAPTER_VERSION), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - ), - Err(BrowserContextProtocolDispatchError::BrowserAuthority( - BrowserRegistryError::UnknownBrowserSession - )) - ); - assert!(!dispatch_was_called()); - - assert_eq!( - descriptor.dispatch_if_context_current( - ®istry, - target(session, unknown_context), - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(ADAPTER_VERSION), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - ), - Err(BrowserContextProtocolDispatchError::BrowserAuthority( - BrowserRegistryError::UnknownBrowsingContext - )) - ); - assert!(!dispatch_was_called()); - Ok(()) -} - -#[test] -fn protocol_mismatch_after_context_validation_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")?; - reset_dispatch_marker(); - - let result = descriptor.dispatch_if_context_current( - ®istry, - target(session, context), - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata("originweave-bidi-v2"), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - ); - - assert_eq!( - result, - Err(BrowserContextProtocolDispatchError::ProtocolValidation( - BrowserProtocolUseValidationError::AdapterVersionMismatch - )) - ); - assert!(!dispatch_was_called()); - Ok(()) -} - -#[test] -fn context_protocol_dispatch_errors_preserve_typed_sources() { - let authority = BrowserContextProtocolDispatchError::BrowserAuthority( - BrowserRegistryError::UnknownBrowsingContext, - ); - assert!(authority.source().is_some()); - assert_eq!( - authority.to_string(), - "browser context authority denied protocol dispatch: browsing context is not registered in this authority registry" - ); - - let protocol = BrowserContextProtocolDispatchError::ProtocolValidation( - BrowserProtocolUseValidationError::AdapterVersionMismatch, - ); - assert!(protocol.source().is_some()); - assert_eq!( - protocol.to_string(), - "browser protocol validation denied context dispatch: runtime browser adapter version does not match the pinned adapter version" - ); -} diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs index 5cf457a66..b18d22318 100644 --- a/crates/originweave-core/tests/browser_protocol_adapter.rs +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -5,39 +5,19 @@ use std::error::Error; use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, - BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, - OriginWeaveProtocolVersion, + MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; -const CURRENT_ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = - OriginWeaveProtocolVersion::new(0, 1); -const FUTURE_ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = - OriginWeaveProtocolVersion::new(0, 2); const BIDI_ADAPTER_VERSION: &str = "originweave-bidi-v1"; const BIDI_PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; const CDP_PROTOCOL_REVISION: &str = "cdp-browser-r1639810"; const BROWSER_REVISION: &str = "chromium-r1639810"; -#[test] -fn originweave_protocol_version_is_explicit_and_canonical() { - assert_eq!(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.major(), 0); - assert_eq!(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.minor(), 1); - assert_eq!( - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.to_string(), - "originweave/0.1" - ); - assert_ne!( - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, - FUTURE_ORIGINWEAVE_PROTOCOL_VERSION - ); -} - #[test] fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), Box> { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -49,10 +29,6 @@ fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), )?; assert_eq!(descriptor.kind(), BrowserProtocolKind::WebDriverBiDi); - assert_eq!( - descriptor.originweave_protocol_version(), - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION - ); assert_eq!(descriptor.adapter_version(), BIDI_ADAPTER_VERSION); assert_eq!(descriptor.protocol_revision(), BIDI_PROTOCOL_REVISION); assert_eq!(descriptor.browser_revision(), BROWSER_REVISION); @@ -68,7 +44,6 @@ fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box> { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::ChromeDevToolsProtocol, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, CDP_ADAPTER_VERSION, CDP_PROTOCOL_REVISION, BROWSER_REVISION, @@ -88,7 +63,6 @@ fn required_capability_fails_closed_without_side_effectful_fallback() -> Result< { let descriptor = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -110,33 +84,6 @@ fn required_capability_fails_closed_without_side_effectful_fallback() -> Result< Ok(()) } -#[test] -fn required_originweave_protocol_version_fails_closed() -> Result<(), Box> { - let descriptor = BrowserProtocolAdapterDescriptor::new( - BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, - BIDI_ADAPTER_VERSION, - BIDI_PROTOCOL_REVISION, - BROWSER_REVISION, - &[BrowserProtocolCapability::Navigation], - )?; - - assert_eq!( - descriptor.require_originweave_protocol_version(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION), - Ok(()) - ); - assert_eq!( - descriptor.require_originweave_protocol_version(FUTURE_ORIGINWEAVE_PROTOCOL_VERSION), - Err( - BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { - required: FUTURE_ORIGINWEAVE_PROTOCOL_VERSION, - actual: CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, - } - ) - ); - Ok(()) -} - #[test] fn malformed_or_ambiguous_metadata_fails_closed() { let valid_capabilities = [BrowserProtocolCapability::Navigation]; @@ -145,7 +92,6 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, adapter_version, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -167,7 +113,6 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, protocol_revision, BROWSER_REVISION, @@ -189,7 +134,6 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, browser_revision, @@ -203,7 +147,6 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, &oversized, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -214,7 +157,6 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, &oversized, BROWSER_REVISION, @@ -225,7 +167,6 @@ fn malformed_or_ambiguous_metadata_fails_closed() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, &oversized, @@ -240,7 +181,6 @@ fn capability_set_must_be_nonempty_and_canonical() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -252,7 +192,6 @@ fn capability_set_must_be_nonempty_and_canonical() { assert_eq!( BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -269,7 +208,6 @@ fn capability_set_must_be_nonempty_and_canonical() { fn capability_order_does_not_change_descriptor_identity() -> Result<(), Box> { let forward = BrowserProtocolAdapterDescriptor::new( BrowserProtocolKind::WebDriverBiDi, - CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, BIDI_ADAPTER_VERSION, BIDI_PROTOCOL_REVISION, BROWSER_REVISION, @@ -282,7 +220,6 @@ fn capability_order_does_not_change_descriptor_identity() -> Result<(), Box Result> { - Ok(BrowserProtocolAdapterDescriptor::new( - BrowserProtocolKind::WebDriverBiDi, - ORIGINWEAVE_PROTOCOL_VERSION, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - &[BrowserProtocolCapability::Navigation], - )?) -} - -#[test] -fn runtime_adapter_version_is_bound_into_atomic_use_validation() -> Result<(), Box> { - let descriptor = descriptor()?; - - let validated = descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - BrowserProtocolCapability::Navigation, - )?; - - assert_eq!(validated.adapter_version(), ADAPTER_VERSION); - Ok(()) -} - -#[test] -fn runtime_adapter_version_mismatch_precedes_revision_and_capability_checks() --> Result<(), Box> { - let descriptor = descriptor()?; - - let error = descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - "originweave-bidi-v2", - "runtime revision with spaces", - "browser/revision", - BrowserProtocolCapability::NetworkObservation, - ); - - assert_eq!( - error, - Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) - ); - let error = error.err().ok_or("expected adapter version mismatch")?; - assert_eq!( - error.to_string(), - "runtime browser adapter version does not match the pinned adapter version" - ); - assert!(error.source().is_none()); - Ok(()) -} - -#[test] -fn malformed_runtime_adapter_version_fails_closed_before_revision_checks() --> Result<(), Box> { - let descriptor = descriptor()?; - - let error = descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - "runtime adapter/version", - "runtime revision with spaces", - "browser/revision", - BrowserProtocolCapability::NetworkObservation, - ); - - assert_eq!( - error, - Err(BrowserProtocolUseValidationError::InvalidAdapterVersion) - ); - let error = error.err().ok_or("expected invalid adapter version")?; - assert_eq!( - error.to_string(), - "runtime browser adapter version must be a bounded ASCII metadata token" - ); - assert!(error.source().is_none()); - Ok(()) -} diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs deleted file mode 100644 index 0ca669d7d..000000000 --- a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::{cell::Cell, error::Error}; - -use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, 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<(String, BrowserProtocolCapability), &'static str>; -type DispatchFn = fn(ValidatedBrowserProtocolUse) -> 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::Navigation], - )?) -} - -fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { - BrowserProtocolRuntimeMetadata::new( - BrowserProtocolKind::WebDriverBiDi, - adapter_version, - PROTOCOL_REVISION, - BROWSER_REVISION, - ) -} - -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) -> DispatchOutcome { - DISPATCH_CALLED.with(|called| called.set(true)); - Ok(( - validated.adapter_version().to_owned(), - validated.capability(), - )) -} - -fn failing_dispatch(_: ValidatedBrowserProtocolUse) -> DispatchOutcome { - DISPATCH_CALLED.with(|called| called.set(true)); - Err("adapter-failure") -} - -#[test] -fn exact_runtime_validation_hands_single_use_proof_to_dispatch() -> Result<(), Box> { - let descriptor = descriptor()?; - reset_dispatch_marker(); - - let dispatch_result = descriptor.dispatch_if_runtime_matches( - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(ADAPTER_VERSION), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - )?; - - assert!(dispatch_was_called()); - assert_eq!( - dispatch_result, - Ok(( - ADAPTER_VERSION.to_owned(), - BrowserProtocolCapability::Navigation - )) - ); - Ok(()) -} - -#[test] -fn runtime_mismatch_prevents_dispatch_callback() -> Result<(), Box> { - let descriptor = descriptor()?; - reset_dispatch_marker(); - - let result = descriptor.dispatch_if_runtime_matches( - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata("originweave-bidi-v2"), - BrowserProtocolCapability::Navigation, - successful_dispatch as DispatchFn, - ); - - assert_eq!( - result, - Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) - ); - assert!(!dispatch_was_called()); - Ok(()) -} - -#[test] -fn adapter_callback_failure_remains_separate_after_validation() -> Result<(), Box> { - let descriptor = descriptor()?; - reset_dispatch_marker(); - - let dispatch_result = descriptor.dispatch_if_runtime_matches( - ORIGINWEAVE_PROTOCOL_VERSION, - runtime_metadata(ADAPTER_VERSION), - BrowserProtocolCapability::Navigation, - failing_dispatch as DispatchFn, - )?; - - assert!(dispatch_was_called()); - assert_eq!(dispatch_result, Err("adapter-failure")); - Ok(()) -} diff --git a/crates/originweave-core/tests/browser_protocol_runtime_revision.rs b/crates/originweave-core/tests/browser_protocol_runtime_revision.rs deleted file mode 100644 index 60a04acdb..000000000 --- a/crates/originweave-core/tests/browser_protocol_runtime_revision.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserProtocolRuntimeRequirementError, OriginWeaveProtocolVersion, -}; - -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() -> Result> { - Ok(BrowserProtocolAdapterDescriptor::new( - BrowserProtocolKind::WebDriverBiDi, - ORIGINWEAVE_PROTOCOL_VERSION, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - &[BrowserProtocolCapability::Navigation], - )?) -} - -#[test] -fn exact_runtime_revisions_are_required_before_adapter_use() -> Result<(), Box> { - let descriptor = descriptor()?; - assert_eq!( - descriptor.require_runtime_revisions(PROTOCOL_REVISION, BROWSER_REVISION), - Ok(()) - ); - Ok(()) -} - -#[test] -fn runtime_revision_drift_fails_closed() -> Result<(), Box> { - let descriptor = descriptor()?; - assert_eq!( - descriptor.require_runtime_revisions("webdriver-bidi-wd-2026-07-01", BROWSER_REVISION), - Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch) - ); - assert_eq!( - descriptor.require_runtime_revisions(PROTOCOL_REVISION, "chromium-r1639811"), - Err(BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch) - ); - assert_eq!( - descriptor.require_runtime_revisions("webdriver-bidi-wd-2026-07-01", "chromium-r1639811"), - Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch) - ); - Ok(()) -} - -#[test] -fn malformed_runtime_revision_evidence_fails_before_comparison() -> Result<(), Box> { - let descriptor = descriptor()?; - assert_eq!( - descriptor.require_runtime_revisions("webdriver bidi current", BROWSER_REVISION), - Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision) - ); - assert_eq!( - descriptor.require_runtime_revisions(PROTOCOL_REVISION, "chromium/current"), - Err(BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision) - ); - assert_eq!( - descriptor.require_runtime_revisions("", ""), - Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision) - ); - Ok(()) -} - -#[test] -fn runtime_requirement_errors_are_stable_and_source_free() { - let cases = [ - ( - BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision, - "runtime browser protocol revision must be a bounded ASCII metadata token", - ), - ( - BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision, - "runtime browser revision must be a bounded ASCII metadata token", - ), - ( - BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, - "runtime browser protocol revision does not match the pinned adapter revision", - ), - ( - BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch, - "runtime browser revision does not match the pinned adapter browser revision", - ), - ]; - - for (error, expected) in cases { - assert_eq!(error.to_string(), expected); - assert!(error.source().is_none()); - } -} diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs deleted file mode 100644 index 31a15238c..000000000 --- a/crates/originweave-core/tests/browser_protocol_use_validation.rs +++ /dev/null @@ -1,190 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, - BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, - BrowserProtocolRuntimeRequirementError, BrowserProtocolUseValidationError, - BrowserProtocolVersionRequirementError, OriginWeaveProtocolVersion, -}; - -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() -> Result> { - Ok(BrowserProtocolAdapterDescriptor::new( - BrowserProtocolKind::WebDriverBiDi, - ORIGINWEAVE_PROTOCOL_VERSION, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - &[ - BrowserProtocolCapability::Navigation, - BrowserProtocolCapability::TypedInput, - ], - )?) -} - -#[test] -fn validated_use_binds_all_required_adapter_metadata() -> Result<(), Box> { - let descriptor = descriptor()?; - let validated = descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - BrowserProtocolCapability::Navigation, - )?; - - assert_eq!(validated.kind(), BrowserProtocolKind::WebDriverBiDi); - assert_eq!( - validated.originweave_protocol_version(), - ORIGINWEAVE_PROTOCOL_VERSION - ); - assert_eq!(validated.adapter_version(), ADAPTER_VERSION); - assert_eq!(validated.protocol_revision(), PROTOCOL_REVISION); - assert_eq!(validated.browser_revision(), BROWSER_REVISION); - assert_eq!( - validated.capability(), - BrowserProtocolCapability::Navigation - ); - Ok(()) -} - -#[test] -fn protocol_generation_mismatch_precedes_runtime_and_capability_checks() --> Result<(), Box> { - let descriptor = descriptor()?; - let wrong_generation = OriginWeaveProtocolVersion::new(0, 2); - - assert_eq!( - descriptor.validate_use( - wrong_generation, - BrowserProtocolKind::ChromeDevToolsProtocol, - "runtime adapter/version", - "runtime revision with spaces", - "browser/revision", - BrowserProtocolCapability::NetworkObservation, - ), - Err(BrowserProtocolUseValidationError::ProtocolVersion( - BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { - required: wrong_generation, - actual: ORIGINWEAVE_PROTOCOL_VERSION, - } - )) - ); - Ok(()) -} - -#[test] -fn runtime_protocol_kind_mismatch_precedes_adapter_revision_and_capability_checks() --> Result<(), Box> { - let descriptor = descriptor()?; - - let error = descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::ChromeDevToolsProtocol, - "runtime adapter/version", - "runtime revision with spaces", - "browser/revision", - BrowserProtocolCapability::NetworkObservation, - ); - - assert_eq!( - error, - Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { - descriptor_kind: BrowserProtocolKind::WebDriverBiDi, - runtime_kind: BrowserProtocolKind::ChromeDevToolsProtocol, - }) - ); - let error = error.err().ok_or("expected protocol kind mismatch")?; - assert_eq!( - error.to_string(), - "runtime browser protocol kind does not match the pinned adapter kind" - ); - assert!(error.source().is_none()); - Ok(()) -} - -#[test] -fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box> { - let descriptor = descriptor()?; - - assert_eq!( - descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - ADAPTER_VERSION, - "webdriver-bidi-wd-2026-07-01", - BROWSER_REVISION, - BrowserProtocolCapability::NetworkObservation, - ), - Err(BrowserProtocolUseValidationError::RuntimeRevision( - BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, - )) - ); - Ok(()) -} - -#[test] -fn undeclared_capability_cannot_produce_validated_use() -> Result<(), Box> { - let descriptor = descriptor()?; - - assert_eq!( - descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - BrowserProtocolCapability::NetworkObservation, - ), - Err(BrowserProtocolUseValidationError::Capability( - BrowserProtocolCapabilityRequirementError::UnsupportedCapability( - BrowserProtocolCapability::NetworkObservation, - ), - )) - ); - Ok(()) -} - -#[test] -fn validation_errors_preserve_stable_typed_sources() { - let wrong_generation = OriginWeaveProtocolVersion::new(0, 2); - let cases = [ - ( - BrowserProtocolUseValidationError::ProtocolVersion( - BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { - required: wrong_generation, - actual: ORIGINWEAVE_PROTOCOL_VERSION, - }, - ), - "browser protocol adapter targets originweave/0.1 but originweave/0.2 is required", - ), - ( - BrowserProtocolUseValidationError::RuntimeRevision( - BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, - ), - "runtime browser protocol revision does not match the pinned adapter revision", - ), - ( - BrowserProtocolUseValidationError::Capability( - BrowserProtocolCapabilityRequirementError::UnsupportedCapability( - BrowserProtocolCapability::NetworkObservation, - ), - ), - "browser protocol adapter does not declare required network-observation capability", - ), - ]; - - for (error, expected) in cases { - assert_eq!(error.to_string(), expected); - assert_eq!( - error.source().map(ToString::to_string).as_deref(), - Some(expected) - ); - } -} diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs new file mode 100644 index 000000000..c38fcf227 --- /dev/null +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -0,0 +1,88 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; + +#[test] +fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() +-> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43127")?; + + let mut first_registry = BrowserAuthorityRegistry::new(); + let first_session = first_registry.register_session("first-session")?; + let first_context = first_registry.register_context(first_session, "first-context")?; + let first_handle = + first_registry.bind_node(first_session, first_context, &origin, "first-node")?; + + let mut second_registry = BrowserAuthorityRegistry::new(); + let second_session = second_registry.register_session("second-session")?; + let second_context = second_registry.register_context(second_session, "second-context")?; + let second_handle = + second_registry.bind_node(second_session, second_context, &origin, "second-node")?; + let forged_matching = ObservedNodeHandle::new( + second_session, + second_context, + origin.clone(), + second_handle.document_epoch(), + second_handle.node_id(), + )?; + + assert_eq!(first_session, second_session); + assert_eq!(first_context, second_context); + assert_eq!(first_handle.node_id(), second_handle.node_id()); + assert_ne!(first_handle, second_handle); + assert_eq!( + second_registry.validate_node_handle(&first_handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + second_registry.validate_node_handle(&forged_matching), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); + Ok(()) +} + +#[test] +fn unissued_handles_do_not_reveal_registered_session_membership() -> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43128")?; + + let mut issuing_registry = BrowserAuthorityRegistry::new(); + let known_numeric_session = issuing_registry.register_session("issuing-session")?; + let unknown_numeric_session = issuing_registry.register_session("issuing-extra-session")?; + + let mut target_registry = BrowserAuthorityRegistry::new(); + let target_session = target_registry.register_session("target-session")?; + let target_context = target_registry.register_context(target_session, "target-context")?; + let target_handle = + target_registry.bind_node(target_session, target_context, &origin, "target-node")?; + + assert_eq!(known_numeric_session, target_session); + assert_ne!(unknown_numeric_session, target_session); + + let forged_known_session = ObservedNodeHandle::new( + known_numeric_session, + target_context, + origin.clone(), + target_handle.document_epoch(), + target_handle.node_id(), + )?; + let forged_unknown_session = ObservedNodeHandle::new( + unknown_numeric_session, + target_context, + origin, + target_handle.document_epoch(), + target_handle.node_id(), + )?; + + assert_eq!( + target_registry.validate_node_handle(&forged_known_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + target_registry.validate_node_handle(&forged_unknown_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs deleted file mode 100644 index 6223b067d..000000000 --- a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs +++ /dev/null @@ -1,150 +0,0 @@ -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 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] -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::TypeText, - |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { - (operation, validated.capability(), epoch.value()) - }, - )?; - - assert_eq!( - result, - ( - BrowserProtocolOperation::TypeText, - 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::ClickNode, - |_validated, _operation, _epoch| dispatch_called.set(true), - ); - - assert!(matches!( - result, - Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) - )); - assert!(!dispatch_called.get()); - Ok(()) -} diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs new file mode 100644 index 000000000..9d3681673 --- /dev/null +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -0,0 +1,221 @@ +use std::error::Error; + +use originweave_core::mcp::{ + MAX_MCP_METHOD_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, + McpResultType, McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, + supported_mcp_tools, +}; + +#[test] +fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { + let page = mcp_tools_list_page(); + + assert_eq!(page.result_type(), McpResultType::Complete); + assert_eq!(page.tools(), supported_mcp_tools()); + assert_eq!(page.ttl_ms(), 0); + assert_eq!(page.cache_scope(), McpCacheScope::Private); + assert_eq!(page.next_cursor(), None); +} + +fn valid_tools_list_request( + cursor: Option<&str>, +) -> Result { + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + cursor, + ) +} + +#[test] +fn mcp_tools_list_request_requires_complete_request_metadata() { + assert_eq!( + valid_tools_list_request(None).map(|validated| validated.method()), + Ok(MCP_TOOLS_LIST_METHOD) + ); + + assert_eq!( + ValidatedMcpToolsListRequest::new( + None, + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionHeader) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + None, + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionMetadata) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some("2025-11-25"), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + false, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingClientCapabilities) + ); +} + +#[test] +fn mcp_tools_list_bounds_protocol_metadata_before_cross_field_comparison() { + let oversized_protocol_version = format!("{MCP_PROTOCOL_VERSION}0"); + + for (header, metadata) in [ + (oversized_protocol_version.as_str(), MCP_PROTOCOL_VERSION), + (MCP_PROTOCOL_VERSION, oversized_protocol_version.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(header), + Some(metadata), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + } +} + +#[test] +fn mcp_tools_list_validates_each_method_before_cross_field_comparison() { + let oversized_method = "a".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + for (routing_method, body_method) in [ + ("tools list", MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, "tools list"), + (oversized_method.as_str(), MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, oversized_method.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + routing_method, + body_method, + None, + ), + Err(McpToolsListBoundaryError::InvalidMethod) + ); + } +} + +#[test] +fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + "tools/call", + None, + ), + Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + "resources/list", + "resources/list", + None, + ), + Err(McpToolsListBoundaryError::UnsupportedMethod) + ); + + for cursor in ["cursor-1", ""] { + assert_eq!( + valid_tools_list_request(Some(cursor)), + Err(McpToolsListBoundaryError::UnsupportedCursor) + ); + } +} + +#[test] +fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { + let cases = [ + ( + McpToolsListBoundaryError::MissingProtocolVersionHeader, + "MCP protocol version header is required", + ), + ( + McpToolsListBoundaryError::MissingProtocolVersionMetadata, + "MCP request metadata protocol version is required", + ), + ( + McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch, + "MCP protocol version header does not match request metadata", + ), + ( + McpToolsListBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolsListBoundaryError::MissingClientCapabilities, + "MCP request metadata client capabilities are required", + ), + ( + McpToolsListBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), + ( + McpToolsListBoundaryError::MethodHeaderBodyMismatch, + "MCP method header does not match the request body", + ), + ( + McpToolsListBoundaryError::UnsupportedMethod, + "only MCP tools/list requests can enter the discovery boundary", + ), + ( + McpToolsListBoundaryError::UnsupportedCursor, + "MCP tools/list cursor was not issued by this fixed catalog", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/node_retirement.rs b/crates/originweave-core/tests/node_retirement.rs new file mode 100644 index 000000000..56c4d2132 --- /dev/null +++ b/crates/originweave-core/tests/node_retirement.rs @@ -0,0 +1,39 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; + +#[test] +fn same_document_node_retirement_revokes_authority_without_reusing_identity() +-> Result<(), Box> { + 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 live = registry.bind_node(session, context, &origin, "backend-node-17")?; + + let different_observation = ObservedNodeHandle::new( + session, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_ne!(live, different_observation); + + registry.remove_node(&live)?; + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + registry.remove_node(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_ne!(live.node_id(), rebound.node_id()); + assert_eq!(registry.validate_node_handle(&rebound), Ok(())); + Ok(()) +} diff --git a/crates/originweave-core/tests/origin_port_syntax.rs b/crates/originweave-core/tests/origin_port_syntax.rs new file mode 100644 index 000000000..ce58e523e --- /dev/null +++ b/crates/originweave-core/tests/origin_port_syntax.rs @@ -0,0 +1,18 @@ +use originweave_core::{Origin, OriginError}; + +#[test] +fn origin_rejects_non_digit_port_prefixes() { + for input in [ + "https://example.com:+443", + "https://example.com:+8443", + "http://localhost:+80", + "http://127.0.0.1:+8080", + "https://[2001:db8::1]:+443", + ] { + assert_eq!( + Origin::parse(input), + Err(OriginError::InvalidPort), + "input={input}" + ); + } +} diff --git a/crates/originweave-core/tests/protocol_version_parsing.rs b/crates/originweave-core/tests/protocol_version_parsing.rs deleted file mode 100644 index 189bde6be..000000000 --- a/crates/originweave-core/tests/protocol_version_parsing.rs +++ /dev/null @@ -1,59 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::error::Error; -use std::str::FromStr; - -use originweave_core::{OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError}; - -#[test] -fn canonical_protocol_versions_parse_and_round_trip() -> Result<(), Box> { - let current = OriginWeaveProtocolVersion::from_str("originweave/0.1")?; - assert_eq!(current, OriginWeaveProtocolVersion::new(0, 1)); - assert_eq!(current.to_string(), "originweave/0.1"); - - let maximum = OriginWeaveProtocolVersion::from_str("originweave/65535.65535")?; - assert_eq!(maximum, OriginWeaveProtocolVersion::new(u16::MAX, u16::MAX)); - assert_eq!(maximum.to_string(), "originweave/65535.65535"); - Ok(()) -} - -#[test] -fn malformed_or_noncanonical_protocol_versions_fail_closed() { - let malformed = [ - "", - "originweave/", - "originweave/0", - "originweave/0.", - "originweave/.1", - "originweave/0.1.0", - "OriginWeave/0.1", - "originweave/00.1", - "originweave/0.01", - "originweave/+0.1", - "originweave/0.+1", - "originweave/-0.1", - "originweave/0.-1", - "originweave/65536.1", - "originweave/0.65536", - " originweave/0.1", - "originweave/0.1 ", - "originweave/0.1", - ]; - - for value in malformed { - assert_eq!( - OriginWeaveProtocolVersion::from_str(value), - Err(OriginWeaveProtocolVersionParseError::InvalidFormat) - ); - } -} - -#[test] -fn protocol_version_parse_error_is_stable_and_source_free() { - let error = OriginWeaveProtocolVersionParseError::InvalidFormat; - assert_eq!( - error.to_string(), - "OriginWeave protocol version must use canonical originweave/. syntax" - ); - assert!(error.source().is_none()); -} diff --git a/crates/originweave-core/tests/protocol_version_runtime_coverage.rs b/crates/originweave-core/tests/protocol_version_runtime_coverage.rs deleted file mode 100644 index aeca15dec..000000000 --- a/crates/originweave-core/tests/protocol_version_runtime_coverage.rs +++ /dev/null @@ -1,12 +0,0 @@ -use originweave_core::OriginWeaveProtocolVersion; - -#[test] -fn protocol_version_can_be_constructed_from_runtime_values() { - let major = std::hint::black_box(0_u16); - let minor = std::hint::black_box(1_u16); - let version = OriginWeaveProtocolVersion::new(major, minor); - - assert_eq!(version.major(), 0); - assert_eq!(version.minor(), 1); - assert_eq!(version.to_string(), "originweave/0.1"); -} diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs new file mode 100644 index 000000000..3e37fab18 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -0,0 +1,397 @@ +use originweave_core::release_acceptance::{ + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, + ReleaseDecision, ReleaseDecisionError, decide_release, +}; + +fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { + BenchmarkSuite::ALL + .into_iter() + .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) + .collect() +} + +fn declared_limitation() -> Result { + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is not included in the declared release support profile.", + ) +} + +#[test] +fn generic_constructor_input_shapes_cover_success_paths_in_this_test_crate() { + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn complete_passing_evidence_is_accepted_without_declared_limitations() +-> Result<(), ReleaseDecisionError> { + let report = decide_release(passing_results(), &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Accepted); + assert!(report.failed_suites().is_empty()); + assert!(report.inconclusive_suites().is_empty()); + assert!(report.missing_suites().is_empty()); + assert!(report.declared_limitations().is_empty()); + Ok(()) +} + +#[test] +fn complete_passing_evidence_preserves_declared_limitation_details() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + let report = decide_release(passing_results(), std::slice::from_ref(&limitation))?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!(report.declared_limitations(), &[limitation]); + Ok(()) +} + +#[test] +fn limitation_requires_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new( + " ", + "A buyer-visible consequence must not stand without the narrowed claim.", + ), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); +} + +#[test] +fn limitation_requires_a_buyer_visible_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "\t\n"), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_control_characters_in_release_metadata() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64\nforged_release_claim", + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is unsupported.\rforged_release_consequence" + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_ambiguous_unicode_formatting_characters() { + for character in [ + '\u{00ad}', '\u{061c}', '\u{180e}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', + '\u{2060}', '\u{2066}', '\u{206f}', '\u{feff}', + ] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence") + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + } +} + +#[test] +fn limitation_preserves_unambiguous_international_buyer_text() -> Result<(), ReleaseDecisionError> { + let limitation = DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + )?; + + assert_eq!(limitation.unsupported_claim(), "한국어_운영환경"); + assert_eq!( + limitation.buyer_consequence(), + "이 운영환경은 현재 지원 범위에 포함되지 않습니다." + ); + Ok(()) +} + +#[test] +fn limitation_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::EmptyLimitationClaim, + "declared release limitation must name an unsupported claim", + ), + ( + ReleaseDecisionError::InvalidLimitationClaim, + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::EmptyLimitationConsequence, + "declared release limitation must state a buyer-visible consequence", + ), + ( + ReleaseDecisionError::InvalidLimitationConsequence, + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::DuplicateLimitationClaim, + "benchmark release decision contains duplicate limitation claim", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn limitation_exposes_the_exact_narrowed_claim_and_consequence() -> Result<(), ReleaseDecisionError> +{ + let limitation = declared_limitation()?; + + assert_eq!(limitation.unsupported_claim(), "linux_arm64"); + assert_eq!( + limitation.buyer_consequence(), + "Linux ARM64 is not included in the declared release support profile." + ); + Ok(()) +} + +#[test] +fn every_mandatory_suite_is_required_for_acceptance() -> Result<(), ReleaseDecisionError> { + for omitted_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .filter(|(suite, _)| *suite != omitted_suite) + .collect::>(); + + let report = decide_release(evidence, &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.missing_suites(), &[omitted_suite]); + assert!(report.failed_suites().is_empty()); + } + Ok(()) +} + +#[test] +fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() +-> Result<(), ReleaseDecisionError> { + for inconclusive_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == inconclusive_suite { + (suite, BenchmarkSuiteOutcome::Inconclusive) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() +-> Result<(), ReleaseDecisionError> { + for failed_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == failed_suite { + (suite, BenchmarkSuiteOutcome::Failed) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!(report.failed_suites(), &[failed_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn known_failure_remains_rejected_when_other_evidence_is_incomplete() +-> Result<(), ReleaseDecisionError> { + let report = decide_release( + vec![ + ( + BenchmarkSuite::ControlledDeterministic, + BenchmarkSuiteOutcome::Failed, + ), + ( + BenchmarkSuite::WebCompatibility, + BenchmarkSuiteOutcome::Inconclusive, + ), + ], + &[], + )?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!( + report.failed_suites(), + &[BenchmarkSuite::ControlledDeterministic] + ); + assert_eq!( + report.inconclusive_suites(), + &[BenchmarkSuite::WebCompatibility] + ); + assert_eq!( + report.missing_suites(), + &[ + BenchmarkSuite::SecurityAdversarial, + BenchmarkSuite::ReliabilityRecovery, + BenchmarkSuite::EnterpriseOperability, + ] + ); + Ok(()) +} + +#[test] +fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { + for duplicate_suite in BenchmarkSuite::ALL { + let expected_error = ReleaseDecisionError::DuplicateSuite(duplicate_suite); + assert_eq!( + decide_release( + vec![ + (duplicate_suite, BenchmarkSuiteOutcome::Passed), + (duplicate_suite, BenchmarkSuiteOutcome::Failed), + ], + &[], + ), + Err(expected_error) + ); + + assert_eq!( + expected_error.to_string(), + format!( + "benchmark release evidence contains duplicate suite: {}", + duplicate_suite.as_str() + ) + ); + let standard_error: &dyn std::error::Error = &expected_error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn duplicate_suite_evidence_in_vector_input_also_fails_closed() { + let duplicate_suite = BenchmarkSuite::ControlledDeterministic; + let mut evidence = passing_results(); + evidence.push((duplicate_suite, BenchmarkSuiteOutcome::Failed)); + + assert_eq!( + decide_release(evidence, &[]), + Err(ReleaseDecisionError::DuplicateSuite(duplicate_suite)) + ); +} + +#[test] +fn decision_is_independent_of_evidence_input_order() { + let mut reversed = passing_results(); + reversed.reverse(); + + assert_eq!( + decide_release(reversed, &[]), + decide_release(passing_results(), &[]) + ); +} + +#[test] +fn conflicting_consequences_for_one_limitation_claim_fail_closed() +-> Result<(), ReleaseDecisionError> { + let first = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + )?; + let conflicting = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is supported only for evaluation deployments.", + )?; + + assert_eq!( + decide_release(passing_results(), &[first, conflicting]), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn duplicate_limitation_claim_fails_closed_even_when_consequence_matches() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + + assert_eq!( + decide_release(passing_results(), &[limitation.clone(), limitation],), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn release_report_bounds_declared_limitation_count_before_cloning() +-> Result<(), ReleaseDecisionError> { + let maximum = (0..MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + let report = decide_release(passing_results(), &maximum)?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!( + report.declared_limitations().len(), + MAX_DECLARED_RELEASE_LIMITATIONS + ); + + let too_many = (0..=MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + assert_eq!( + decide_release(passing_results(), &too_many), + Err(ReleaseDecisionError::TooManyDeclaredLimitations) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs new file mode 100644 index 000000000..2d7840af3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -0,0 +1,116 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn limitation_accepts_canonical_boundary_text() { + let limitation = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + + assert_eq!( + limitation + .as_ref() + .map(|value| (value.unsupported_claim(), value.buyer_consequence())), + Ok(( + "linux_arm64", + "Linux ARM64 is excluded from the support profile." + )) + ); +} + +#[test] +fn limitation_rejects_empty_fields_for_the_canonical_string_input_shape() { + assert_eq!( + DeclaredLimitation::new("", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); +} + +#[test] +fn limitation_rejects_surrounding_whitespace_that_changes_claim_identity() { + for unsupported_claim in [" linux_arm64", "linux_arm64 ", "\tlinux_arm64"] { + assert_eq!( + DeclaredLimitation::new( + unsupported_claim, + "Linux ARM64 is excluded from the support profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "surrounding whitespace must not create a second spelling for one claim identity: {unsupported_claim:?}", + ); + } +} + +#[test] +fn limitation_rejects_surrounding_whitespace_in_buyer_consequence() { + for buyer_consequence in [ + " Linux ARM64 is excluded from the support profile.", + "Linux ARM64 is excluded from the support profile. ", + "Linux ARM64 is excluded from the support profile.\t", + ] { + assert_eq!( + DeclaredLimitation::new("linux_arm64", buyer_consequence), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequence must have one canonical boundary spelling: {buyer_consequence:?}", + ); + } +} + +#[test] +fn limitation_rejects_non_nfc_claim_identity() { + let nfc_claim = "caf\u{e9}"; + let canonically_equivalent_nfd_claim = "cafe\u{301}"; + + assert!( + DeclaredLimitation::new( + nfc_claim, + "This normalized claim remains a supported buyer-visible spelling.", + ) + .is_ok(), + "NFC international text must remain admissible", + ); + assert_eq!( + DeclaredLimitation::new( + canonically_equivalent_nfd_claim, + "This decomposed spelling must not create a second claim identity.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "canonically equivalent NFD text must not bypass limitation identity", + ); +} + +#[test] +fn limitation_rejects_non_nfc_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequences must use one canonical Unicode spelling", + ); +} + +#[test] +fn invalid_canonical_text_errors_describe_all_rejected_causes() { + let claim_result = DeclaredLimitation::new( + " linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + assert_eq!( + claim_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation claim is not canonical or contains an unsafe presentation character".to_owned()) + ); + + let consequence_result = DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ); + assert_eq!( + consequence_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation consequence is not canonical or contains an unsafe presentation character".to_owned()) + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs new file mode 100644 index 000000000..0dfb20ba3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs @@ -0,0 +1,46 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn punctuation_only_limitation_claim_does_not_name_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new("---", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); +} + +#[test] +fn punctuation_only_limitation_consequence_does_not_state_a_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "..."), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn meaningful_text_may_begin_with_allowed_punctuation() { + assert!( + DeclaredLimitation::new( + "-linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ) + .is_ok() + ); + assert!( + DeclaredLimitation::new( + "linux_arm64", + "... Linux ARM64 remains outside the support profile.", + ) + .is_ok() + ); +} + +#[test] +fn international_alphanumeric_limitation_text_remains_admissible() { + assert!( + DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + ) + .is_ok() + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs new file mode 100644 index 000000000..fd45e0e6d --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -0,0 +1,98 @@ +use originweave_core::release_acceptance::{ + DeclaredLimitation, MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecisionError, +}; + +#[test] +fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { + let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let limitation = DeclaredLimitation::new(maximum_claim.as_str(), maximum_consequence.as_str())?; + + assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); + assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); + + let oversized_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new(oversized_claim.as_str(), "bounded buyer consequence"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); + + let oversized_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new("bounded_claim", oversized_consequence.as_str()), + Err(ReleaseDecisionError::LimitationConsequenceTooLong) + ); + Ok(()) +} + +#[test] +fn borrowed_limitation_text_covers_every_validation_exit() { + assert_eq!( + DeclaredLimitation::new("", "bounded buyer consequence"), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new(" bounded_claim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("cafe\u{301}", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "bounded buyer consequence "), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "cafe\u{301} buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "forged\nconsequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_byte_budget_applies_to_international_text() { + let korean_character = "가"; + let repeated = + korean_character.repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1); + assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); + assert_eq!( + DeclaredLimitation::new(repeated.as_str(), "지원 범위를 설명하는 구매자 안내"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); +} + +#[test] +fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::LimitationClaimTooLong, + "declared release limitation claim exceeds the byte budget", + ), + ( + ReleaseDecisionError::LimitationConsequenceTooLong, + "declared release limitation consequence exceeds the byte budget", + ), + ( + ReleaseDecisionError::TooManyDeclaredLimitations, + "benchmark release decision contains too many declared limitations", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs new file mode 100644 index 000000000..eccd90e89 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -0,0 +1,121 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +const UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT: usize = 4_174; + +#[test] +fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { + assert_eq!( + DeclaredLimitation::new(String::new(), "Linux ARM64 is unsupported."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", String::new()), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { + // Unicode 17.0.0 DerivedCoreProperties.txt (2025-07-30), + // Default_Ignorable_Code_Point. The reviewed ranges contain exactly 4,174 code points. + let ranges = [ + (0x00ad_u32, 0x00ad_u32), + (0x034f, 0x034f), + (0x061c, 0x061c), + (0x115f, 0x1160), + (0x17b4, 0x17b5), + (0x180b, 0x180f), + (0x200b, 0x200f), + (0x202a, 0x202e), + (0x2060, 0x206f), + (0x3164, 0x3164), + (0xfe00, 0xfe0f), + (0xfeff, 0xfeff), + (0xffa0, 0xffa0), + (0xfff0, 0xfff8), + (0x1bca0, 0x1bca3), + (0x1d173, 0x1d17a), + (0xe0000, 0xe0fff), + ]; + let mut tested_code_points = 0_usize; + + for (start, end) in ranges { + for code_point in start..=end { + let character = char::from_u32(code_point) + .ok_or("reviewed Unicode 17 default-ignorable range must contain scalar values")?; + tested_code_points += 1; + + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "U+{code_point:04X} must be rejected in the unsupported claim", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "U+{code_point:04X} must be rejected in the buyer consequence", + ); + } + } + + assert_eq!( + tested_code_points, UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT, + "reviewed Unicode 17 Default_Ignorable_Code_Point ranges must match the authoritative cardinality", + ); + Ok(()) +} + +#[test] +fn limitation_rejects_line_and_paragraph_separators_beyond_default_ignorable_set() { + for (name, separator) in [("U+2028", '\u{2028}'), ("U+2029", '\u{2029}')] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{separator}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "{name} must be rejected in the unsupported claim to prevent line-forging ambiguity", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{separator}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "{name} must be rejected in the buyer consequence to prevent line-forging ambiguity", + ); + } +} + +#[test] +fn limitation_does_not_blanket_reject_unicode_17_whitespace() -> Result<(), ReleaseDecisionError> { + let medium_mathematical_space = '\u{205f}'; + let ideographic_space = '\u{3000}'; + + let limitation = DeclaredLimitation::new( + format!("east{ideographic_space}asia"), + format!("Support is limited{medium_mathematical_space}to the declared profile."), + )?; + + assert_eq!( + limitation.unsupported_claim(), + format!("east{ideographic_space}asia") + ); + assert_eq!( + limitation.buyer_consequence(), + format!("Support is limited{medium_mathematical_space}to the declared profile.") + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs deleted file mode 100644 index 360e8d4f1..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ /dev/null @@ -1,195 +0,0 @@ -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, 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] -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 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)?; - 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_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_locator_text_rejects_unicode_format_and_bidi_overrides() { - 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] -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); - 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_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 = [ - WebDriverBiDiAccessibilityQueryError::MissingLocatorValue, - WebDriverBiDiAccessibilityQueryError::EmptyRole, - WebDriverBiDiAccessibilityQueryError::RoleTooLong, - WebDriverBiDiAccessibilityQueryError::EmptyName, - WebDriverBiDiAccessibilityQueryError::InvalidRole, - WebDriverBiDiAccessibilityQueryError::InvalidName, - WebDriverBiDiAccessibilityQueryError::NameTooLong, - WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, - WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, - ]; - - for error in errors { - assert!(error.source().is_none()); - assert!(!error.to_string().is_empty()); - } -} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs deleted file mode 100644 index 80add05b6..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs +++ /dev/null @@ -1,289 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::error::Error; - -use originweave_core::{ - BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - 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") -} - -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 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> -{ - 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( - semantic_observation_proof()?, - &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( - semantic_observation_proof()?, - &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( - semantic_observation_proof()?, - &mut registry, - stale_target, - &[("node", Some("shared-submit"))], - ), - Err( - WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { - expected: stale_target.expected_epoch(), - current: current_epoch, - } - ) - ); - Ok(()) -} - -#[test] -fn exhausted_node_identifier_space_fails_after_remote_value_admission() -> 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( - semantic_observation_proof()?, - &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(semantic_observation_proof()?, &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( - semantic_observation_proof()?, - &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(); - 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( - semantic_observation_proof()?, - &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, - ), - WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( - BrowserProtocolCapability::TypedInput, - ), - WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( - BrowserProtocolCapability::Navigation, - ), - WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( - BrowserProtocolCapability::SemanticObservation, - ), - WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( - BrowserProtocolCapability::NetworkObservation, - ), - ]; - - 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()); - 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")); -} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs deleted file mode 100644 index 974ea4d3f..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs +++ /dev/null @@ -1,88 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::error::Error; -use std::io; - -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").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)?; - 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(()) -} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs deleted file mode 100644 index d0195649b..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs +++ /dev/null @@ -1,121 +0,0 @@ -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()); - } -} 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 deleted file mode 100644 index 91f7fc143..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - MAX_WEBDRIVER_BIDI_COMMAND_ID, 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 locate_nodes_response_requires_exact_command_id() -> Result<(), Box> { - 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"); - Ok(()) -} - -#[test] -fn locate_nodes_response_rejects_mismatched_command_id() -> 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 locate_nodes_response_rejects_out_of_range_id_before_correlation() -> Result<(), Box> -{ - let error = locate_nodes_command(1)?.correlate_response_envelope( - WebDriverBiDiCommandResponseKind::Success, - Some(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1), - ); - - assert_eq!( - error, - Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( - 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()); - } -} 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 deleted file mode 100644 index 811def1f3..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs +++ /dev/null @@ -1,100 +0,0 @@ -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()); -} 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 deleted file mode 100644 index 4425be6ee..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, - 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 correlated_success_can_be_consumed_as_success_evidence() -> 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 correlated_success_enforces_exact_serialized_result_budget() -> 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)? - .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 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)? - .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, - WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse, - ]; - 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()); -} 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 deleted file mode 100644 index 5c70cdc7a..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs +++ /dev/null @@ -1,297 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - 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> { - 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()?, - ) -} - -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 controlled_origin() -> Result> { - Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) -} - -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> { - 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()); -} - -#[test] -fn correlated_result_binds_only_to_its_exact_registered_context() -> Result<(), Box> { - let mut registry = BrowserAuthorityRegistry::new(); - 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")), - ("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 = controlled_origin()?; - 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 = 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::ChromeDevToolsProtocol, - BrowserProtocolCapability::SemanticObservation, - )?, - &mut registry, - target, - ), - Err( - WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( - BrowserProtocolKind::ChromeDevToolsProtocol, - ) - ) - ); - Ok(()) -} - -#[test] -fn correlated_result_rejects_non_observation_protocol_proof() -> Result<(), Box> { - let mut registry = BrowserAuthorityRegistry::new(); - 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::WebDriverBiDi, - BrowserProtocolCapability::TypedInput, - )?, - &mut registry, - target, - ), - Err( - WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( - BrowserProtocolCapability::TypedInput, - ) - ) - ); - 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(); - 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)?; - 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 = controlled_origin()?; - 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(()) -} 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 deleted file mode 100644 index aba667210..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, -}; - -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":{"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)?; - - 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_checks_node_budget_before_parsing_overflow_items() -> 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( - 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(()) -} - -#[test] -fn wire_result_boundary_preserves_parse_correlation_and_protocol_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_eq!( - locate_nodes_command(42, 1)?.admit_response_document_nodes(error_response), - Err( - WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( - WebDriverBiDiErrorCode::InvalidArgument, - ) - ) - ); - Ok(()) -} - -#[test] -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, - 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); - let error = match result { - Err(error @ WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) => error, - _ => { - return Err( - "over-budget wire result must preserve result-admission error evidence".into(), - ); - } - }; - assert!(!error.to_string().is_empty()); - assert!(error.source().is_some()); - Ok(()) -} diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs deleted file mode 100644 index a138cab4e..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, - WebDriverBiDiLocateNodesResponseEnvelopeError, -}; - -#[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(()) -} - -#[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(()) -} diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs deleted file mode 100644 index 8c764060d..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs +++ /dev/null @@ -1,75 +0,0 @@ -#![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)?; - - let error = query - .bind_current_nodes( - cdp_semantic_observation_proof()?, - &mut registry, - target, - &[("node", Some("shared-task-text"))], - ) - .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(()) -} diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs deleted file mode 100644 index 69f407aa2..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs +++ /dev/null @@ -1,365 +0,0 @@ -#![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, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, - ValidatedBrowserProtocolUse, 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(()) -} - -fn protocol_use_proof( - descriptor: &BrowserProtocolAdapterDescriptor, - capability: BrowserProtocolCapability, -) -> 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> { - 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()); - } -} diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs deleted file mode 100644 index d5f25bfbc..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, - 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_rejects_unicode_format_and_bidi_overrides() { - 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] -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); - 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()); - } -} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs deleted file mode 100644 index 8d440272e..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs +++ /dev/null @@ -1,98 +0,0 @@ -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 bounded_response_document_admits_transport_bytes_without_preallocating_untrusted_text() --> 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"] { - assert_eq!( - BoundedWebDriverBiDiResponseDocument::new(raw), - Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument) - ); - } -} - -#[test] -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), - Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) - ); - } - - 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> { - 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::InvalidUtf8, - WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary, - ] { - assert!(!error.to_string().is_empty()); - assert!(error.source().is_none()); - } -} 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 deleted file mode 100644 index 692e7ea68..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, - WebDriverBiDiResponseDocumentAdmissionError, 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 non_object_response_stops_at_document_admission_before_parser() { - assert!(matches!( - BoundedWebDriverBiDiResponseDocument::new("[]"), - Err(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}"#, - ] { - assert_invalid_json(raw)?; - } - Ok(()) -} - -#[test] -fn parser_rejects_malformed_escape_and_unicode_code_units() -> Result<(), Box> { - for raw in [ - 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)?; - } - 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(()) -} 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 deleted file mode 100644 index 5b56399be..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs +++ /dev/null @@ -1,107 +0,0 @@ -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(()) -} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs deleted file mode 100644 index d15a525b0..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs +++ /dev/null @@ -1,254 +0,0 @@ -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) {} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs deleted file mode 100644 index 92b5c6611..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs +++ /dev/null @@ -1,130 +0,0 @@ -use std::error::Error; - -use originweave_core::{BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode}; - -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_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\":\"{raw_code}\",\"message\":\"browser rejected command\"}}" - ); - 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(()) -} - -#[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\"}", - )?; - - let error = match document.parse_command_response() { - Ok(_) => { - 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(()) -} 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 deleted file mode 100644 index 88bed1649..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs +++ /dev/null @@ -1,35 +0,0 @@ -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(()) -} diff --git a/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs b/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs deleted file mode 100644 index dd20a4ef1..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs +++ /dev/null @@ -1,107 +0,0 @@ -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()); -} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs deleted file mode 100644 index 85f26f658..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs +++ /dev/null @@ -1,99 +0,0 @@ -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}"); - let result = correlated(&endpoint).into_explicit_connect_target(); - assert!(matches!( - &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 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" - ); - assert!(error.source().is_none()); -} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs deleted file mode 100644 index 9440dff76..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, WebDriverBiDiWebSocketEndpoint, - WebDriverBiDiWebSocketEndpointAdmissionError, -}; - -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() { - 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); - assert_eq!(ipv4.session_id(), SESSION_ID); - assert_eq!( - ipv4.as_str(), - format!("ws://127.0.0.1:9515/session/{SESSION_ID}") - ); - - 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_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); -} - -#[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 [ - 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!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&endpoint), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost) - )); - } - - 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}"), - format!("ws://[::1]9515/session/{SESSION_ID}"), - format!("ws://[::zz]:9515/session/{SESSION_ID}"), - format!("ws://:9515/session/{SESSION_ID}"), - ] { - assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&endpoint), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority) - )); - } -} - -#[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 [ - 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!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&endpoint), - 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!(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", - "0123456789abcdef0123456789abcdeF", - "0123456789abcdef0123456789abcdeg", - "0123456789abcdef0123456789abcde_", - "0123456789abcdef0123456789abcde", - ] { - assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://localhost:9515/session/{session_id}" - )), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId) - )); - } -} - -#[test] -fn endpoint_text_rejects_noncanonical_or_unbounded_inputs_before_transport_use() { - assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(""), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint) - )); - assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&format!("http://localhost:9515/session/{SESSION_ID}")), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme) - )); - assert!(matches!( - 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}")), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) - )); - assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://localhost:9515/session/{SESSION_ID}?token=secret" - )), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) - )); - assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://localhost:9515/session/{SESSION_ID}#fragment" - )), - Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) - )); - - let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES + 1); - assert!(matches!( - WebDriverBiDiWebSocketEndpoint::new(&oversized), - 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()); - } -} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs deleted file mode 100644 index e522d7c55..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs +++ /dev/null @@ -1,92 +0,0 @@ -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"; -const CHROMEDRIVER_SESSION_ID: &str = "0123456789abcdef0123456789abcdef"; - -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 chromedriver_session_identity_correlation_preserves_exact_session_evidence() { - 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; - }; - - let result = endpoint.correlate_session_id(CHROMEDRIVER_SESSION_ID); - assert!(result.is_ok(), "{result:?}"); - let Ok(correlated) = result else { - return; - }; - assert_eq!(correlated.session_id(), CHROMEDRIVER_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", - "0123456789abcdef0123456789abcdeF", - "0123456789abcdef0123456789abcdeg", - ] { - 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()); - } -} diff --git a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs deleted file mode 100644 index 70802e89c..000000000 --- a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, - BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserRegistryError, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, - WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiLocateNodesResponseEnvelopeError, -}; - -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"}]}}"#, - )?) -} - -fn mismatched_wire_document() -> 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> { - 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_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> -{ - 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(()) -} diff --git a/crates/originweave-destination/src/lib.rs b/crates/originweave-destination/src/lib.rs index 5fdf2d363..774ba9ee9 100644 --- a/crates/originweave-destination/src/lib.rs +++ b/crates/originweave-destination/src/lib.rs @@ -24,6 +24,7 @@ pub use redirect::{ RedirectTargetDigestError, }; pub use resolution::{ - ConnectionEvidence, DestinationError, DestinationPolicy, MAX_RESOLUTION_ADDRESS_COUNT, + ConnectionEvidence, DestinationError, DestinationPolicy, FreshConnectionEvidence, + FreshResolutionSnapshot, MAX_RESOLUTION_ADDRESS_COUNT, MAX_RESOLUTION_VALIDITY, ResolutionSnapshot, }; diff --git a/crates/originweave-destination/src/proxy.rs b/crates/originweave-destination/src/proxy.rs index ef64289fa..4695dc3aa 100644 --- a/crates/originweave-destination/src/proxy.rs +++ b/crates/originweave-destination/src/proxy.rs @@ -446,6 +446,9 @@ fn explicit_port(authority: &str) -> Result, ProxyServerError> { port }; + if !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ProxyServerError::InvalidIdentifier); + } let port = port_text .parse::() .map_err(|_error| ProxyServerError::InvalidIdentifier)?; diff --git a/crates/originweave-destination/src/resolution.rs b/crates/originweave-destination/src/resolution.rs index f55d1722b..45620e6cd 100644 --- a/crates/originweave-destination/src/resolution.rs +++ b/crates/originweave-destination/src/resolution.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use std::fmt; use std::net::IpAddr; +use std::time::Duration; use originweave_core::Origin; @@ -9,6 +10,13 @@ use crate::{AddressClass, ClassifiedAddress, classify_address}; /// The largest resolver answer accepted by one resolution snapshot. pub const MAX_RESOLUTION_ADDRESS_COUNT: usize = 256; +/// The largest freshness interval accepted for one resolution approval. +/// +/// This is an OriginWeave product safety budget, not a DNS protocol validity +/// rule. Callers may choose any smaller non-zero interval appropriate to their +/// resolver and network adapter. +pub const MAX_RESOLUTION_VALIDITY: Duration = Duration::from_secs(30); + /// A fail-closed allow-list of destination address classes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DestinationPolicy { @@ -103,6 +111,34 @@ pub enum DestinationError { /// The newly introduced canonical address. address: IpAddr, }, + /// A freshness interval was zero or exceeded [`MAX_RESOLUTION_VALIDITY`]. + InvalidResolutionValidity { + /// The rejected freshness interval. + validity: Duration, + /// The largest accepted freshness interval. + maximum_validity: Duration, + }, + /// Adding the freshness interval to the approval time overflowed. + ResolutionValidityOverflow { + /// The trusted monotonic time at which the answer was approved. + approved_at: Duration, + /// The requested freshness interval. + validity: Duration, + }, + /// A caller supplied a monotonic time earlier than the recorded approval. + ResolutionUseBeforeApproval { + /// The recorded approval time. + approved_at: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, + /// A bounded resolution approval reached its exclusive validity deadline. + ResolutionApprovalExpired { + /// The exclusive upper bound of the approval interval. + valid_until: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, } impl fmt::Display for DestinationError { @@ -142,6 +178,34 @@ impl fmt::Display for DestinationError { formatter, "refreshed DNS answer introduced unapproved address {address}", ), + Self::InvalidResolutionValidity { + validity, + maximum_validity, + } => write!( + formatter, + "resolution validity {validity:?} is outside 1ns..={maximum_validity:?}", + ), + Self::ResolutionValidityOverflow { + approved_at, + validity, + } => write!( + formatter, + "resolution validity {validity:?} overflows approval time {approved_at:?}", + ), + Self::ResolutionUseBeforeApproval { + approved_at, + current_time, + } => write!( + formatter, + "resolution use time {current_time:?} precedes approval time {approved_at:?}", + ), + Self::ResolutionApprovalExpired { + valid_until, + current_time, + } => write!( + formatter, + "resolution approval expired at {valid_until:?}; current time is {current_time:?}", + ), } } } @@ -254,6 +318,143 @@ impl ResolutionSnapshot { } } +/// A resolution snapshot bound to one explicit trusted monotonic validity window. +/// +/// The time values are opaque durations from one caller-owned monotonic clock +/// domain. This type never reads a wall clock itself. Constructing a new fresh +/// snapshot always reruns the same destination validation used by +/// [`ResolutionSnapshot`], so callers cannot renew authority without presenting +/// another policy-valid answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshResolutionSnapshot { + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + valid_until: Duration, +} + +impl FreshResolutionSnapshot { + /// Validate addresses and bind the resulting snapshot to a bounded lifetime. + pub fn approve( + origin: Origin, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + approved_at: Duration, + validity: Duration, + ) -> Result { + let snapshot = ResolutionSnapshot::approve(origin, addresses, policy)?; + Self::from_snapshot(snapshot, approved_at, validity) + } + + fn from_snapshot( + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + ) -> Result { + if validity.is_zero() || validity > MAX_RESOLUTION_VALIDITY { + return Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }); + } + let Some(valid_until) = approved_at.checked_add(validity) else { + return Err(DestinationError::ResolutionValidityOverflow { + approved_at, + validity, + }); + }; + Ok(Self { + snapshot, + approved_at, + validity, + valid_until, + }) + } + + /// Return the logical origin whose DNS answer was approved. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.snapshot.origin() + } + + /// Return the canonical addresses pinned for this fresh snapshot. + #[must_use] + pub const fn addresses(&self) -> &BTreeSet { + self.snapshot.addresses() + } + + /// Return the trusted monotonic approval time. + #[must_use] + pub const fn approved_at(&self) -> Duration { + self.approved_at + } + + /// Return the configured non-zero validity budget. + #[must_use] + pub const fn validity(&self) -> Duration { + self.validity + } + + /// Return the exclusive upper bound of the approval interval. + #[must_use] + pub const fn valid_until(&self) -> Duration { + self.valid_until + } + + /// Authorize one pinned address only while the freshness window is valid. + pub fn authorize_connection( + &self, + address: IpAddr, + current_time: Duration, + ) -> Result { + self.validate_current_time(current_time)?; + let connection = self.snapshot.authorize_connection(address)?; + Ok(FreshConnectionEvidence { + connection, + resolution_approved_at: self.approved_at, + resolution_valid_until: self.valid_until, + authorized_at: current_time, + }) + } + + /// Revalidate a fresh answer and renew the same bounded validity budget. + /// + /// `revalidated_at` must come from the same monotonic clock domain and may + /// not precede this snapshot's approval time. Expansion of the pinned set + /// remains fail-closed under [`ResolutionSnapshot::revalidate`]. + pub fn revalidate( + &self, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + revalidated_at: Duration, + ) -> Result { + if revalidated_at < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time: revalidated_at, + }); + } + let snapshot = self.snapshot.revalidate(addresses, policy)?; + Self::from_snapshot(snapshot, revalidated_at, self.validity) + } + + fn validate_current_time(&self, current_time: Duration) -> Result<(), DestinationError> { + if current_time < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time, + }); + } + if current_time >= self.valid_until { + return Err(DestinationError::ResolutionApprovalExpired { + valid_until: self.valid_until, + current_time, + }); + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OriginHostConstraint { Domain, @@ -344,3 +545,38 @@ impl ConnectionEvidence { self.address_class } } + +/// Credential-free evidence that a pinned connection address was used while fresh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshConnectionEvidence { + connection: ConnectionEvidence, + resolution_approved_at: Duration, + resolution_valid_until: Duration, + authorized_at: Duration, +} + +impl FreshConnectionEvidence { + /// Return the underlying canonical destination/connection evidence. + #[must_use] + pub const fn connection_evidence(&self) -> &ConnectionEvidence { + &self.connection + } + + /// Return the trusted monotonic time at which the answer was approved. + #[must_use] + pub const fn resolution_approved_at(&self) -> Duration { + self.resolution_approved_at + } + + /// Return the exclusive upper bound of the resolution approval interval. + #[must_use] + pub const fn resolution_valid_until(&self) -> Duration { + self.resolution_valid_until + } + + /// Return the trusted monotonic time used for this authorization decision. + #[must_use] + pub const fn authorized_at(&self) -> Duration { + self.authorized_at + } +} diff --git a/crates/originweave-destination/tests/proxy_port_syntax.rs b/crates/originweave-destination/tests/proxy_port_syntax.rs new file mode 100644 index 000000000..9038c14ed --- /dev/null +++ b/crates/originweave-destination/tests/proxy_port_syntax.rs @@ -0,0 +1,29 @@ +use originweave_destination::{ProxyServer, ProxyServerError}; + +#[test] +fn proxy_server_rejects_non_digit_port_prefixes() { + for input in [ + "proxy.example:+8080", + "http://proxy.example:+8080", + "https://proxy.example:+8443", + "socks5://proxy.example:+1080", + "https://[2001:db8::1]:+8443", + ] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} + +#[test] +fn proxy_server_rejects_decimal_ports_outside_u16_range() { + for input in ["proxy.example:65536", "https://[2001:db8::1]:65536"] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs new file mode 100644 index 000000000..2df264563 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -0,0 +1,235 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{ + AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, + MAX_RESOLUTION_VALIDITY, +}; + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { + let approved_at = Duration::from_secs(100); + let validity = Duration::from_secs(5); + let target = origin("https://example.com"); + let approved = ipv4(8, 8, 8, 8); + let snapshot = FreshResolutionSnapshot::approve( + target.clone(), + [approved], + &DestinationPolicy::public_web(), + approved_at, + validity, + ) + .expect("bounded fresh resolution"); + + assert_eq!(snapshot.origin(), &target); + assert_eq!(snapshot.approved_at(), approved_at); + assert_eq!(snapshot.validity(), validity); + assert_eq!(snapshot.valid_until(), Duration::from_secs(105)); + + let evidence = snapshot + .authorize_connection(approved, approved_at) + .expect("authority begins at approval time"); + let connection = evidence.connection_evidence(); + assert_eq!(connection.origin(), &target); + assert_eq!(connection.requested_address(), approved); + assert_eq!(connection.canonical_address(), approved); + assert_eq!(connection.address_class(), AddressClass::Public); + assert_eq!(evidence.resolution_approved_at(), approved_at); + assert_eq!(evidence.resolution_valid_until(), Duration::from_secs(105)); + assert_eq!(evidence.authorized_at(), approved_at); + + snapshot + .authorize_connection(approved, Duration::from_secs(104)) + .expect("authority remains valid before the exclusive deadline"); + + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(99)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at, + current_time: Duration::from_secs(99), + }) + ); + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(105)), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(105), + current_time: Duration::from_secs(105), + }) + ); + assert_eq!( + snapshot.authorize_connection(ipv4(9, 9, 9, 9), approved_at), + Err(DestinationError::UnapprovedConnectionAddress { + address: ipv4(9, 9, 9, 9), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_invalid_or_overflowing_validity() { + let target = origin("https://example.com"); + let address = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + + for validity in [ + Duration::ZERO, + MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1), + ] { + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [address], + &policy, + Duration::from_secs(1), + validity, + ), + Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }) + ); + } + + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [address], + &policy, + Duration::MAX, + Duration::from_nanos(1), + ), + Err(DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_denied_addresses_before_granting_time_authority() { + let target = origin("https://example.com"); + let denied = ipv4(127, 0, 0, 1); + let public = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + let expected = Err(DestinationError::AddressClassDenied { + address: denied, + address_class: AddressClass::Loopback, + }); + + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [denied], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected.clone() + ); + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [denied, public], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected + ); +} + +#[test] +fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin("https://example.com"), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial fresh resolution"); + + let refreshed = snapshot + .revalidate([second], &policy, Duration::from_secs(13)) + .expect("a fresh non-expanding answer renews the bounded window"); + assert_eq!( + refreshed.addresses(), + &std::collections::BTreeSet::from([second]) + ); + assert_eq!(refreshed.approved_at(), Duration::from_secs(13)); + assert_eq!(refreshed.validity(), Duration::from_secs(4)); + assert_eq!(refreshed.valid_until(), Duration::from_secs(17)); + refreshed + .authorize_connection(second, Duration::from_secs(16)) + .expect("refreshed authority is usable before its new deadline"); + + assert_eq!( + snapshot.revalidate([second], &policy, Duration::from_secs(9)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }) + ); + assert_eq!( + snapshot.revalidate([unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); + assert_eq!( + snapshot.revalidate([first, unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} + +#[test] +fn freshness_errors_have_deterministic_bounded_messages() { + let invalid = DestinationError::InvalidResolutionValidity { + validity: Duration::ZERO, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }; + assert_eq!( + invalid.to_string(), + "resolution validity 0ns is outside 1ns..=30s" + ); + + let overflow = DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }; + assert!(overflow.to_string().contains("overflows approval time")); + + let before = DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }; + assert_eq!( + before.to_string(), + "resolution use time 9s precedes approval time 10s" + ); + + let expired = DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(15), + current_time: Duration::from_secs(15), + }; + assert_eq!( + expired.to_string(), + "resolution approval expired at 15s; current time is 15s" + ); +} diff --git a/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs new file mode 100644 index 000000000..3c8443554 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs @@ -0,0 +1,78 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{DestinationError, DestinationPolicy, FreshResolutionSnapshot}; + +fn origin() -> Origin { + Origin::parse("https://example.com").expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn post_expiry_revalidation_establishes_new_authority_without_reviving_the_old_snapshot() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + let expiry = Duration::from_secs(14); + assert_eq!( + snapshot.authorize_connection(first, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); + + let refreshed = snapshot + .revalidate([second], &policy, expiry) + .expect("fresh non-expanding validation may establish a new bounded snapshot"); + assert_eq!(refreshed.approved_at(), expiry); + assert_eq!(refreshed.valid_until(), Duration::from_secs(18)); + refreshed + .authorize_connection(second, expiry) + .expect("the newly validated snapshot has independent current authority"); + + assert_eq!( + snapshot.authorize_connection(second, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); +} + +#[test] +fn post_expiry_revalidation_still_rejects_address_set_expansion() { + let approved = ipv4(8, 8, 8, 8); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [approved], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + assert_eq!( + snapshot.revalidate([approved, unexpected], &policy, Duration::from_secs(14)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs new file mode 100644 index 000000000..14a86a24c --- /dev/null +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -0,0 +1,297 @@ +//! Versioned schema contracts for typed evidence extraction. +//! +//! These value objects describe what may be extracted and which reviewed +//! evidence channels may support each field. They do not read browser data, +//! disclose protected values, persist artifacts, execute models, or grant any +//! browser, network, secret, approval, or storage authority. + +use std::{collections::BTreeSet, fmt}; + +/// Maximum encoded byte length for an extraction schema or field identifier. +pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; +/// Maximum number of fields admitted by one extraction schema. +pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256; + +/// The typed value contract for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionValueType { + /// Bounded textual data. + Text, + /// A whole-number value. + Integer, + /// A decimal numeric value. + Decimal, + /// A boolean value. + Boolean, + /// A timestamp value whose concrete normalization is defined by the schema version. + Timestamp, +} + +/// The number of values admitted for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionCardinality { + /// Exactly one value is admitted. + One, + /// Zero or one value is admitted. + ZeroOrOne, + /// A bounded collection may be admitted by a later extraction runtime. + Many, +} + +/// A reviewed evidence channel that may support an extracted value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionSourceChannel { + /// A semantic browser node with an independently validated identity. + SemanticNode, + /// Embedded structured metadata such as JSON-LD, RDFa, or Microdata. + StructuredData, + /// A bounded table-cell observation. + TableCell, + /// A bounded network response whose origin and response identity are independently verified. + NetworkResponse, + /// A separately approved model interpretation backed by explicit evidence identifiers. + ModelInterpretation, +} + +/// A deterministic normalization rule declared for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionNormalizationRule { + /// Preserve the typed source value without text normalization. + Verbatim, + /// Trim surrounding whitespace from a textual value. + TrimTextWhitespace, + /// Normalize a timestamp into an RFC 3339 UTC representation. + Rfc3339Utc, +} + +/// A validation failure while constructing an extraction schema contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtractionSchemaError { + /// A schema or field identifier was empty or outside the accepted identifier grammar. + InvalidIdentifier, + /// An identifier or field collection exceeded its bounded limit. + LimitExceeded, + /// A field's required flag contradicted its declared cardinality. + InvalidCardinalityRequirement, + /// A field did not declare any reviewed source channel. + MissingSourceChannel, + /// A field declared the same source channel more than once. + DuplicateSourceChannel, + /// The declared normalization rule was incompatible with the field value type. + InvalidNormalizationRule, + /// A schema did not contain any field definitions. + MissingField, + /// A schema declared the same field identifier more than once. + DuplicateField, +} + +impl fmt::Display for ExtractionSchemaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidIdentifier => "invalid extraction schema or field identifier", + Self::LimitExceeded => "extraction schema limit exceeded", + Self::InvalidCardinalityRequirement => { + "extraction field required flag is incompatible with the declared cardinality" + } + Self::MissingSourceChannel => "extraction field requires at least one source channel", + Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", + Self::InvalidNormalizationRule => { + "extraction normalization rule is incompatible with the field value type" + } + Self::MissingField => "extraction schema requires at least one field", + Self::DuplicateField => "extraction schema contains a duplicate field identifier", + }) + } +} + +impl std::error::Error for ExtractionSchemaError {} + +/// One typed field declared by a versioned extraction schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionField { + identifier: String, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: Vec, +} + +impl ExtractionField { + /// Validate and construct one extraction field contract with verbatim normalization. + pub fn new( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + Self::new_with_normalization( + identifier, + value_type, + cardinality, + required, + ExtractionNormalizationRule::Verbatim, + source_channels, + ) + } + + /// Validate and construct one extraction field with an explicit normalization rule. + pub fn new_with_normalization( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + validate_identifier(identifier)?; + + let cardinality_requirement_is_compatible = match cardinality { + ExtractionCardinality::One => required, + ExtractionCardinality::ZeroOrOne => !required, + ExtractionCardinality::Many => true, + }; + if !cardinality_requirement_is_compatible { + return Err(ExtractionSchemaError::InvalidCardinalityRequirement); + } + + if source_channels.is_empty() { + return Err(ExtractionSchemaError::MissingSourceChannel); + } + + let normalization_is_compatible = match normalization_rule { + ExtractionNormalizationRule::Verbatim => true, + ExtractionNormalizationRule::TrimTextWhitespace => { + value_type == ExtractionValueType::Text + } + ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp, + }; + if !normalization_is_compatible { + return Err(ExtractionSchemaError::InvalidNormalizationRule); + } + + let mut seen_channels = BTreeSet::new(); + for source_channel in source_channels { + if !seen_channels.insert(*source_channel) { + return Err(ExtractionSchemaError::DuplicateSourceChannel); + } + } + + Ok(Self { + identifier: identifier.to_owned(), + value_type, + cardinality, + required, + normalization_rule, + source_channels: seen_channels.into_iter().collect(), + }) + } + + /// Return the stable field identifier. + #[must_use] + pub fn identifier(&self) -> &str { + &self.identifier + } + + /// Return the declared value type. + #[must_use] + pub const fn value_type(&self) -> ExtractionValueType { + self.value_type + } + + /// Return the declared cardinality. + #[must_use] + pub const fn cardinality(&self) -> ExtractionCardinality { + self.cardinality + } + + /// Return whether the field must be present in a conforming extraction result. + #[must_use] + pub const fn required(&self) -> bool { + self.required + } + + /// Return the deterministic normalization rule declared for this field. + #[must_use] + pub const fn normalization_rule(&self) -> ExtractionNormalizationRule { + self.normalization_rule + } + + /// Return the reviewed source channels that may support this field. + #[must_use] + pub fn source_channels(&self) -> &[ExtractionSourceChannel] { + &self.source_channels + } +} + +/// A bounded versioned collection of typed extraction-field contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionSchema { + version: String, + fields: Vec, +} + +impl ExtractionSchema { + /// Validate and construct one versioned extraction schema. + pub fn new(version: &str, fields: Vec) -> Result { + validate_identifier(version)?; + if fields.is_empty() { + return Err(ExtractionSchemaError::MissingField); + } + if fields.len() > MAX_EXTRACTION_FIELD_COUNT { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut field_identifiers = BTreeSet::new(); + for field in &fields { + if !field_identifiers.insert(field.identifier()) { + return Err(ExtractionSchemaError::DuplicateField); + } + } + + Ok(Self { + version: version.to_owned(), + fields, + }) + } + + /// Return the immutable schema version identifier. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Return the schema's ordered field definitions. + #[must_use] + pub fn fields(&self) -> &[ExtractionField] { + &self.fields + } + + /// Find one field by its stable identifier. + #[must_use] + pub fn field(&self, identifier: &str) -> Option<&ExtractionField> { + self.fields + .iter() + .find(|field| field.identifier() == identifier) + } +} + +fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> { + if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut bytes = identifier.bytes(); + let Some(first_byte) = bytes.next() else { + return Err(ExtractionSchemaError::InvalidIdentifier); + }; + if !first_byte.is_ascii_lowercase() { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + + Ok(()) +} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 30d4f301e..17c97bec8 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -7,20 +7,27 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod extraction_schema; mod sensitive_access; +mod sensitive_handle_lifecycle; +pub use extraction_schema::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema, + ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, + MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, +}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, SensitiveEvidenceError, }; +pub use sensitive_handle_lifecycle::{ + SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, +}; use std::collections::BTreeMap; -use originweave_core::{ - BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, - ValidatedBrowserProtocolUse, -}; +use originweave_core::Origin; const REDACTED: &str = "[REDACTED]"; @@ -37,75 +44,6 @@ pub const MAX_METADATA_VALUE_BYTES: usize = 8_192; /// Maximum source URL or source-locator size retained in provenance metadata. pub const MAX_PROVENANCE_TEXT_BYTES: usize = 8_192; -/// Immutable credential-safe audit metadata for one validated browser protocol use. -/// -/// This value can only be constructed from [`ValidatedBrowserProtocolUse`], so -/// it records metadata that already passed the exact OriginWeave generation, -/// runtime protocol-family, pinned runtime-revision, and capability checks. It -/// intentionally remains ordinary cloneable evidence: cloning this value does -/// not recreate the non-cloneable validation prerequisite or grant browser, -/// Agent, origin, session, context, network, or secret authority. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BrowserProtocolValidationEvidence { - kind: BrowserProtocolKind, - originweave_protocol_version: OriginWeaveProtocolVersion, - adapter_version: String, - protocol_revision: String, - browser_revision: String, - capability: BrowserProtocolCapability, -} - -impl BrowserProtocolValidationEvidence { - /// Record owned audit metadata from one already validated browser protocol use. - #[must_use] - pub fn from_validated_use(validated: &ValidatedBrowserProtocolUse) -> Self { - Self { - kind: validated.kind(), - originweave_protocol_version: validated.originweave_protocol_version(), - adapter_version: validated.adapter_version().to_owned(), - protocol_revision: validated.protocol_revision().to_owned(), - browser_revision: validated.browser_revision().to_owned(), - capability: validated.capability(), - } - } - - /// Return the validated browser protocol family. - #[must_use] - pub const fn kind(&self) -> BrowserProtocolKind { - self.kind - } - - /// Return the validated OriginWeave Protocol generation. - #[must_use] - pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { - self.originweave_protocol_version - } - - /// Return the bounded validated adapter-version metadata token. - #[must_use] - pub fn adapter_version(&self) -> &str { - &self.adapter_version - } - - /// Return the bounded validated upstream protocol-revision metadata token. - #[must_use] - pub fn protocol_revision(&self) -> &str { - &self.protocol_revision - } - - /// Return the bounded validated browser-revision metadata token. - #[must_use] - pub fn browser_revision(&self) -> &str { - &self.browser_revision - } - - /// Return the exact browser protocol capability validated for this use. - #[must_use] - pub const fn capability(&self) -> BrowserProtocolCapability { - self.capability - } -} - /// An HTTP method recorded for network evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum HttpMethod { diff --git a/crates/originweave-evidence/src/sensitive_access.rs b/crates/originweave-evidence/src/sensitive_access.rs index 9123119f7..24cb43047 100644 --- a/crates/originweave-evidence/src/sensitive_access.rs +++ b/crates/originweave-evidence/src/sensitive_access.rs @@ -297,7 +297,10 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> { Ok(()) } -fn valid_identifier(value: &str) -> bool { +/// Return whether `value` is a non-empty identifier of at most +/// `MAX_SENSITIVE_IDENTIFIER_BYTES` ASCII bytes, contains at least one +/// alphanumeric byte, and otherwise uses only `.`, `_`, `:`, or `-` punctuation. +pub(crate) fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs new file mode 100644 index 000000000..f61c8527f --- /dev/null +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -0,0 +1,144 @@ +//! Credential-free lifecycle evidence for opaque sensitive-value handles. +//! +//! A trusted broker can use this value object to record when a handle was +//! issued, when it expires, how many uses it permits, how many resolutions were +//! observed, and when it was revoked. The lifecycle retains the complete +//! credential-free sensitive-access receipt that authorized opaque-handle use, +//! while intentionally excluding the opaque handle token and protected value. + +use crate::sensitive_access::{ + SensitiveAccessEvidence, SensitiveAccessOutcome, SensitiveEvidenceError, +}; + +/// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. +/// +/// The embedded access receipt binds the lifecycle to the tenant, actor, task, +/// field set, purpose, destination, classification, policy version, and exact +/// opaque-handle authorization without carrying protected values. When the access +/// receipt carries a retention deadline, the handle must expire no later than +/// that deadline so derived opaque authority cannot outlive its governing receipt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidenceInput { + /// Credential-free access receipt that authorized this opaque handle. + pub access_evidence: SensitiveAccessEvidence, + /// Trusted Unix epoch second when the handle was issued. + pub issued_epoch_seconds: u64, + /// Trusted Unix epoch second after which the handle is no longer valid. + /// + /// When the retained access receipt defines a retention deadline, this value + /// may equal but must not exceed that deadline. + pub expires_epoch_seconds: u64, + /// Maximum number of broker resolutions authorized for the handle. + pub maximum_uses: u32, + /// Number of broker resolutions already observed for the handle. + pub resolution_count: u32, + /// Trusted Unix epoch second when the handle was revoked, when applicable. + /// + /// A revocation recorded exactly at expiry is retained as a terminal audit + /// event even though it cannot extend or restore handle validity. + pub revoked_epoch_seconds: Option, +} + +/// Immutable credential-free evidence about one opaque handle lifecycle. +/// +/// The value retains the exact credential-free sensitive-access receipt that +/// authorized opaque-handle use, but deliberately excludes both the opaque +/// handle token and the secret or protected value that the broker can resolve. +/// Any receipt retention deadline also bounds the derived handle lifetime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidence { + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, + expires_epoch_seconds: u64, + maximum_uses: u32, + resolution_count: u32, + revoked_epoch_seconds: Option, +} + +impl TryFrom for SensitiveHandleLifecycleEvidence { + type Error = SensitiveEvidenceError; + + fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result { + if input.access_evidence.outcome() != SensitiveAccessOutcome::OpaqueHandleOnly + || input.issued_epoch_seconds == 0 + || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() + || input.expires_epoch_seconds <= input.issued_epoch_seconds + || input + .access_evidence + .retention_deadline_epoch_seconds() + .is_some_and(|deadline| input.expires_epoch_seconds > deadline) + || input.maximum_uses == 0 + || input.resolution_count > input.maximum_uses + || input.revoked_epoch_seconds.is_some_and(|revoked| { + revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds + }) + { + return Err(SensitiveEvidenceError::InvalidLifecycle); + } + + Ok(Self { + access_evidence: input.access_evidence, + issued_epoch_seconds: input.issued_epoch_seconds, + expires_epoch_seconds: input.expires_epoch_seconds, + maximum_uses: input.maximum_uses, + resolution_count: input.resolution_count, + revoked_epoch_seconds: input.revoked_epoch_seconds, + }) + } +} + +impl SensitiveHandleLifecycleEvidence { + /// Return the credential-free access receipt that authorized this opaque handle. + #[must_use] + pub const fn access_evidence(&self) -> &SensitiveAccessEvidence { + &self.access_evidence + } + + /// Return the originating sensitive-data access request identifier. + #[must_use] + pub fn request_id(&self) -> &str { + self.access_evidence.request_id() + } + + /// Return the policy decision identifier associated with the handle. + #[must_use] + pub fn decision_id(&self) -> &str { + self.access_evidence.decision_id() + } + + /// Return the trusted handle issuance time as a Unix epoch second. + #[must_use] + pub const fn issued_epoch_seconds(&self) -> u64 { + self.issued_epoch_seconds + } + + /// Return the trusted handle expiry time as a Unix epoch second. + #[must_use] + pub const fn expires_epoch_seconds(&self) -> u64 { + self.expires_epoch_seconds + } + + /// Return the maximum number of broker resolutions authorized for the handle. + #[must_use] + pub const fn maximum_uses(&self) -> u32 { + self.maximum_uses + } + + /// Return the number of broker resolutions already observed for the handle. + #[must_use] + pub const fn resolution_count(&self) -> u32 { + self.resolution_count + } + + /// Return the trusted revocation time when the handle has been revoked. + #[must_use] + pub const fn revoked_epoch_seconds(&self) -> Option { + self.revoked_epoch_seconds + } + + /// Return whether trusted evidence records that this handle was revoked. + #[must_use] + pub const fn is_revoked(&self) -> bool { + self.revoked_epoch_seconds.is_some() + } +} diff --git a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs deleted file mode 100644 index fdab7d12c..000000000 --- a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::error::Error; - -use originweave_core::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - OriginWeaveProtocolVersion, -}; -use originweave_evidence::BrowserProtocolValidationEvidence; - -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"; - -#[test] -fn records_exact_metadata_from_one_validated_browser_protocol_use() -> Result<(), Box> { - let descriptor = BrowserProtocolAdapterDescriptor::new( - BrowserProtocolKind::WebDriverBiDi, - ORIGINWEAVE_PROTOCOL_VERSION, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - &[BrowserProtocolCapability::SemanticObservation], - )?; - let validated = descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::WebDriverBiDi, - ADAPTER_VERSION, - PROTOCOL_REVISION, - BROWSER_REVISION, - BrowserProtocolCapability::SemanticObservation, - )?; - - let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); - - assert_eq!(evidence.kind(), BrowserProtocolKind::WebDriverBiDi); - assert_eq!( - evidence.originweave_protocol_version(), - ORIGINWEAVE_PROTOCOL_VERSION - ); - assert_eq!(evidence.adapter_version(), ADAPTER_VERSION); - assert_eq!(evidence.protocol_revision(), PROTOCOL_REVISION); - assert_eq!(evidence.browser_revision(), BROWSER_REVISION); - assert_eq!( - evidence.capability(), - BrowserProtocolCapability::SemanticObservation - ); - Ok(()) -} - -#[test] -fn evidence_is_owned_audit_metadata_not_reusable_validation_authority() -> Result<(), Box> -{ - let cdp_adapter_version = "originweave-cdp-v1"; - let cdp_protocol_revision = "cdp-1-3-r1639810"; - let descriptor = BrowserProtocolAdapterDescriptor::new( - BrowserProtocolKind::ChromeDevToolsProtocol, - ORIGINWEAVE_PROTOCOL_VERSION, - cdp_adapter_version, - cdp_protocol_revision, - BROWSER_REVISION, - &[BrowserProtocolCapability::NetworkObservation], - )?; - let validated = descriptor.validate_use( - ORIGINWEAVE_PROTOCOL_VERSION, - BrowserProtocolKind::ChromeDevToolsProtocol, - cdp_adapter_version, - cdp_protocol_revision, - BROWSER_REVISION, - BrowserProtocolCapability::NetworkObservation, - )?; - - let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); - let cloned = evidence.clone(); - - assert_eq!(cloned, evidence); - assert_eq!(cloned.kind(), BrowserProtocolKind::ChromeDevToolsProtocol); - assert_eq!( - cloned.capability(), - BrowserProtocolCapability::NetworkObservation - ); - Ok(()) -} diff --git a/crates/originweave-evidence/tests/extraction_normalization.rs b/crates/originweave-evidence/tests/extraction_normalization.rs new file mode 100644 index 000000000..63afd39e6 --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_normalization.rs @@ -0,0 +1,77 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn extraction_fields_require_an_explicit_typed_normalization_rule() +-> Result<(), ExtractionSchemaError> { + let text = ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert_eq!( + text.normalization_rule(), + ExtractionNormalizationRule::TrimTextWhitespace + ); + + let timestamp = ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::NetworkResponse], + )?; + assert_eq!( + timestamp.normalization_rule(), + ExtractionNormalizationRule::Rfc3339Utc + ); + Ok(()) +} + +#[test] +fn extraction_fields_fail_closed_on_type_incompatible_normalization() { + assert_eq!( + ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::NetworkResponse], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); + assert_eq!( + ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); +} + +#[test] +fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> { + let field = ExtractionField::new( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + field.normalization_rule(), + ExtractionNormalizationRule::Verbatim + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs new file mode 100644 index 000000000..fc875ef0f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -0,0 +1,326 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, + MAX_EXTRACTION_IDENTIFIER_BYTES, +}; + +fn field( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], +) -> Result { + ExtractionField::new( + identifier, + value_type, + cardinality, + required, + source_channels, + ) +} + +#[test] +fn schema_binds_versioned_typed_fields_to_explicit_source_channels() +-> Result<(), ExtractionSchemaError> { + let schema = ExtractionSchema::new( + "product-card-v1", + vec![ + field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ], + )?, + field( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ + ExtractionSourceChannel::TableCell, + ExtractionSourceChannel::NetworkResponse, + ], + )?, + ], + )?; + + assert_eq!(schema.version(), "product-card-v1"); + assert_eq!(schema.fields().len(), 2); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::identifier), + Some("product_name") + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::value_type), + Some(ExtractionValueType::Text) + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::cardinality), + Some(ExtractionCardinality::One) + ); + assert_eq!( + schema.field("product_name").map(ExtractionField::required), + Some(true) + ); + let expected_product_sources = [ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ]; + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::source_channels), + Some(expected_product_sources.as_slice()) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::value_type), + Some(ExtractionValueType::Decimal) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::cardinality), + Some(ExtractionCardinality::ZeroOrOne) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::required), + Some(false) + ); + assert!(schema.field("missing_field").is_none()); + Ok(()) +} + +#[test] +fn field_accepts_all_reviewed_value_and_source_channel_variants() +-> Result<(), ExtractionSchemaError> { + let cases = [ + ( + ExtractionValueType::Text, + ExtractionSourceChannel::SemanticNode, + ), + ( + ExtractionValueType::Integer, + ExtractionSourceChannel::StructuredData, + ), + ( + ExtractionValueType::Decimal, + ExtractionSourceChannel::TableCell, + ), + ( + ExtractionValueType::Boolean, + ExtractionSourceChannel::NetworkResponse, + ), + ( + ExtractionValueType::Timestamp, + ExtractionSourceChannel::ModelInterpretation, + ), + ]; + + for (index, (value_type, source_channel)) in cases.into_iter().enumerate() { + let field = field( + &format!("field_{index}"), + value_type, + ExtractionCardinality::Many, + false, + &[source_channel], + )?; + assert_eq!(field.value_type(), value_type); + assert_eq!(field.cardinality(), ExtractionCardinality::Many); + assert_eq!(field.source_channels(), &[source_channel]); + } + + let required_many = field( + "required_many", + ExtractionValueType::Text, + ExtractionCardinality::Many, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert!(required_many.required()); + Ok(()) +} + +#[test] +fn field_rejects_contradictory_required_cardinality_contracts() { + assert_eq!( + ExtractionField::new( + "optional_exactly_one", + ExtractionValueType::Text, + ExtractionCardinality::One, + false, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); + assert_eq!( + ExtractionField::new( + "required_zero_or_one", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); +} + +#[test] +fn field_rejects_empty_malformed_or_overlong_identifiers() { + assert_eq!( + ExtractionField::new( + "", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "Product Name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "product name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "1product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); +} + +#[test] +fn field_requires_a_nonempty_duplicate_free_source_channel_set() { + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[], + ), + Err(ExtractionSchemaError::MissingSourceChannel) + ); + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::SemanticNode, + ], + ), + Err(ExtractionSchemaError::DuplicateSourceChannel) + ); +} + +#[test] +fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() +-> Result<(), ExtractionSchemaError> { + assert_eq!( + ExtractionSchema::new( + "Product Schema", + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?] + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionSchema::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![]), + Err(ExtractionSchemaError::MissingField) + ); + + let duplicate = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + let duplicate_again = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![duplicate, duplicate_again]), + Err(ExtractionSchemaError::DuplicateField) + ); + + let too_many_fields = (0..=MAX_EXTRACTION_FIELD_COUNT) + .map(|index| { + field( + &format!("field_{index}"), + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::SemanticNode], + ) + }) + .collect::, _>>()?; + assert_eq!( + ExtractionSchema::new("product-card-v1", too_many_fields), + Err(ExtractionSchemaError::LimitExceeded) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs new file mode 100644 index 000000000..b4897d90f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs @@ -0,0 +1,48 @@ +use std::error::Error as _; + +use originweave_evidence::ExtractionSchemaError; + +fn assert_standard_error_contract() {} + +#[test] +fn extraction_schema_errors_implement_standard_error_contract() { + assert_standard_error_contract::(); + + for (error, message) in [ + ( + ExtractionSchemaError::InvalidIdentifier, + "invalid extraction schema or field identifier", + ), + ( + ExtractionSchemaError::LimitExceeded, + "extraction schema limit exceeded", + ), + ( + ExtractionSchemaError::InvalidCardinalityRequirement, + "extraction field required flag is incompatible with the declared cardinality", + ), + ( + ExtractionSchemaError::MissingSourceChannel, + "extraction field requires at least one source channel", + ), + ( + ExtractionSchemaError::DuplicateSourceChannel, + "extraction field contains a duplicate source channel", + ), + ( + ExtractionSchemaError::InvalidNormalizationRule, + "extraction normalization rule is incompatible with the field value type", + ), + ( + ExtractionSchemaError::MissingField, + "extraction schema requires at least one field", + ), + ( + ExtractionSchemaError::DuplicateField, + "extraction schema contains a duplicate field identifier", + ), + ] { + assert_eq!(error.to_string(), message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-evidence/tests/extraction_source_channel_set.rs b/crates/originweave-evidence/tests/extraction_source_channel_set.rs new file mode 100644 index 000000000..1f5070e8a --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_source_channel_set.rs @@ -0,0 +1,40 @@ +#![allow(clippy::expect_used)] + +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn equivalent_source_channel_sets_have_canonical_identity() { + let semantic_then_network = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ], + ) + .expect("reviewed source set must be valid"); + let network_then_semantic = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::NetworkResponse, + ExtractionSourceChannel::SemanticNode, + ], + ) + .expect("equivalent reviewed source set must be valid"); + + assert_eq!(semantic_then_network, network_then_semantic); + assert_eq!( + network_then_semantic.source_channels(), + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ] + ); +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs new file mode 100644 index 000000000..6dbf8d713 --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -0,0 +1,114 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn access_evidence( + outcome: SensitiveAccessOutcome, + decision_epoch_seconds: u64, +) -> Result { + let destination = + Origin::parse("https://checkout.example.com").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-browser-adapter".to_owned(), + task_id: "task-99".to_owned(), + field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn lifecycle_input( + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, +) -> SensitiveHandleLifecycleEvidenceInput { + SensitiveHandleLifecycleEvidenceInput { + access_evidence, + issued_epoch_seconds, + expires_epoch_seconds: issued_epoch_seconds + 300, + maximum_uses: 2, + resolution_count: 0, + revoked_epoch_seconds: None, + } +} + +#[test] +fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let evidence = + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001)) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.access_evidence(), &access); + assert_eq!(evidence.request_id(), access.request_id()); + assert_eq!(evidence.decision_id(), access.decision_id()); + assert_eq!(evidence.access_evidence().tenant_id(), "tenant-7"); + assert_eq!(evidence.access_evidence().task_id(), "task-99"); + assert_eq!( + evidence.access_evidence().field_ids(), + ["shipping_name", "shipping_address"] + ); + assert_eq!( + evidence.access_evidence().destination().as_str(), + "https://checkout.example.com" + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_non_opaque_handle_access_decision() -> TestResult { + let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(denied, 1_720_000_001)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access, 1_720_000_099)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_expiry_respects_access_retention_deadline() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let retention_deadline = access + .retention_deadline_epoch_seconds() + .ok_or_else(|| "fixture must carry a retention deadline".to_owned())?; + + let mut exact_deadline = lifecycle_input(access.clone(), 1_720_000_001); + exact_deadline.expires_epoch_seconds = retention_deadline; + SensitiveHandleLifecycleEvidence::try_from(exact_deadline) + .map_err(|error| format!("{error:?}"))?; + + let mut after_deadline = lifecycle_input(access, 1_720_000_001); + after_deadline.expires_epoch_seconds = retention_deadline + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(after_deadline), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs new file mode 100644 index 000000000..95034cecc --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -0,0 +1,142 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn valid_access_evidence() -> Result { + let destination = + Origin::parse("https://shipping.example").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-fulfillment".to_owned(), + task_id: "task-42".to_owned(), + field_ids: vec!["shipping.address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome: SensitiveAccessOutcome::OpaqueHandleOnly, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds: 1_720_000_000, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(1_720_003_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn valid_input() -> Result { + Ok(SensitiveHandleLifecycleEvidenceInput { + access_evidence: valid_access_evidence()?, + issued_epoch_seconds: 1_720_000_001, + expires_epoch_seconds: 1_720_000_301, + maximum_uses: 2, + resolution_count: 1, + revoked_epoch_seconds: None, + }) +} + +#[test] +fn records_bounded_handle_lifecycle_without_handle_or_secret_material() -> TestResult { + let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()?) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.request_id(), "request-42"); + assert_eq!(evidence.decision_id(), "decision-42"); + assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_001); + assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_301); + assert_eq!(evidence.maximum_uses(), 2); + assert_eq!(evidence.resolution_count(), 1); + assert_eq!(evidence.revoked_epoch_seconds(), None); + assert!(!evidence.is_revoked()); + + let debug = format!("{evidence:?}"); + assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); + assert!(!debug.contains("raw-secret-should-never-be-evidence")); + Ok(()) +} + +#[test] +fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(1_720_000_120); + input.resolution_count = 2; + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); + assert!(evidence.is_revoked()); + assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); + Ok(()) +} + +#[test] +fn records_revocation_at_exact_expiry_boundary() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(input.expires_epoch_seconds); + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!( + evidence.revoked_epoch_seconds(), + Some(evidence.expires_epoch_seconds()) + ); + assert!(evidence.is_revoked()); + Ok(()) +} + +#[test] +fn rejects_zero_or_non_increasing_handle_lifetime() -> TestResult { + for (issued, expires) in [ + (0, 1_720_000_301), + (1_720_000_301, 1_720_000_301), + (1_720_000_302, 1_720_000_301), + ] { + let mut input = valid_input()?; + input.issued_epoch_seconds = issued; + input.expires_epoch_seconds = expires; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} + +#[test] +fn rejects_zero_use_limit_or_resolution_count_above_limit() -> TestResult { + let mut zero_limit = valid_input()?; + zero_limit.maximum_uses = 0; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(zero_limit), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + + let mut overused = valid_input()?; + overused.resolution_count = overused.maximum_uses + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(overused), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn rejects_revocation_before_issue_or_after_expiry() -> TestResult { + for revoked in [1_720_000_000, 1_720_000_302] { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(revoked); + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index a77d9b794..d5b26c1c3 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -1,29 +1,15 @@ //! Direct-only policy-bound TCP connection authority for OriginWeave. //! -//! 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 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. +//! 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. #![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, NetworkError, SocketConnectionEvidence, }; -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_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs deleted file mode 100644 index 5d39bb5e3..000000000 --- a/crates/originweave-network/src/webdriver_bidi_connection.rs +++ /dev/null @@ -1,254 +0,0 @@ -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, - 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 - } - - /// 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 - } -} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/error.rs b/crates/originweave-network/src/webdriver_bidi_connection/error.rs deleted file mode 100644 index 226cd1d2b..000000000 --- a/crates/originweave-network/src/webdriver_bidi_connection/error.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::{fmt, io, net::SocketAddr, time::Duration}; - -use originweave_core::WebDriverBiDiSocketPeerVerificationError; - -/// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. -#[derive(Debug)] -pub enum WebDriverBiDiTcpConnectionError { - /// The requested timeout was zero or exceeded [`crate::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, - } - } -} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs deleted file mode 100644 index e2d287ca1..000000000 --- a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs +++ /dev/null @@ -1,361 +0,0 @@ -#![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::{ - WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, - is_retryable_connect_error, -}; -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()); -} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs deleted file mode 100644 index 026fa390b..000000000 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::fmt; - -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'/') -} - -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. 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 { - 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 -/// 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. 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. -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( - 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, - client_key, - request, - }) - } - - /// Borrow the exact serialized RFC 6455 opening-request bytes. - #[must_use] - pub fn request_bytes(&self) -> &[u8] { - &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 { - self.connection.verified_peer() - } -} diff --git a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs deleted file mode 100644 index fac15b6a5..000000000 --- a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs +++ /dev/null @@ -1,94 +0,0 @@ -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 (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 { - 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 { .. }) - )); -} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs deleted file mode 100644 index 93550245a..000000000 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ /dev/null @@ -1,202 +0,0 @@ -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=="; -const REDACTED_CLIENT_KEY: &str = ""; - -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 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)); - 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.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()); - - 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_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_length = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ="); - assert!(matches!( - invalid_length, - Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) - )); - 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 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:?}"); - 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:?}"); - } -} diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs new file mode 100644 index 000000000..b9c55b278 --- /dev/null +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -0,0 +1,349 @@ +#![allow(clippy::expect_used)] + +//! Keep extension proposal-grant evaluation separate from ordinary action policy. +//! +//! OriginWeave does not yet implement an adapter that converts an extension proposal into an +//! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: +//! the exact extension/task/session/context/origin/unexpired grant permits only +//! `ProposeTypedAction`, while an ordinary user-sourced action request remains subject to the +//! core policy decision shown in each test. + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn agent_task() -> AgentTaskId { + AgentTaskId::new(13).expect("nonzero agent task") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(17).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(23).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + agent_task(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + agent_task(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_cross_origin_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let source = origin("https://source.example"); + let target = origin("https://target.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([source.clone(), target.clone()]), + BTreeSet::from([target.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + source, + target, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrossOriginMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_write_origin_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotWritable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_crawler_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrawlerMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_mode_purpose_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ModePurposeMismatch) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_disallowed_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Disallowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsDisallowed) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_unknown_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Unknown, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsUnknown) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_missing_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsNotApplicable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_non_delegable_r5_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://consent.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::LegalConsent]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::LegalConsent, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ForbiddenRisk) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_human_mode_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://human.example"); + let context = PolicyContext::new( + SessionMode::Human, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::HumanModeNotAgentControlled) + ); +} diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs new file mode 100644 index 000000000..11986b1f9 --- /dev/null +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -0,0 +1,221 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn agent_task() -> AgentTaskId { + AgentTaskId::new(5).expect("nonzero agent task") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + agent_task(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + agent_task(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn explicit_extension_grant_does_not_widen_agent_origin_authority() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let allowed = origin("https://app.example"); + let forbidden = origin("https://outside.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([allowed.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + allowed, + forbidden, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotReadable) + ); +} + +#[test] +fn explicit_extension_grant_does_not_supply_agent_action_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} + +#[test] +fn untrusted_extension_content_cannot_become_a_policy_instruction() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::WebContent, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UntrustedInstructionSource) + ); +} + +#[test] +fn explicit_extension_grant_cannot_turn_raw_secret_delivery_into_a_fill_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::SecretBrokerRequired) + ); +} + +#[test] +fn explicit_extension_grant_cannot_attach_secret_material_to_non_secret_action() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UnexpectedSecretMaterial) + ); +} diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs new file mode 100644 index 000000000..86d04df4b --- /dev/null +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -0,0 +1,102 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_access, +}; +use originweave_policy::{Decision, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn agent_task() -> AgentTaskId { + AgentTaskId::new(5).expect("nonzero agent task") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin() -> Origin { + Origin::parse("https://login.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + agent_task(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + agent_task(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +fn secret_context(site: &Origin) -> PolicyContext { + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn extension_action_grant_cannot_skip_secret_broker_approval() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin(); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site.clone(), + InstructionSource::User, + SecretDelivery::BrokerHandle, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &secret_context(&site)), + Decision::RequireApproval(RiskClass::R3) + ); +} diff --git a/crates/originweave-resource/src/lib.rs b/crates/originweave-resource/src/lib.rs index 8c77aa3d0..35a30789a 100644 --- a/crates/originweave-resource/src/lib.rs +++ b/crates/originweave-resource/src/lib.rs @@ -9,6 +9,8 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +use std::fmt; + /// A validation error in a resource budget. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BudgetError { @@ -18,6 +20,19 @@ pub enum BudgetError { SoftExceedsHard, } +impl fmt::Display for BudgetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroLimit => formatter.write_str("resource budget limits must be nonzero"), + Self::SoftExceedsHard => { + formatter.write_str("resource budget soft limits must not exceed hard limits") + } + } + } +} + +impl std::error::Error for BudgetError {} + /// Validated resource limits for one agent task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResourceBudget { diff --git a/crates/originweave-resource/tests/error_contract.rs b/crates/originweave-resource/tests/error_contract.rs new file mode 100644 index 000000000..cc8b88dfb --- /dev/null +++ b/crates/originweave-resource/tests/error_contract.rs @@ -0,0 +1,21 @@ +use originweave_resource::BudgetError; +use std::error::Error as _; + +#[test] +fn budget_errors_expose_stable_standard_error_contract() { + let cases = [ + ( + BudgetError::ZeroLimit, + "resource budget limits must be nonzero", + ), + ( + BudgetError::SoftExceedsHard, + "resource budget soft limits must not exceed hard limits", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-tls/src/lib.rs b/crates/originweave-tls/src/lib.rs index f9ec5e877..9024946f4 100644 --- a/crates/originweave-tls/src/lib.rs +++ b/crates/originweave-tls/src/lib.rs @@ -14,6 +14,7 @@ mod evidence; mod handshake; mod identity; mod policy; +mod revocation; mod trust; mod validity; @@ -29,6 +30,7 @@ pub use policy::{ MAX_MINIMUM_LEAF_VALIDITY, MAX_SERVER_CERTIFICATE_BYTES, MAX_SERVER_CERTIFICATE_COUNT, MAX_TLS_HANDSHAKE_TIMEOUT, TlsClientPolicy, }; +pub use revocation::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; pub use trust::{ MAX_TRUST_ROOT_BYTES, MAX_TRUST_ROOT_COUNT, TrustBundleIdentifier, TrustRootBundle, }; diff --git a/crates/originweave-tls/src/revocation.rs b/crates/originweave-tls/src/revocation.rs new file mode 100644 index 000000000..e500125a2 --- /dev/null +++ b/crates/originweave-tls/src/revocation.rs @@ -0,0 +1,174 @@ +use std::fmt; + +/// A deterministic freshness window for independently verified revocation material. +/// +/// This value does not fetch, parse, authenticate, or interpret OCSP responses or +/// certificate revocation lists. A trusted adapter must first obtain and +/// cryptographically validate the revocation material, then pass the signed +/// `thisUpdate` and `nextUpdate` timestamps into this authority together with a +/// caller-selected local maximum freshness window. Passing this check proves only +/// that the supplied material is within both its signed interval and the caller's +/// bounded freshness policy; it does not prove that any certificate is unrevoked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RevocationMaterialFreshness { + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, +} + +impl RevocationMaterialFreshness { + /// Create a non-empty, locally bounded freshness window from trusted signed timestamps. + /// + /// The signed window is half-open: `thisUpdate <= trusted_time < nextUpdate`. + /// Equal or reversed timestamps fail closed because they provide no usable + /// interval. `maximum_window_seconds` is a separate local policy ceiling and + /// must be nonzero; signed material whose declared interval exceeds that + /// ceiling is rejected even if its timestamps are otherwise well-formed. + pub const fn new( + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, + ) -> Result { + if next_update_unix_seconds <= this_update_unix_seconds { + return Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + }); + } + if maximum_window_seconds == 0 { + return Err(RevocationMaterialFreshnessError::ZeroMaximumWindow); + } + + let window_seconds = next_update_unix_seconds - this_update_unix_seconds; + if window_seconds > maximum_window_seconds { + return Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + }); + } + + Ok(Self { + this_update_unix_seconds, + next_update_unix_seconds, + maximum_window_seconds, + }) + } + + /// Return the signed time at which the revocation material becomes current. + #[must_use] + pub const fn this_update_unix_seconds(self) -> u64 { + self.this_update_unix_seconds + } + + /// Return the signed time at which this freshness window stops being usable. + #[must_use] + pub const fn next_update_unix_seconds(self) -> u64 { + self.next_update_unix_seconds + } + + /// Return the caller-selected maximum accepted signed-window duration. + #[must_use] + pub const fn maximum_window_seconds(self) -> u64 { + self.maximum_window_seconds + } + + /// Evaluate one trusted time against the half-open freshness window. + /// + /// A time before `thisUpdate` is not yet usable. A time equal to or later + /// than `nextUpdate` is stale. Both cases fail closed without making any + /// statement about the certificate's revocation state. + pub const fn evaluate( + self, + trusted_time_unix_seconds: u64, + ) -> Result<(), RevocationMaterialFreshnessError> { + if trusted_time_unix_seconds < self.this_update_unix_seconds { + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds: self.this_update_unix_seconds, + }) + } else if trusted_time_unix_seconds >= self.next_update_unix_seconds { + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds: self.next_update_unix_seconds, + }) + } else { + Ok(()) + } + } +} + +/// A deterministic reason that verified revocation material is not fresh enough to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevocationMaterialFreshnessError { + /// The supplied signed timestamps do not define a non-empty freshness window. + InvalidWindow { + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, + /// The caller supplied no positive local maximum freshness duration. + ZeroMaximumWindow, + /// The material's signed interval exceeds the caller's local freshness ceiling. + WindowExceedsMaximum { + /// Duration of the signed `thisUpdate` to `nextUpdate` interval in seconds. + window_seconds: u64, + /// Caller-selected maximum accepted interval in seconds. + maximum_window_seconds: u64, + }, + /// Trusted time falls before the material's signed `thisUpdate` timestamp. + NotYetValid { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + }, + /// Trusted time is equal to or later than the material's signed `nextUpdate` timestamp. + Expired { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, +} + +impl fmt::Display for RevocationMaterialFreshnessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material window is invalid: thisUpdate {this_update_unix_seconds} must be before nextUpdate {next_update_unix_seconds}", + ), + Self::ZeroMaximumWindow => write!( + formatter, + "revocation material maximum freshness window must be greater than zero", + ), + Self::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + } => write!( + formatter, + "revocation material window is {window_seconds} seconds, exceeding the local maximum of {maximum_window_seconds} seconds", + ), + Self::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds, + } => write!( + formatter, + "revocation material is not usable at trusted time {trusted_time_unix_seconds}; thisUpdate is {this_update_unix_seconds}", + ), + Self::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material is stale at trusted time {trusted_time_unix_seconds}; nextUpdate is {next_update_unix_seconds}", + ), + } + } +} + +impl std::error::Error for RevocationMaterialFreshnessError {} diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index f3e3374b6..32aa66e17 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -19,6 +19,7 @@ impl TrustBundleIdentifier { pub fn parse(input: &str) -> Result { if input.is_empty() || input.len() > 128 + || !input.bytes().any(|byte| byte.is_ascii_alphanumeric()) || !input.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') }) diff --git a/crates/originweave-tls/tests/policy_contract.rs b/crates/originweave-tls/tests/policy_contract.rs index 5a8b71ef4..4fad353b3 100644 --- a/crates/originweave-tls/tests/policy_contract.rs +++ b/crates/originweave-tls/tests/policy_contract.rs @@ -25,7 +25,7 @@ fn trust_bundle_identifier_is_bounded_and_ascii() { TrustBundleIdentifier::parse("enterprise_roots:v1").expect("valid trust bundle identifier"); assert_eq!(identifier.as_str(), "enterprise_roots:v1"); - for invalid in ["", "contains space", "한글", "slash/value"] { + for invalid in ["", "contains space", "한글", "slash/value", "---"] { assert!(matches!( TrustBundleIdentifier::parse(invalid), Err(TlsError::InvalidTrustBundleIdentifier) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs new file mode 100644 index 000000000..c7af7bd7c --- /dev/null +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -0,0 +1,119 @@ +use std::error::Error as _; + +use originweave_tls::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; + +const MAXIMUM_WINDOW_SECONDS: u64 = 300; + +#[test] +fn revocation_material_freshness_uses_a_half_open_verified_window() { + let freshness = RevocationMaterialFreshness::new(1_000, 1_100, MAXIMUM_WINDOW_SECONDS); + assert!(freshness.is_ok()); + + if let Ok(freshness) = freshness { + assert_eq!(freshness.this_update_unix_seconds(), 1_000); + assert_eq!(freshness.next_update_unix_seconds(), 1_100); + assert_eq!(freshness.maximum_window_seconds(), MAXIMUM_WINDOW_SECONDS); + assert_eq!(freshness.evaluate(1_000), Ok(())); + assert_eq!(freshness.evaluate(1_099), Ok(())); + assert_eq!( + freshness.evaluate(999), + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }) + ); + assert_eq!( + freshness.evaluate(1_100), + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_rejects_empty_or_reversed_windows() { + for (this_update, next_update) in [(1_000, 1_000), (1_001, 1_000)] { + assert_eq!( + RevocationMaterialFreshness::new(this_update, next_update, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: this_update, + next_update_unix_seconds: next_update, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_requires_a_bounded_local_policy_window() { + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_100, 0), + Err(RevocationMaterialFreshnessError::ZeroMaximumWindow) + ); + + let exact_maximum = RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); + assert!(exact_maximum.is_ok()); + + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_301, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }) + ); + + assert_eq!( + RevocationMaterialFreshness::new(1, u64::MAX, 1), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: u64::MAX - 1, + maximum_window_seconds: 1, + }) + ); +} + +#[test] +fn revocation_freshness_errors_are_stable_and_source_free() { + let invalid = RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: 1_000, + next_update_unix_seconds: 1_000, + }; + let zero_maximum = RevocationMaterialFreshnessError::ZeroMaximumWindow; + let too_long = RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }; + let future = RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }; + let stale = RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }; + + assert_eq!( + invalid.to_string(), + "revocation material window is invalid: thisUpdate 1000 must be before nextUpdate 1000" + ); + assert_eq!( + zero_maximum.to_string(), + "revocation material maximum freshness window must be greater than zero" + ); + assert_eq!( + too_long.to_string(), + "revocation material window is 301 seconds, exceeding the local maximum of 300 seconds" + ); + assert_eq!( + future.to_string(), + "revocation material is not usable at trusted time 999; thisUpdate is 1000" + ); + assert_eq!( + stale.to_string(), + "revocation material is stale at trusted time 1100; nextUpdate is 1100" + ); + + for error in [invalid, zero_maximum, too_long, future, stale] { + assert!(error.source().is_none()); + } +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 4ac62061d..f750922fd 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -227,8 +227,6 @@ 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 transfers a non-cloneable `QueryNodes` / `SemanticObservation` protocol-use proof into `bind_current_nodes` on the exact current session, browsing context, canonical origin, and document epoch. Navigation or TypedInput proofs fail closed. That composition still performs no browser I/O and does not authorize typed input. - ## 14. Action operations ### `browser.act` diff --git a/docs/README.md b/docs/README.md index 775dd0de6..1ea57ad29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,4 +87,12 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. +### Proposed decisions introduced by active feature work + +- [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) + +ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. diff --git a/docs/adr/0010-session-context-bound-node-authority.md b/docs/adr/0010-session-context-bound-node-authority.md index 97e792948..9c085bdd3 100644 --- a/docs/adr/0010-session-context-bound-node-authority.md +++ b/docs/adr/0010-session-context-bound-node-authority.md @@ -30,7 +30,6 @@ 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 transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before translating an admitted `locateNodes` `sharedId` into an `ObservedNodeHandle`. Navigation or TypedInput proofs fail closed and cannot mint observation handles. ## References diff --git a/docs/adr/0016-bap-task-lifecycle-authority.md b/docs/adr/0016-bap-task-lifecycle-authority.md new file mode 100644 index 000000000..54fae8607 --- /dev/null +++ b/docs/adr/0016-bap-task-lifecycle-authority.md @@ -0,0 +1,123 @@ +# ADR 0016: BAP task lifecycle and state authority + +- **Status:** Proposed +- **Date:** 2026-08-22 +- **Supersedes:** None +- **Superseded by:** None + +## Context + +OriginWeave needs a deterministic lifecycle primitive for governed browser-agent work before durable BAP transport, persistence, idempotency, or crash recovery can be added safely. A task state is security-relevant because downstream components may use it to decide whether work may start, resume, complete, reconcile, or terminate. If adapters, persistence layers, browser drivers, or recovery code can mint state independently, OriginWeave would inherit ambient execution authority from whichever boundary supplied the most convenient state value. + +The `originweave-bap` crate therefore introduces a typed in-memory state machine with monotonic transition receipts and fail-closed recovery validation. The crate deliberately owns no browser, network, model, secret, approval, persistence, tenant-authentication, or protocol authority. External protocols may project lifecycle intent into this kernel, but protocol metadata cannot bypass its transition rules or upgrade a task's authority. + +## Decision drivers + +- Keep task-state authority explicit and deterministic rather than distributed across protocol adapters. +- Prevent stale, unreachable, or terminal lifecycle snapshots from reopening governed work. +- Preserve a monotonic transition sequence suitable for later durable replay evidence without claiming persistence today. +- Separate lifecycle state from browser, network, secret, model, approval, and tenant authority. +- Make waiting, checkpoint, reconciliation, completion, cancellation, expiry, and dead-letter behavior typed and testable. +- Keep recovery validation fail closed when a supplied state/sequence pair cannot arise from the reviewed state machine. + +## Assumptions and authority boundaries + +- The lifecycle is an in-memory logical primitive; it is not a durable task repository. +- Creating or restoring a lifecycle does not authenticate a caller, tenant, browser session, document, origin, destination, secret, model, approval, or external side effect. +- A transition receipt proves only what this in-memory lifecycle instance accepted. It is not durable audit evidence until a separate authenticated persistence boundary stores it. +- Waiting for approval is a lifecycle condition, not proof that approval exists. A later approval authority must independently authenticate and authorize any decision before resumption. +- `Succeeded` is entered only after a caller asserts that its separately governed post-condition has been verified; the lifecycle does not itself verify that post-condition. +- Reconciliation and dead-letter states preserve control-flow intent only. Durable reconciliation evidence remains the responsibility of a later persistence/recovery boundary. + +## Options considered + +### Let each BAP or MCP adapter own its own state machine + +Rejected. Adapter-local state machines would duplicate policy, make recovery semantics drift by protocol, and allow external protocol metadata to become implicit OriginWeave execution authority. + +### Store task state as an unrestricted string or integer + +Rejected. Untyped state admits unknown values, weakens exhaustive transition review, and makes invalid or stale recovery snapshots difficult to reject deterministically. + +### Allow restored state to resume whenever the state name looks resumable + +Rejected. State-only recovery loses monotonic history. A state/sequence pair that cannot be reached through the reviewed transitions must fail closed rather than becoming execution authority. + +### Centralize logical lifecycle transitions in a typed Rust kernel + +Selected. + +## Decision + +If Accepted, OriginWeave applies these lifecycle rules: + +1. **One typed kernel owns logical BAP task state.** `originweave-bap` is the canonical state-transition authority for the task lifecycle represented by this contract. Protocol adapters may request transitions but do not mint lifecycle state directly. +2. **Transitions are explicit and fail closed.** The kernel accepts only reviewed event/state combinations. Invalid events preserve the existing state and sequence and return a typed error. +3. **Terminal states never reopen.** `Succeeded`, `Failed`, `Cancelled`, `Expired`, and `DeadLettered` reject later lifecycle events. +4. **Waiting and checkpoint states require explicit resumption.** Approval wait, external-input wait, and checkpoint states do not silently become running work. +5. **Reconciliation is distinct from normal suspension.** A task in `ReconciliationRequired` cannot use the ordinary resume path; it requires explicit reconciliation resolution or governed dead-letter handling. +6. **Transition sequence is monotonic and bounded.** Every accepted transition advances the sequence exactly once. Sequence exhaustion fails closed instead of wrapping. +7. **Recovery validates reachability.** A supplied state/sequence snapshot must be reachable under the same reviewed state machine. Unreachable snapshots are rejected with a typed restore error. +8. **Lifecycle state grants no ambient authority.** A `Running`, resumable, or otherwise valid lifecycle state does not authorize browser I/O, network destinations, secret resolution, model access, approvals, external protocol operations, or tenant access. Those authorities must be revalidated by their owning boundaries. +9. **Durability is a separate owner.** This contract does not claim atomic persistence, idempotency, locking, authenticated replay evidence, side-effect reconciliation, or crash-safe recovery. Later durable components must bind those concerns to lifecycle receipts without weakening this state authority. +10. **External protocol state is projected, not inherited.** BAP, MCP, WebDriver BiDi, CDP, or other adapters may translate reviewed external events into typed lifecycle requests only after their own authentication and policy checks. External state labels cannot overwrite the kernel directly. + +## Consequences + +OriginWeave gains one reviewable state authority that later transport, idempotency, persistence, and recovery slices can compose without duplicating transition semantics. Invalid transitions and unreachable recovery snapshots have deterministic typed failures, while terminal and reconciliation states have explicit closure behavior. + +The trade-off is that adapters and durable stores must perform explicit mapping and validation instead of assigning state directly. The current slice also cannot claim commercial crash recovery until durable authenticated evidence and side-effect reconciliation are implemented separately. + +## Failure and degraded behavior + +- An invalid event returns a typed transition error and leaves state/history unchanged. +- A terminal lifecycle rejects all later events rather than reopening work. +- Sequence exhaustion returns a typed failure rather than wrapping or silently reusing an identifier. +- An unreachable restored state/sequence pair is rejected rather than normalized into a nearby valid state. +- Missing browser, tenant, policy, destination, secret, approval, persistence, or recovery authority is not converted into lifecycle success. +- If a future adapter cannot map external protocol state without ambiguity, it must fail closed or require reconciliation rather than inventing a lifecycle transition. + +## Security / privacy / governance impact + +This decision narrows authority. It prevents external protocol metadata, stale snapshots, or arbitrary state assignment from becoming execution authority and keeps lifecycle state separate from sensitive-data, secret, browser, network, model, approval, and tenant boundaries. The lifecycle stores no secret values or personal-data payloads by itself. Any future persistent representation must independently satisfy OriginWeave data-governance, retention, tenant-isolation, integrity, and evidence requirements. + +## Tests and acceptance evidence + +The owning branch must keep executable evidence for: + +- the reviewed created/admitted/running/waiting/checkpointed/reconciliation/terminal transition paths; +- fail-closed invalid transitions with no sequence advancement; +- terminal irreversibility; +- cancellation and expiry across allowed pre-dispatch and suspended states; +- explicit reconciliation resolution and governed dead-letter behavior; +- monotonic transition receipts and sequence-exhaustion failure; +- recovery acceptance for reachable snapshots and rejection for unreachable snapshots; and +- deterministic public Rust error contracts. + +Repository contracts must also require this ADR so the `originweave-bap` control-plane boundary cannot remain undocumented while the crate is present. Exact protected-main acceptance still depends on current-head CI, exact owned-production coverage, rustdoc, security evidence, review, live governance, and integration state; ADR presence does not substitute for those gates. + +## Migration and rollback + +No database migration is introduced. Existing callers on this branch construct the typed lifecycle directly. A future durable task repository should persist state and transition evidence in an authenticated form that can be validated by this kernel rather than introducing a second transition authority. + +Rollback before acceptance is removal of the active BAP lifecycle branch and its Proposed ADR. After acceptance, rollback or replacement must preserve fail-closed terminal/recovery semantics or explicitly supersede this ADR with a reviewed migration for any persisted lifecycle representation. + +## Open follow-ups + +- Bind durable idempotency receipts to exact accepted transitions without making retry metadata task authority. +- Define authenticated persistence, atomicity, and concurrency semantics for lifecycle plus command evidence. +- Define crash-recovery classification and reconciliation for ambiguous external side effects. +- Map authenticated BAP/MCP transport messages into typed lifecycle requests without ambient protocol authority. +- Propagate cancellation and expiry into real browser/process supervision only after the corresponding runtime authority exists. + +## Supersession / reversal conditions + +Supersede this ADR if OriginWeave replaces the BAP lifecycle model, introduces a materially different durable event-sourced task authority, or moves canonical task-state ownership to another reviewed component. A successor must preserve explicit state authority, terminal fail-closure, monotonic recovery evidence, and the rule that lifecycle state cannot mint unrelated browser/network/secret/model/approval/tenant authority. + +## References + +ContextualWisdomLab. (2026). *OriginWeave architecture* [Repository specification]. *OriginWeave*. [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) + +ContextualWisdomLab. (2026). *OriginWeave architecture decision records* [Repository specification]. *OriginWeave*. [`README.md`](README.md) + +ContextualWisdomLab. (2026). *Agent development contract* [Repository specification]. *OriginWeave*. [`../../AGENTS.md`](../../AGENTS.md) diff --git a/docs/adr/0106-provenance-evidence-model.md b/docs/adr/0106-provenance-evidence-model.md index 0e2741f37..09cb0d7ca 100644 --- a/docs/adr/0106-provenance-evidence-model.md +++ b/docs/adr/0106-provenance-evidence-model.md @@ -33,29 +33,47 @@ OriginWeave maintains provenance-native evidence with stable identifiers for ses WARC and PROV are interoperability/export contracts, not substitutes for OriginWeave's internal authorization or evidence schema. A WARC record can contain untrusted or sensitive payload bytes and therefore inherits capture, retention, encryption, and export policy. A PROV entity/activity/agent relation records derivation or responsibility; it cannot manufacture authentication, authorization, durable completion, or tenant ownership not established by the producing system. +### Versioned extraction-schema binding + +A versioned `ExtractionSchema` is the binding contract for typed extraction before any capture persistence or export format is allowed to claim semantic authority. Each schema version contains an ordered, non-empty set of unique `ExtractionField` definitions. Schema-version and field identifiers are bounded to 128 encoded bytes, begin with a lowercase ASCII letter, and thereafter admit only lowercase ASCII letters, digits, `_`, or `-`. One schema admits at most 256 fields. + +Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. Cardinality and required status form one internally consistent presence contract: `One` is necessarily required, `ZeroOrOne` is necessarily optional, and `Many` may be marked required or optional because this value-object layer does not yet define a minimum collection item count. Contradictory `One`/optional or `ZeroOrOne`/required declarations fail closed during field construction. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. + +At this value-object boundary, the version identifier is immutable schema identity; there is deliberately no registry that silently treats two different field contracts as compatible merely because their version strings compare or sort in a particular way. Callers changing a field identifier, value type, cardinality, required status, normalization rule, or admitted source-channel set must use a distinct reviewed schema version and perform any migration/compatibility decision at an explicit higher layer. The current schema object does not itself read browser data, materialize extracted values, persist artifacts, execute models, or change governance policy. Those capabilities require separately authorized runtime boundaries and are not implied by schema construction. + ## Consequences Capture becomes a designed product surface rather than incidental logging. Storage and retention need budgets. Consumers can distinguish a model claim from source evidence and an action request from verified completion. Export adapters can target WARC, provenance graphs, audit streams, or buyer-specific schemas. +A schema consumer can also determine the exact field/type/cardinality/normalization/source contract it reviewed rather than relying on free-form extraction instructions. Schema evolution is explicit instead of being inferred from mutable field definitions; runtime compatibility, migrations, durable storage, and extracted-value validation remain separate implementation work until those boundaries are delivered. + ## Failure and degraded behavior If mandatory evidence cannot be recorded durably enough for a governed state-changing action, the action fails before execution or reports an explicit unverifiable failure; it is never marked proved. Read-only operations may degrade to reduced evidence only when the API contract declares that mode. Corrupt or incomplete evidence is quarantined rather than silently accepted. +Invalid or oversized extraction identifiers, contradictory cardinality/required declarations, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. + ## Security / privacy / governance impact Evidence is tenant-scoped, selectively disclosed, encrypted as appropriate, retention-bounded, and auditable. Credential-bearing headers, cookies, secret values, and sensitive form data are excluded or transformed according to explicit schema policy. Integrity metadata and immutable artifact identities support tamper detection without claiming external certification. `docs/DATA_GOVERNANCE.md` defines the disclosure/retention boundary for protected content and derived artifacts. +The extraction-schema contract does not modify governance authority. It describes admissible typed fields and reviewed evidence-channel classes only. In particular, declaring `NetworkResponse` or `ModelInterpretation` does not authorize network access, model execution, protected-data disclosure, approvals, retention, or export; those remain governed by their existing owning boundaries. + ## Tests and acceptance evidence Require provenance-link tests, credential-leak tests, integrity/corruption tests, crash-recovery tests, WARC/export conformance where implemented, PROV relation/schema tests where implemented, retention/deletion tests, tenant-isolation tests, and end-to-end checks that state-changing actions link request, policy, approval, execution, and post-condition as separate records. Export tests must prove that disabled or unauthorized source bodies never appear merely because metadata provenance is exportable. +The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, consistent cardinality/required combinations and contradictory-combination rejection, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. + ## Migration and rollback Introduce stable evidence identifiers and schema versions before changing export formats. Migrations preserve old evidence semantics or explicitly mark unavailable fields. Rollback may revert an exporter but cannot collapse mandatory action and policy evidence into opaque logs. +Extraction contract changes that alter field identity or semantics require a new reviewed schema version rather than mutating the meaning of an existing version. Rolling back a consumer may stop accepting a newer version, but it must not reinterpret that newer contract as an older one or silently discard required fields. + ## Open follow-ups -Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. +Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. Add the runtime that validates concrete extracted values against an `ExtractionSchema`, plus explicit migration/compatibility policy when durable schema registration is introduced. ## Supersession / reversal conditions diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index e3c0bf657..fb1bf2e17 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -36,11 +36,13 @@ MCP version negotiation is independent of the OriginWeave Protocol version. As o ### Current implementation boundary -The complete MCP adapter remains **Planned**. Active PR #168 is narrower **IMPLEMENTED_ON_ACTIVE_PR** evidence inside the Rust control plane: it validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. +The complete MCP adapter remains **Planned**. Protected main now contains the narrower bounded Rust `tools/call` routing/action-policy foundation merged through PR #168. That protected-main foundation validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. -PR #168 does not implement Streamable HTTP transport parsing, complete request `_meta` validation, `tools/list` serialization/caching/pagination, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` therefore must continue to describe MCP as planned until this active-PR evidence is integrated, and even after integration only the merged bounded routing foundation may be called implemented; the full adapter remains planned until its remaining acceptance boundaries ship. +Active PR #170 is a separate non-shipped refinement on top of that protected-main catalog. It adds one conservative typed `tools/list` request/result contract: both protocol-version fields are required and bounded before comparison, client-capability metadata must be present without becoming authority, both routing/body methods are syntax-bounded before correlation, only exact `tools/list` is admitted, and every caller-supplied cursor is rejected because the current fixed catalog issues none. The result is one complete page with zero freshness, private cache scope, and no continuation cursor. -The version boundary is explicit: the routing foundation accepts only MCP `2026-07-28`; it does not infer compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. +Neither protected main nor PR #170 implements Streamable HTTP transport parsing, JSON-RPC/HTTP serialization, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, general pagination/subscription state, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` may therefore describe only the bounded merged `tools/call` foundation as implemented; the full MCP adapter remains planned, and the `tools/list` refinement remains active-PR evidence until separately integrated. + +The version boundary is explicit: the protected-main routing foundation and active discovery refinement accept only MCP `2026-07-28`; neither infers compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. ## Consequences @@ -58,7 +60,7 @@ Protocol validation occurs before messages influence policy. Tool/page-provided Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. -For active PR #168 specifically, acceptance additionally requires deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. +For the protected-main `tools/call` foundation, acceptance includes deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and integrated review evidence from PR #168. For active PR #170, exact-current acceptance additionally requires bounded protocol metadata before cross-field comparison, required client-capabilities presence, bounded `tools/list` method correlation, rejection of unissued cursors, deterministic result/cache semantics, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. ## Migration and rollback @@ -66,7 +68,7 @@ Adapters are independently versioned and can be canaried. Clients migrate throug ## Open follow-ups -Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP discovery/serialization/cache behavior, and MCP/WebMCP schema isolation. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP transport serialization, authenticated deployment, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions diff --git a/docs/adr/README.md b/docs/adr/README.md index 416231b1c..5f9e2a878 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,16 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +### Proposed decisions introduced by active feature work + +| ADR | Decision | Status | Governs | +|---|---|---|---| +| [0016](0016-bap-task-lifecycle-authority.md) | BAP task lifecycle and state authority | Proposed | BAP task states, transitions, recovery validation, transition sequencing, and authority separation | + +ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and active-PR, non-protected-main maturity. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. ## Index completeness rule diff --git a/docs/doctoring.md b/docs/doctoring.md index 7395a67e6..ec51daaf3 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,12 +8,6 @@ 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, 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 obtains a non-cloneable SemanticObservation protocol-use proof and transfers that proof by ownership into `bind_current_nodes`, which refuses Navigation and TypedInput proofs before translating 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, 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. - The final Model Context Protocol `2026-07-28` specification defines the currently reviewed MCP generation. Its stateless request model carries protocol metadata per request and standard Streamable HTTP routing metadata for MCP operations; its Tools surface defines bounded, case-sensitive tool names and requires clients to treat tool annotations as untrusted unless supplied by a trusted server. OriginWeave therefore keeps MCP outside the product authority model. Active PR #168 implements only a bounded Rust `tools/call` routing/action-policy foundation for that exact generation; the complete transport, request-metadata, discovery, OAuth, browser, secret, and persistence adapter remains planned and cannot be inferred from the core routing primitive. ### Browser origin equivalence @@ -30,6 +24,12 @@ RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers RFC 9700 is the current Best Current Practice for OAuth 2.0 security. It requires access tokens to be restricted in lifetime and treats long-lived bearer credentials as a standing authorization risk. An OriginWeave `extension_grant` that matches extension identity, session, browsing context, and canonical origin but has no exclusive expiry remains usable after the Agent Task window ends. OriginWeave therefore requires the grant to carry an exclusive `expires_at_epoch_seconds` deadline and the request to carry trusted `now_epoch_seconds`. Evaluation fails closed when `now >= expires_at`, matching the existing sensitive-handle exclusive-expiry rule. Page, extension, and model clocks are not trusted time. This slice does not bind task identity, install an extension, or mint Agent capabilities from Manifest V3 permissions. +### Release-limitation presentation safety + +Unicode 17.0 defines `Default_Ignorable_Code_Point` in the Unicode Character Database and records the exact derived set in the versioned `DerivedCoreProperties.txt` data file. Those characters can be invisible or alter presentation without supplying an ordinary visible glyph. OriginWeave therefore treats the Unicode 17.0 derived property as a pinned presentation-safety input for buyer-visible release-limitation metadata, in addition to rejecting control characters and non-canonical leading or trailing whitespace. The admitted text is not silently normalized: accepted content retains its exact bytes, while ambiguous presentation characters and surrounding whitespace fail closed so one release claim cannot acquire multiple stored spellings. This is a bounded metadata-identity policy, not a claim of complete Unicode spoofing resistance or semantic text equivalence. + +Unicode Standard Annex #15, revision 57 for Unicode 17.0.0, defines canonical equivalence and NFC and states that normalized equivalent strings have a unique binary representation. A release limitation is an identity-bearing buyer artifact, so OriginWeave rejects canonically equivalent non-NFC spellings instead of silently rewriting them. The production boundary uses only `unicode_normalization::is_nfc`; accepted strings remain byte-for-byte caller input. Rust's standard library does not provide Unicode normalization, so `unicode-normalization` is pinned exactly to 0.1.25. The reviewed crate implements UAX #15 normalization, declares Rust 1.36+ compatibility (below OriginWeave's Rust 1.97.1 baseline), is dual MIT/Apache-2.0 licensed, and adds only `tinyvec`/`tinyvec_macros` transitively in this workspace lockfile. The dependency is narrow, deterministic, non-networked, and maintained through the existing locked-dependency/security-scan process; any future Unicode-version or crate-version movement requires renewed normalization and supply-chain review. + ### Resolved destination and redirect safety Canonical origin identity is not a network-destination authorization. The IANA IPv4 and IPv6 Special-Purpose Address Space registries enumerate blocks whose source, destination, forwardability, globally reachable, and protocol-reserved properties differ. Both registries were last updated on 9 October 2025 and explicitly warn that registry presence does not guarantee routability in a particular local or global context. RFC 6890 established the common special-purpose registry fields, and RFC 8190 replaced the ambiguous `global` field with `globally reachable`. @@ -90,6 +90,8 @@ RFC 9309 standardizes robots parsing, matching, error handling, and caching. It W3C PROV-O supplies interoperable Entity, Activity, Agent, derivation, attribution, and responsibility concepts. ISO 28500:2017, confirmed in 2023, defines WARC storage for protocol payloads, control information, metadata, transformations, duplicate detection, integrity, and segmentation. OriginWeave uses source hashes and locators in the safety kernel, then adds WARC and PROV adapters as separately testable modules. +The versioned `ExtractionSchema` is an admission and interpretation contract for typed extracted fields: each field is bounded, declares a value type, cardinality, normalization rule, and a canonical duplicate-free set of reviewed source-channel classes. That declaration does not create browser, network, model, secret, storage, retention, disclosure, or governance authority. PROV/WARC interoperability is therefore layered after the schema contract rather than inferred from it. + RFC 3986 remains Internet Standard STD 66 for generic URI syntax. RFC 8820 is the current URI design-and-ownership Best Current Practice; it obsoletes RFC 7320 and updates RFC 3986 without replacing RFC 3986's path grammar. Section 3.3 of RFC 3986 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave's shared evidence-path validator therefore applies that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators to both `NetworkEvidence::capture` paths and provenance source-URL paths. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This fail-closed syntax tightening affects both evidence surfaces; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. ### AI risk and prompt injection @@ -180,22 +182,18 @@ 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 +The Unicode Consortium. (2025). *DerivedCoreProperties-17.0.0.txt* [Data file]. https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt -Unicode Consortium. (2025a, September 4). *Unicode bidirectional algorithm* (Unicode Standard Annex #9, Version 17.0.0). https://www.unicode.org/reports/tr9/ +The Unicode Consortium. (2025, July 30). *Unicode Standard Annex #15: Unicode normalization forms* (Revision 57, Unicode 17.0.0). https://www.unicode.org/reports/tr15/ -Unicode Consortium. (2025b, September 4). *Unicode security mechanisms* (Unicode Technical Standard #39, Revision 32). https://www.unicode.org/reports/tr39/tr39-32.html +Unicode-RS Project Developers. (2025). *unicode-normalization 0.1.25* [Computer software]. https://docs.rs/unicode-normalization/0.1.25/unicode_normalization/ 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/ -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 dbf3ef731..052ecf2aa 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -8,15 +8,9 @@ This addendum complements the main doctoring record. The main record already car ## WebDriver BiDi -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. +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. -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. - -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). +Primary source: World Wide Web Consortium, *WebDriver BiDi*. ## Chrome Manifest V3 @@ -44,6 +38,10 @@ Primary sources: Chrome for Developers, *WebMCP*; *WebMCP tool security*; *Agent The Model Context Protocol project released specification version `2026-07-28` on 28 July 2026. That release moved the protocol core toward stateless request/response operation and removed the earlier protocol-session assumptions described by previous releases. OriginWeave therefore keeps durable browser state in explicit OriginWeave application handles and exposes MCP only as a high-level adapter to the Rust runtime. MCP clients or servers do not connect models directly to Chromium/CDP authority. +The final `2026-07-28` schema requires every client request to carry `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` in request `_meta`; client capabilities are request-scoped and servers must not infer them from prior requests. `io.modelcontextprotocol/clientInfo` is optional/SHOULD rather than authorization evidence. For Streamable HTTP, `MCP-Protocol-Version` must agree with the body protocol version, `Mcp-Method` is required for every request, and `Mcp-Name` is required only for named operations such as `tools/call`, `resources/read`, and `prompts/get`, not `tools/list`. OriginWeave's typed `tools/list` admission boundary therefore independently requires the transport protocol-version header and body `_meta` protocol version, rejects disagreement or an unsupported generation, requires per-request client-capabilities presence without treating its contents as OriginWeave authority, validates routing/body `tools/list` method agreement, and does not invent a name header. It rejects any supplied cursor because the current fixed catalog emits no `nextCursor`; this is a conservative local invariant against accepting pagination state OriginWeave never issued, not a claim that MCP forbids `tools/list` cursors generally. + +The same specification requires every Result to carry `resultType`, using `complete` for a terminal result, and adds explicit cache hints for cacheable result families including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore binds `resultType = complete`, chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. + Primary sources: Model Context Protocol, *2026-07-28 Specification* and the maintainers' official release announcement. ## Provenance standards @@ -52,14 +50,15 @@ 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, 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. -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. +1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. +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. Require modern MCP per-request protocol version and client capabilities from request `_meta`; on Streamable HTTP require the matching protocol-version header and exact method routing, while treating optional client identity metadata as non-authoritative. +7. Bind mandatory MCP result disposition and cacheable-list metadata to reviewed typed results; use a complete terminal result with zero freshness and private scope unless a separate reviewed policy proves broader semantics safe. Reject a `tools/list` cursor while the current fixed page has never issued one. +8. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. +9. Treat WARC/PROV as provenance representations, not policy or truth escalation. ## References — APA 7th @@ -81,12 +80,6 @@ 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, 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 +International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html \ No newline at end of file diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md index 4aa0ace97..c61dfee63 100644 --- a/docs/product-roadmap.md +++ b/docs/product-roadmap.md @@ -70,7 +70,6 @@ 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 transfers a SemanticObservation protocol-use proof by ownership into `bind_current_nodes` 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 234e6ae5c..8a702c75f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,56 +2,75 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. -## Observed snapshot: 2026-08-24 +## Observed snapshot: 2026-08-26 ### Protected-main truth -- Protected `main` remained at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was refreshed. -- Phase 0 is documented as complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. +- Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` for this snapshot. Since the 2026-08-24 observation (`0841d2ab`), protected `main` absorbed #196 (dated gap baseline publication), #216 (RFC 3986 evidence-path syntax enforcement), #194 (branch-coverage nightly and toolchain tracking refresh), #168 (typed MCP stateless tool-routing foundations), and #151 (exact crash-root termination before crash credit). +- Phase 0 remains complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. - Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. - HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. - Active pull requests remain evidence, not shipped behavior. Successful checks on a feature or stacked branch do not prove that protected `main` contains the capability or that a child can merge before its prerequisite. ### Open pull requests -The live repository contained **158 open pull requests: 44 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Compared with the prior **2026-08-24 158-PR snapshot**, the current inventory is 32 PRs smaller. Intervening queue consolidation includes #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 being merged into their immediate stacked prerequisites, while PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. + +#### 2026-08-26 maintenance-loop record + +The interactive maintenance loop performed the following verified state changes on exact heads; none of them is protected-main behavior until merged: + +| Action | Exact evidence | +|---|---| +| Supersession closure | #153 closed with replacement evidence: base-stack tip (`4da223ac`) already implements `_terminate_owned_process_bounded` exit-race tolerance that supersedes the branch delta | +| Conflict reconciliation | Merge commits pushed to #37 (`27f6acd6`, ci.yml aligned to reviewed `nightly-2026-08-18` pin), #149 (`7852a540` + rustfmt fix `54f96008`), #152 (`65b0c705`), #173 (`ecc9574a`), #175 (`765c88f6`, keeps `crate_root.rs` naming) | +| Governance remediation (#212) | #43 reconciled with main in `04e262d5`; the `chrome_sandbox` workflow mutation was first removed, then restored under recorded independent authorization (issue #212 option (b)) because the PR's own contract test fails closed without it; fresh exact-head checks re-ran on the restored head | +| Security finding fix (#124) | Strix vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated in `30cc458b`: audited workflow paths now restricted to a canonical ASCII alphabet with homoglyph/fraction-slash/fullwidth regression contract tests; CHANGELOG updated | +| Fail-closed provider re-dispatch | ~21 failed Strix required-check runs re-dispatched on unchanged exact heads; completed reruns returned success on #46, #48, #156, #157, #159, #218, and #219 heads at snapshot time; cancellations only where newer heads superseded the run | +| Current-head review re-dispatch | Central merge-scheduler dispatches sent for #47, #62, #63, #65, #74, #166, #173, #175, and #220 because their stale `CHANGES_REQUESTED` verdicts cited coverage-evidence results that are green on the same heads today | + +#### Organization review-pipeline congestion record + +Between 2026-08-26T02:44Z and 2026-08-26T03:35Z the organization-wide Actions queue exhibited a systemic backlog: scheduler, OpenCode-review-dispatch, Noema, and Strix runs across `.github`, `naruon`, `pg-erd-cloud`, and OriginWeave sat `queued`/`pending` while only single-digit runs were `in_progress`. This delays every current-head AI review and therefore every ruleset-gated merge. It is an infrastructure-capacity signal, not a code defect, and it does not authorize merging without current-head review evidence. Representative active workstreams at this snapshot were: | Workstream | Representative active PR evidence | Delivery boundary | |---|---|---| -| Product baseline | #196 | Ready/non-draft documentation PR; all exact-head checks passed and review threads resolved, blocked only by the reviewer-provisioning gap below | -| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; Strix re-scan was re-dispatched after a provider-unavailability failure | -| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; #218's Strix re-scan was re-dispatched after provider unavailability | -| Evidence path conformance | #216 | Ready/non-draft RFC 3986 evidence-path syntax enforcement | -| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #208's Strix re-scan was re-dispatched after provider unavailability | -| WebDriver BiDi transport | #188 through #205 | Draft stack exercising framed `locateNodes` exchange over a bounded WebSocket opening path; still no authenticated browser-process provenance, semantic task execution, or protected-main shipment | -| MCP adapter | #168 and #170 | Typed MCP routing and conservative `tools/list` metadata are active-PR foundations; complete authenticated transport, durable task lifecycle, cancellation/resume, and browser execution remain open under #200 | -| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#153 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | +| Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | +| Presentation identity | #229 at `585a7d5545b13f18d76f79100ff4d47ac423e861` onto `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | Ready/non-draft local privacy kernel; all observed exact-head checks except Strix passed, but the PR remains blocked and review-required, and no Chromium adapter or protected-main shipment is claimed | +| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | +| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | +| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | +| WebDriver BiDi transport | #188 through #205 | Active stack whose top #205 merged into its prerequisite branch, not protected `main`; it exercises framed `locateNodes` exchange over a bounded WebSocket opening path, but authenticated browser-process provenance, semantic task execution, and protected-main shipment remain unproven | +| MCP adapter | (#168 merged) and #170 | Typed MCP routing foundations are protected-main behavior since 2026-08-24; conservative `tools/list` cache metadata remains active-PR evidence with a Strix rerun in flight | +| Workflow-registry audit | #124 | Real Strix finding vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated on head `30cc458b` with regression contract tests; fresh exact-head checks and review re-running | +| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#152 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | | Durable WARC/PROV evidence | #210, #217 | Bounded WARC resource records and PROV JSON-LD binding are draft active-PR foundations; durable ownership, replay, retention/deletion, and browser side-effect reconciliation remain open | -| Manifest V3 and native messaging | #27 and its active extension/native-host stack, including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven | +| Manifest V3 and native messaging | #27, #43 governance remediation, and the extension/native-host stack including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven; #43's sandbox workflow mutation is now owner-authorized under issue #212 option (b) | | Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | -| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority is active-PR evidence; it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | +| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority reconciled with main (`54f96008`); it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | -Draft PR #205 is the current top WebDriver BiDi locate-nodes slice; its opening-path prerequisites #195 and #198 remain draft evidence and cannot be treated as shipped behavior. +PR #205 head `f427aa69151987d7e3369bd96d5739ea38d0f7ad` merged as `6c5ef5e2079d54c617183ecfa757e406f48f0aea` into stacked prerequisite branch `feat/webdriver-bidi-websocket-frame-transport` at base `c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92`. Its successful exact-head checks are stacked-branch integration evidence only; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`. #### Current exact-head active PR evidence -The following newest product slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: +The following newest slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: | PR | State | Exact base head | Exact head | |---|---|---|---| -| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` | -| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` | -| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | -| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` | -| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` | +| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` | +| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | +| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` | +| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` | +| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | +| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` | -These rows are delivery evidence only. #73's latest Strix remediation is locally verified but its required policy workflows remain queued; #208–#211 are stacked product-gap foundations with no protected-main promotion. None has counted independent approval in the current collaborator inventory. +These rows are delivery evidence only. None has counted independent approval in the current collaborator inventory, and predecessor rows from earlier snapshots are retained below as regression anchors that must never be promoted to current-head evidence. -#### Refreshed exact-head active PR evidence: 2026-08-24 +#### Regression-anchor exact-head evidence: superseded 2026-08-24 rows -The following newest slices were re-fetched from GitHub for this snapshot. Heads have moved since the 2026-08-21 rows above; those predecessor rows are retained as regression anchors and must never be promoted to current-head evidence: +The following rows were current on 2026-08-24 and are retained only as regression anchors; every listed head has since been superseded or merged and must never be promoted to current-head evidence: | PR | State | Exact base head | Exact head | |---|---|---|---| @@ -72,7 +91,9 @@ The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 ### Required-check provider failure record -On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. +On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24 and again on 2026-08-26. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. + +On 2026-08-26 rerun outcomes were verified per run: completed reruns returned `success` on the heads of #46, #48, #156, #157, #159, #218, and #219; several earlier runs for #37, #43, and #149 were cancelled only because conflict-reconciliation pushes created newer heads with fresh scans; remaining reruns were still in flight at snapshot time. One rerun (#124) produced a real MEDIUM finding (vuln-0001) instead of provider noise; that finding was remediated on the branch head rather than suppressed, preserving the fail-closed contract. #### #195/#198 WebDriver BiDi opening path status @@ -80,15 +101,15 @@ Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket #### #149 VPN/profile intent status -It remains draft evidence and cannot be treated as shipped behavior. #149 describes bounded WireGuard/IKEv2 profile authority, but it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. +PR #149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. -The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely. +The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely; this loop exercised that policy by closing superseded #153 with replacement evidence. ### Review and merge authority -The active `CWL Central required workflows` ruleset requires two approving reviews, approval after the last push, resolved review threads, and configured required workflows. The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. +The active `CWL Central required workflows` ruleset (re-fetched for this snapshot) requires one approving review, resolved review threads, no last-push approval requirement, `merge`/`squash` merge methods, and seven configured required workflows (`close-empty-pr`, `opencode-review`, `pr-review-merge-scheduler`, `security-scan`, `strix`, `sast-semgrep`, `noema-review`). The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. -This gap does not authorize self-approval, administrative bypass, stale-head merge, or weaker checks. Exact current-head checks, security gates, complete coverage, rustdoc/Clippy, thread resolution, and branch protection remain mandatory. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. +This gap does not authorize self-approval, stale-head merges, administrative bypass, or weaker checks. Because the current GitHub ruleset independently requires a counted approval, the solo-maintainer hold does not satisfy the live merge gate: an eligible non-author collaborator must submit a formal `APPROVED` review on the current head. Until that reviewer-provisioning gap is repaired, protected-main merges stop even when exact-head checks, security gates, complete coverage, rustdoc/Clippy, threads, and AI-review evidence are otherwise complete. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. ### Open issues and operational signals @@ -100,7 +121,7 @@ This gap does not authorize self-approval, administrative bypass, stale-head mer | #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | | #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | | #187 | Manual-authority review of the coverage-diagnostics workflow delta | -| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation | +| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation — **option (b) executed 2026-08-26** with owner-directed authorization recorded on the issue and the mutation restored on the reconciled branch; re-evaluate if the authorization record is contested | | #215 | Governance: restore an enforceable protected-main policy that does not create a routine admin bypass | | #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | | #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | @@ -127,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 158-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 126-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition @@ -146,9 +167,9 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Re-fetch all 158 open PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. Re-dispatch required checks that failed closed on provider infrastructure instead of code defects. -2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. -3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. +1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain the current ruleset's counted `APPROVED` review from an eligible non-author collaborator; OpenCode approval or skip evidence does not substitute for that GitHub review. If no eligible approver exists, record the reviewer-provisioning gap and do not merge. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. +2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. +3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. 5. Implement #199, then #200, so durable evidence and stable task authority precede broad enterprise integrations. 6. Implement #201 before making release/support claims; exact CI browser evidence must be bound to the actual signed artifact. @@ -322,4 +343,4 @@ done The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author, and requires `APPROVED` on the exact head. It deliberately does **not** infer GitHub's actual last-push actor from commit author or committer metadata: when `require_last_push_approval` is active, this portable evidence procedure records `github_rule_evaluation_required` and keeps `approval_gate_satisfied` false until GitHub's authoritative rule evaluation is consulted. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. -For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. \ No newline at end of file +For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md index ddbd5927c..94f181ed4 100644 --- a/docs/traceability/mcp-authority-route.md +++ b/docs/traceability/mcp-authority-route.md @@ -1,35 +1,38 @@ # MCP 2026-07-28 authority-route traceability -- **Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` -- **Owning work:** PR #168 `feat(mcp): bind stateless tool routing to typed actions` -- **Protected-main status:** non-shipped active-PR evidence +- **`tools/call` capability maturity:** `IMPLEMENTED_ON_PROTECTED_MAIN` +- **`tools/list` capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- **Protected-main owning work:** merged PR #168 `feat(mcp): bind stateless tool routing to typed actions` +- **Active follow-on:** PR #170 `feat(mcp): expose conservative tools list cache contract` - **Complete MCP adapter status:** `PLANNED` - **Governing decision:** ADR 0107 ## Scope -PR #168 implements a bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. +Protected main at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` contains the bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing that merged through PR #168. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. +Active PR #170 builds on that protected-main catalog with a conservative typed `tools/list` request/result boundary. Its current branch requires matching MCP protocol metadata, required client-capability presence, bounded and syntax-validated routing/body methods, exact `tools/list` routing, and no caller-supplied cursor because the fixed catalog issues none. Its result is one complete page with zero freshness, private cache scope, and no continuation cursor. This active-PR slice remains non-shipped until it reaches protected main and does not grant any OriginWeave action authority. + ## Product-status reconciliation -`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by this active PR: the PR implements only a reusable routing/action-policy foundation below the product adapter. `README.md` and `CHANGELOG.md` therefore distinguish the active foundation from shipped protected-main capability, and ADR 0107 records the same version and authority boundary. +`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by the bounded `tools/call` foundation now on protected main or by active PR #170: both are reusable control-plane contracts below the complete product adapter. `README.md` and `CHANGELOG.md` distinguish protected-main routing from the active discovery refinement, and ADR 0107 records the protocol/version and authority boundary. -The following remain outside PR #168 and must not be inferred from it: +The following remain outside protected main and PR #170 and must not be inferred from either: - Streamable HTTP transport parsing and header materialization; -- complete request `_meta` validation, including per-request client capabilities; -- `tools/list` serialization, pagination, cache semantics, and subscription handling; +- JSON-RPC/HTTP response serialization of the typed discovery page; - OAuth and authenticated MCP deployment policy; - browser-control I/O or BiDi/CDP/WebMCP translation; - secret materialization or broker transport; -- persistence, durable audit storage, or WARC/PROV export; and +- persistence, durable audit storage, or WARC/PROV export; +- general pagination/subscription state beyond the fixed no-cursor catalog; and - an OriginWeave Protocol version transition. ## Version boundary -The active routing foundation accepts only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. +The protected-main routing foundation and active discovery refinement accept only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. The reviewed primary source is: @@ -39,15 +42,17 @@ The canonical bibliography remains `docs/doctoring.md`. ## Executable evidence -Current PR #168 production/test surfaces include: +Protected-main PR #168 production/test surfaces include: - `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog plus method/tool routing validation in the `ValidatedMcpToolCall` primitive; - `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, exact method/tool bounds, empty/oversized/malformed inputs, version/method/header-body correlation, and error-contract evidence; - `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and - `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. -Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Predecessor-head success is historical only. +Active PR #170 additionally exercises its discovery contract in `crates/originweave-core/tests/mcp_tools_list_cache.rs`, including result/cache semantics, required protocol/client metadata, bounded protocol and method validation, routing correlation, cursor rejection, and public error contracts. + +Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Protected-main evidence proves only the merged `tools/call` foundation; predecessor or protected-main results are not current-head proof for active PR #170. ## Promotion rule -This dossier may change to `IMPLEMENTED_ON_PROTECTED_MAIN` for the bounded routing foundation only after PR #168 reaches protected `main` under live governance and exact-head acceptance. That promotion still does **not** promote the complete MCP adapter from `PLANNED`; each remaining transport/runtime boundary requires its own integrated evidence. +The bounded `tools/call` routing foundation is already `IMPLEMENTED_ON_PROTECTED_MAIN`. The `tools/list` discovery refinement may change to `IMPLEMENTED_ON_PROTECTED_MAIN` only after PR #170 reaches protected `main` under live governance and exact-head acceptance. Neither promotion makes the complete MCP adapter implemented; each remaining transport/runtime boundary requires its own integrated evidence. diff --git a/tests/fixtures/agent_task_basic/index.html b/tests/fixtures/agent_task_basic/index.html new file mode 100644 index 000000000..510b239f1 --- /dev/null +++ b/tests/fixtures/agent_task_basic/index.html @@ -0,0 +1,42 @@ + + + + + + OriginWeave controlled Agent Task fixture + + +
+

Controlled Agent Task

+

This page is synthetic test data for deterministic browser integration.

+ +
+ + + +
+ + idle + + +
+ + + + diff --git a/tests/test_agent_task_fixture_contract.py b/tests/test_agent_task_fixture_contract.py new file mode 100644 index 000000000..2565a35c7 --- /dev/null +++ b/tests/test_agent_task_fixture_contract.py @@ -0,0 +1,137 @@ +"""Fail-first contract for the controlled Chromium Agent Task fixture.""" + +from __future__ import annotations + +from html.parser import HTMLParser +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" + + +def _is_credential_input(attributes: dict[str, str | None]) -> bool: + """Return whether parsed input attributes describe a credential surface.""" + + input_type = (attributes.get("type") or "").strip().lower() + if input_type == "password": + return True + + autocomplete = (attributes.get("autocomplete") or "").strip().lower() + autocomplete_tokens = autocomplete.split() + return any( + token == "one-time-code" or "password" in token + for token in autocomplete_tokens + ) + + +class _FixtureParser(HTMLParser): + """Collect the small semantic surface required by the deterministic fixture.""" + + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.labels_for: set[str] = set() + self.input_names: set[str] = set() + self.input_attributes: list[dict[str, str | None]] = [] + self.button_types: set[str] = set() + self.hidden_injection_markers = 0 + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + attributes = dict(attrs) + element_id = attributes.get("id") + if element_id: + self.ids.add(element_id) + if tag == "label" and attributes.get("for"): + self.labels_for.add(attributes["for"]) + if tag == "input": + self.input_attributes.append(attributes) + if attributes.get("name"): + self.input_names.add(attributes["name"]) + if tag == "button" and attributes.get("type"): + self.button_types.add(attributes["type"]) + if ( + attributes.get("data-originweave-untrusted") == "prompt-injection" + and "hidden" in attributes + and attributes.get("aria-hidden") == "true" + ): + self.hidden_injection_markers += 1 + + +class AgentTaskFixtureContractTests(unittest.TestCase): + """Require one deterministic semantic workflow for the first browser slice.""" + + def setUp(self) -> None: + """Load the checked-in fixture once for each independent contract.""" + + self.html = FIXTURE.read_text(encoding="utf-8") + self.parser = _FixtureParser() + self.parser.feed(self.html) + + def test_fixture_exposes_semantic_form_and_observable_post_condition(self) -> None: + """The fixture must support role/name discovery and a deterministic state change.""" + + self.assertIn("task-text", self.parser.ids) + self.assertIn("task-text", self.parser.labels_for) + self.assertIn("task_text", self.parser.input_names) + self.assertIn("submit", self.parser.button_types) + self.assertIn("task-result", self.parser.ids) + self.assertIn('data-state="idle"', self.html) + self.assertIn('result.dataset.state = "submitted"', self.html) + self.assertIn("result.textContent = taskText.value", self.html) + + def test_fixture_contains_explicit_untrusted_hidden_prompt_injection(self) -> None: + """A later real-browser regression needs hostile hidden page content to ignore.""" + + self.assertEqual(self.parser.hidden_injection_markers, 1) + self.assertIn("UNTRUSTED_PAGE_INSTRUCTION", self.html) + self.assertIn("request new browser capabilities", self.html) + + def test_hidden_injection_requires_the_actual_hidden_attribute(self) -> None: + """ARIA metadata alone must not satisfy the hidden-injection fixture contract.""" + + parser = _FixtureParser() + parser.feed( + "" + "" + ) + self.assertEqual(parser.hidden_injection_markers, 1) + + def test_fixture_is_synthetic_and_has_no_credential_fields(self) -> None: + """The controlled workflow must not require or imitate real secret collection.""" + + for attributes in self.parser.input_attributes: + with self.subTest(attributes=attributes): + self.assertFalse(_is_credential_input(attributes)) + + lowered = self.html.lower() + for forbidden in ("api_key", "secret_key"): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, lowered) + + def test_credential_detection_is_quote_independent(self) -> None: + """Parsed credential semantics must reject single-quoted and tokenized forms.""" + + for html in ( + "", + "", + "", + "", + "", + ): + with self.subTest(html=html): + parser = _FixtureParser() + parser.feed(html) + self.assertEqual(len(parser.input_attributes), 1) + self.assertTrue(_is_credential_input(parser.input_attributes[0])) + + parser = _FixtureParser() + parser.feed("") + self.assertEqual(len(parser.input_attributes), 1) + self.assertFalse(_is_credential_input(parser.input_attributes[0])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_doctoring_reference_contract.py b/tests/test_doctoring_reference_contract.py new file mode 100644 index 000000000..bdeded44f --- /dev/null +++ b/tests/test_doctoring_reference_contract.py @@ -0,0 +1,28 @@ +"""Regression contracts for standards references that bind OriginWeave design claims.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring.md" + + +class DoctoringReferenceContractTests(unittest.TestCase): + """Keep cited primary-standard authorship aligned with the canonical source.""" + + def test_rfc_5280_reference_uses_canonical_author_initials(self) -> None: + """RFC 5280 must credit Sharon Boeyen as S. Boeyen, matching RFC Editor metadata.""" + text = DOCTORING.read_text(encoding="utf-8") + expected = ( + "Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. " + "(2008). *Internet X.509 public key infrastructure certificate and certificate " + "revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. " + "https://doi.org/10.17487/RFC5280" + ) + self.assertIn(expected, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index 34e8a0238..bc60535a2 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -37,11 +37,12 @@ def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> No """The baseline must preserve exact heads for the newest active product slices.""" for marker in ( "Current exact-head active PR evidence", - "| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` |", - "| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` |", - "| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` |", - "| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` |", - "| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` |", + "| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` |", + "| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` |", + "| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` |", + "| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` |", + "| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` |", + "| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` |", ): with self.subTest(marker=marker): self.assertIn(marker, self.baseline) @@ -53,8 +54,9 @@ def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] self.assertIn(refresh, added) self.assertNotIn(refresh, changed) - self.assertIn("150 open pull requests, 110 drafts", self.changelog) - self.assertNotIn("150 open pull requests, 112 drafts", self.changelog) + self.assertIn("126 open pull requests (54 ready, 72 draft)", self.changelog) + self.assertNotIn("128 open pull requests (54 ready, 74 draft)", added) + self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: """Current browser, network, sensitive and compatibility stacks stay active-only.""" diff --git a/tests/test_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py new file mode 100644 index 000000000..0daca1f85 --- /dev/null +++ b/tests/test_gap_snapshot_inventory_consistency.py @@ -0,0 +1,58 @@ +"""Regression contracts for the current dated product-gap inventory snapshot.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" +CHANGELOG = ROOT / "CHANGELOG.md" + + +class GapSnapshotInventoryConsistencyTests(unittest.TestCase): + """Prevent one dated snapshot from carrying contradictory live PR totals.""" + + @classmethod + def setUpClass(cls) -> None: + cls.baseline = BASELINE.read_text(encoding="utf-8") + cls.changelog = CHANGELOG.read_text(encoding="utf-8") + + def test_current_baseline_inventory_matches_the_verified_snapshot(self) -> None: + """The current snapshot must use the exact 126/54/72 inventory observation.""" + current = self.baseline.split("### Open pull requests", 1)[1].split( + "#### 2026-08-26 maintenance-loop record", 1 + )[0] + for marker in ( + "126 open pull requests", + "54 non-draft", + "72 draft", + ): + with self.subTest(marker=marker): + self.assertIn(marker, current) + + for stale in ( + "128 open pull requests", + "74 draft", + "153 open pull requests", + "114 draft", + ): + with self.subTest(stale=stale): + self.assertNotIn(stale, current) + + def test_unreleased_changelog_uses_one_current_inventory(self) -> None: + """The Unreleased current snapshot must agree before and inside Added.""" + unreleased = self.changelog.split("## [Unreleased]", 1)[1] + preamble, remainder = unreleased.split("### Added", 1) + added = remainder.split("### Changed", 1)[0] + + expected = "126 open pull requests (54 ready, 72 draft)" + self.assertIn(expected, preamble) + self.assertIn(expected, added) + self.assertNotIn("128 open pull requests (54 ready, 74 draft)", preamble) + self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 839393f30..1c24fe674 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,10 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "158 open pull requests", - "44 non-draft", - "114 draft", + "126 open pull requests", + "54 non-draft", + "72 draft", + "2026-08-24 158-PR snapshot", "#198", "#199", "#200", @@ -41,13 +42,24 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "78 draft", "148 open pull requests", "79 draft PRs", - "150 open pull requests", "40 non-draft", "110 draft", + "150 open pull requests", + "prior 150-PR snapshot", + "128 open pull requests", + "74 draft", ): with self.subTest(stale_phrase=stale_phrase): self.assertNotIn(stale_phrase, text) + def test_active_github_approval_rule_is_not_documented_as_bypassable(self) -> None: + """An active counted-approval rule must stop merge without an eligible approver.""" + text = BASELINE.read_text(encoding="utf-8") + + self.assertIn("eligible non-author", text) + self.assertIn("reviewer-provisioning gap", text) + self.assertNotIn("owner-directed administrative merge", text) + def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: """The evidence procedure must paginate the queue and inspect each exact PR head.""" text = BASELINE.read_text(encoding="utf-8") diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 7b1968996..f192aaa4d 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -11,12 +11,6 @@ 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) - @staticmethod def _subsection(text: str, heading: str) -> str: """Return one fourth-level documentation subsection.""" @@ -50,7 +44,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non self.assertTrue(baseline.is_file()) text = baseline.read_text(encoding="utf-8") for phrase in ( - "Observed snapshot: 2026-08-24", + "Observed snapshot: 2026-08-26", "Protected-main truth", "Open pull requests", "Open issues", @@ -68,7 +62,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non )[0] self.assertIn("Phase 1 is **in progress**, not shipped.", protected_main) self.assertIn( - "It remains draft evidence and cannot be treated as shipped behavior.", + "none of them is protected-main behavior until merged", open_pull_requests, ) bidi_status = self._subsection( @@ -79,7 +73,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non ) self.assertIn("Phase 1 is **in progress**, not shipped.", bidi_status) self.assertIn( - "It remains draft evidence and cannot be treated as shipped behavior.", + "does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof", vpn_status, ) diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 775eaeff9..057a0011b 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,6 +20,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination", "crates/originweave-network", @@ -59,6 +60,7 @@ def test_required_architecture_and_governance_documents_exist(self) -> None: "docs/adr/0005-direct-socket-binding.md", "docs/adr/0006-tls-server-identity.md", "docs/adr/0009-hourly-agent-credential-boundary.md", + "docs/adr/0016-bap-task-lifecycle-authority.md", "docs/superpowers/specs/2026-08-06-resolved-destination-policy-design.md", "docs/superpowers/specs/2026-08-06-direct-socket-binding-design.md", "docs/superpowers/specs/2026-08-06-tls-server-identity-design.md", @@ -175,36 +177,6 @@ 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_binding_is_recorded_in_the_changelog(self) -> None: - """The public origin-binding boundary must remain visible in release history.""" - - changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - self.assertIn("BrowserAuthorityRegistry::bind_context_origin", changelog) - - def test_context_origin_revalidation_is_recorded_in_the_changelog(self) -> None: - """The public origin-revalidation boundary must remain visible in release history.""" - - changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - self.assertIn("BrowserAuthorityRegistry::require_context_origin", changelog) - - 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_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_runtime_revision_boundary_is_recorded_in_the_changelog(self) -> None: - """The public runtime-revision boundary must remain visible in release history.""" - - changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - self.assertIn("require_runtime_revisions", changelog) - def test_database_contract_requires_two_word_snake_case(self) -> None: """Persistent naming policy must include the mandated canonical form.""" diff --git a/tests/test_webdriver_bidi_connect_target_governance.py b/tests/test_webdriver_bidi_connect_target_governance.py deleted file mode 100644 index de7949478..000000000 --- a/tests/test_webdriver_bidi_connect_target_governance.py +++ /dev/null @@ -1,35 +0,0 @@ -"""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) - - 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 f365830ca1e675c5f27f55dba38ed54c04e33345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:18:58 -0700 Subject: [PATCH 310/313] fix(core): restore fail-closed protocol capability requirement --- .../originweave-core/src/browser_protocol.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs index 3421b27b3..6af83a411 100644 --- a/crates/originweave-core/src/browser_protocol.rs +++ b/crates/originweave-core/src/browser_protocol.rs @@ -127,6 +127,23 @@ impl BrowserProtocolAdapterDescriptor { pub fn supports(&self, capability: BrowserProtocolCapability) -> bool { self.capabilities.contains(&capability) } + + /// Require one explicitly declared adapter capability before later use. + /// + /// This method never infers support from the browser protocol family. An + /// absent capability fails closed with a typed error so a caller cannot + /// silently fall back to another upstream protocol or a raw browser escape + /// hatch merely because the selected adapter lacks the requested surface. + pub fn require_capability( + &self, + capability: BrowserProtocolCapability, + ) -> Result<(), BrowserProtocolCapabilityRequirementError> { + if self.supports(capability) { + Ok(()) + } else { + Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability(capability)) + } + } } const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { @@ -138,6 +155,15 @@ const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { } } +fn capability_name(capability: BrowserProtocolCapability) -> &'static str { + match capability { + BrowserProtocolCapability::Navigation => "navigation", + BrowserProtocolCapability::SemanticObservation => "semantic-observation", + BrowserProtocolCapability::TypedInput => "typed-input", + BrowserProtocolCapability::NetworkObservation => "network-observation", + } +} + fn metadata_token_is_valid(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_BROWSER_PROTOCOL_METADATA_BYTES @@ -148,6 +174,27 @@ fn metadata_token_is_valid(value: &str) -> bool { && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) } +/// Failure to require one browser protocol capability from an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolCapabilityRequirementError { + /// The adapter did not explicitly declare the required capability. + UnsupportedCapability(BrowserProtocolCapability), +} + +impl fmt::Display for BrowserProtocolCapabilityRequirementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedCapability(capability) => write!( + formatter, + "browser protocol adapter does not declare required {} capability", + capability_name(*capability) + ), + } + } +} + +impl std::error::Error for BrowserProtocolCapabilityRequirementError {} + /// Failure to construct canonical browser protocol adapter metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolDescriptorError { From 231384a12f6ee69c42cff352965f8b795a646bf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:19:12 -0700 Subject: [PATCH 311/313] fix(core): export protocol capability requirement error --- 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 f5791fb5c..b5adf4fc3 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -17,8 +17,9 @@ mod contracts; mod extension_authority; pub use browser_protocol::{ - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, - BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, + MAX_BROWSER_PROTOCOL_METADATA_BYTES, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From 2b95fccc97e7407245b1cdfcd0732e9c23ffa69e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:22:26 -0700 Subject: [PATCH 312/313] fix(core): re-export capability requirement at crate root --- crates/originweave-core/src/root.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 933641bba..ef0ed9cfc 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -16,13 +16,14 @@ pub use core_contracts::{ AuthorityExtensionAccessDecision as ExtensionAccessDecision, AuthorityExtensionAccessRequest as ExtensionAccessRequest, AuthorityExtensionAgentGrant as ExtensionAgentGrant, BrowserAuthorityRegistry, - BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, - BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, - DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, - InstructionSource, MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, - NodeHandleError, Origin, OriginError, PolicyContext, - RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, - SessionMode, evaluate_extension_authority_access as evaluate_extension_access, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, + BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, + ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, + MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, + Origin, OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, + RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_authority_access as evaluate_extension_access, }; /// Stateless MCP routing validation that maps only explicit tools to typed actions. From fd1651993571ccf5285f9df7a7db883e143868f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:24:47 -0700 Subject: [PATCH 313/313] fix(core): apply canonical rustfmt to crate exports --- crates/originweave-core/src/root.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index ef0ed9cfc..6a907cd27 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -21,8 +21,8 @@ pub use core_contracts::{ BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, - Origin, OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, - RobotsDecision, SecretDelivery, SessionMode, + Origin, OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, + RiskClass, RobotsDecision, SecretDelivery, SessionMode, evaluate_extension_authority_access as evaluate_extension_access, };