diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5fb4322..532d0800e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,12 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Retained text-input privacy and validation while adopting the latest click and subscription safeguards; text dispatch and browser outcome verification remain unfinished. - Integrated the current navigation-subscription safeguards while preserving active-subscription admission, replay rejection and stale-document checks. A response from a replacement connection still cannot complete an earlier session shutdown; this source integration is not real-browser or release acceptance. ### Changed +- Preserve text-command privacy and authority checks while adopting click-session and reply safeguards; text construction still does not prove browser execution. - Keep current-node and browser-session click checks when rejecting replies from replacement connections; the original request remains recoverable without consuming unrelated work. - Recheck that a click still targets the admitted node in the current document before sending it. Invalid deadlines send nothing and reserve no pending request; uncertain writes remain pending instead of being treated as safe to retry. - Reject replacement-connection click replies while retaining increasing request numbers, original subscription ownership and same-connection shutdown checks. @@ -82,6 +84,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. - Same-call QueryNodes admission that transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before an untrusted `locateNodes` result can become current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only proofs cannot mint observation handles. - Registry-issued admitted node handles and authority-bound WebDriver BiDi pointer-click construction that revalidate the exact session, context, canonical origin, document epoch, registry provenance, and retained `sharedId` before serializing `input.performActions`; caller-constructed node tuples or arbitrary wire identifiers cannot become typed-input authority, and the command itself grants no policy or Agent authority. +- Node-bound WebDriver BiDi text input that revalidates the exact session, browsing context, canonical origin, current document epoch, registry-issued node provenance, and admitted `sharedId` before serializing bounded protocol-safe non-secret `input.performActions`; the command focuses the admitted element before keyboard input, and its diagnostic representation exposes only command metadata and text byte length rather than typed text or the serialized wire payload. - 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/lib.rs b/crates/originweave-core/src/lib.rs index d9333a776..f30ccb1bc 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -52,6 +52,7 @@ mod webdriver_bidi_response_document; mod webdriver_bidi_response_document_correlation; mod webdriver_bidi_response_envelope; mod webdriver_bidi_result; +mod webdriver_bidi_type_text; mod webdriver_bidi_websocket_connect_target; mod webdriver_bidi_websocket_endpoint; @@ -108,6 +109,10 @@ pub use webdriver_bidi_response_envelope::{ pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; +pub use webdriver_bidi_type_text::{ + MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES, WebDriverBiDiTypeTextAuthorityError, + WebDriverBiDiTypeTextCommand, WebDriverBiDiTypeTextCommandError, +}; pub use webdriver_bidi_websocket_connect_target::{ VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiSocketPeerVerificationError, WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketConnectTargetError, diff --git a/crates/originweave-core/src/webdriver_bidi_type_text.rs b/crates/originweave-core/src/webdriver_bidi_type_text.rs new file mode 100644 index 000000000..468575987 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_type_text.rs @@ -0,0 +1,262 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + AdmittedNodeHandle, BrowserAuthorityRegistry, BrowserRegistryError, + MAX_WEBDRIVER_BIDI_COMMAND_ID, NodeHandleError, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, + WebDriverBiDiRemoteNodeReference, contains_disallowed_protocol_text, +}; + +/// Maximum UTF-8 bytes accepted by one non-secret WebDriver BiDi text-input command. +pub const MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES: usize = 512; + +/// Fail-closed validation errors for one serialized WebDriver BiDi text-input command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiTypeTextCommandError { + /// The command identifier exceeds WebDriver BiDi's unsigned safe-integer range. + InvalidCommandId, + /// The text payload is empty. + EmptyText, + /// The text payload exceeds the reviewed local UTF-8 byte budget. + TextTooLong, + /// The text payload contains a control, non-space whitespace, or reviewed format character. + InvalidText, +} + +impl Display for WebDriverBiDiTypeTextCommandError { + 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::EmptyText => "WebDriver BiDi text input must not be empty", + Self::TextTooLong => "WebDriver BiDi text input exceeds the local byte budget", + Self::InvalidText => { + "WebDriver BiDi text input contains a control, non-space whitespace, or reviewed format character" + } + }) + } +} + +impl Error for WebDriverBiDiTypeTextCommandError {} + +/// Fail-closed authority errors while binding text input to an admitted current node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiTypeTextAuthorityError { + /// The final deterministic text-input command failed its bounded serialization contract. + Command(WebDriverBiDiTypeTextCommandError), + /// Current browser session, context, or origin authority could not be revalidated. + BrowserAuthority(BrowserRegistryError), + /// The observed node belongs to a stale or otherwise mismatched browser document lifetime. + NodeHandle(NodeHandleError), + /// The supplied wire node identifier is not the identifier admitted for this exact node handle. + NodeExternalIdentifierMismatch, +} + +impl Display for WebDriverBiDiTypeTextAuthorityError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Command(error) => write!(formatter, "text-input command rejected input: {error}"), + Self::BrowserAuthority(error) => { + write!( + formatter, + "text-input browser authority rejected input: {error}" + ) + } + Self::NodeHandle(error) => { + write!( + formatter, + "text-input node authority rejected input: {error}" + ) + } + Self::NodeExternalIdentifierMismatch => formatter.write_str( + "text-input wire node identifier does not match the admitted current node", + ), + } + } +} + +impl Error for WebDriverBiDiTypeTextAuthorityError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Command(error) => Some(error), + Self::BrowserAuthority(error) => Some(error), + Self::NodeHandle(error) => Some(error), + Self::NodeExternalIdentifierMismatch => None, + } + } +} + +/// Deterministic `input.performActions` command that focuses one admitted node and types text. +/// +/// The command starts with a primary-button pointer move/down/up sequence on the exact admitted +/// element and then emits key-down/key-up pairs after three synchronized keyboard pauses. Pointer +/// pauses fill the remaining ticks, keeping the two WebDriver action sources aligned. This avoids +/// inheriting ambient focus from an unrelated element before keyboard input begins. +/// +/// The payload is intentionally limited to non-secret, single-line protocol-safe text. Secret +/// material must use the separately governed broker/fill path rather than this public text value. +/// Construction grants no policy, destination, secret, or Agent authority and performs no I/O. +#[derive(PartialEq, Eq)] +pub struct WebDriverBiDiTypeTextCommand { + command_id: u64, + browsing_context: String, + text_bytes: usize, + json: String, +} + +impl std::fmt::Debug for WebDriverBiDiTypeTextCommand { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WebDriverBiDiTypeTextCommand") + .field("command_id", &self.command_id) + .field("method", &WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD) + .field("text_bytes", &self.text_bytes) + .finish_non_exhaustive() + } +} + +impl WebDriverBiDiTypeTextCommand { + /// Bind one text-input command to the exact current semantic node admitted by this registry. + pub fn new_for_current_node( + command_id: u64, + browsing_context: &str, + text: &str, + handle: &AdmittedNodeHandle, + node: &WebDriverBiDiRemoteNodeReference, + registry: &BrowserAuthorityRegistry, + ) -> Result { + registry + .require_context_external_identifier( + handle.browser_session(), + handle.browsing_context(), + browsing_context, + ) + .map_err(WebDriverBiDiTypeTextAuthorityError::BrowserAuthority)?; + + let current_epoch = registry + .require_context_origin( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + ) + .map_err(WebDriverBiDiTypeTextAuthorityError::BrowserAuthority)?; + handle + .validate_current( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + current_epoch, + ) + .map_err(WebDriverBiDiTypeTextAuthorityError::NodeHandle)?; + + if !registry.node_external_identifier_matches(handle, node.shared_id()) { + return Err(WebDriverBiDiTypeTextAuthorityError::NodeExternalIdentifierMismatch); + } + + Self::new(command_id, browsing_context, text, node) + .map_err(WebDriverBiDiTypeTextAuthorityError::Command) + } + + fn new( + command_id: u64, + browsing_context: &str, + text: &str, + node: &WebDriverBiDiRemoteNodeReference, + ) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiTypeTextCommandError::InvalidCommandId); + } + if text.is_empty() { + return Err(WebDriverBiDiTypeTextCommandError::EmptyText); + } + if text.len() > MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES { + return Err(WebDriverBiDiTypeTextCommandError::TextTooLong); + } + if contains_disallowed_protocol_text(text, true) { + return Err(WebDriverBiDiTypeTextCommandError::InvalidText); + } + + let character_count = text.chars().count(); + let mut json = String::from("{\"id\":"); + json.push_str(&command_id.to_string()); + json.push_str(",\"method\":\""); + json.push_str(WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD); + json.push_str("\",\"params\":{\"context\":"); + push_json_string(&mut json, browsing_context); + json.push_str(",\"actions\":[{\"type\":\"pointer\",\"id\":\"originweave-mouse\",\"parameters\":{\"pointerType\":\"mouse\"},\"actions\":[{\"type\":\"pointerMove\",\"x\":0,\"y\":0,\"origin\":{\"type\":\"element\",\"element\":{\"sharedId\":"); + push_json_string(&mut json, node.shared_id()); + json.push_str( + "}}},{\"type\":\"pointerDown\",\"button\":0},{\"type\":\"pointerUp\",\"button\":0}", + ); + for _ in 0..character_count.saturating_mul(2) { + json.push_str(",{\"type\":\"pause\"}"); + } + json.push_str("]},{\"type\":\"key\",\"id\":\"originweave-keyboard\",\"actions\":[{\"type\":\"pause\"},{\"type\":\"pause\"},{\"type\":\"pause\"}"); + for character in text.chars() { + json.push_str(",{\"type\":\"keyDown\",\"value\":"); + push_json_character(&mut json, character); + json.push_str("},{\"type\":\"keyUp\",\"value\":"); + push_json_character(&mut json, character); + json.push('}'); + } + json.push_str("]}]}}"); + + Ok(Self { + command_id, + browsing_context: browsing_context.to_owned(), + text_bytes: text.len(), + json, + }) + } + + /// Return the validated 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_PERFORM_ACTIONS_METHOD + } + + /// Return the exact validated browsing-context identifier. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } + + /// Return the bounded UTF-8 byte length of the typed text without exposing a second copy. + #[must_use] + pub const fn text_bytes(&self) -> usize { + self.text_bytes + } + + /// 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('"'); +} + +fn push_json_character(output: &mut String, value: char) { + output.push('"'); + match value { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + character => output.push(character), + } + output.push('"'); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_type_text_command.rs b/crates/originweave-core/tests/webdriver_bidi_type_text_command.rs new file mode 100644 index 000000000..6bf6233ee --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_type_text_command.rs @@ -0,0 +1,339 @@ +use std::{error::Error, io}; + +use originweave_core::{ + AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, MAX_WEBDRIVER_BIDI_COMMAND_ID, MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES, + NodeHandleError, Origin, OriginWeaveProtocolVersion, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + ValidatedBrowserProtocolUse, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiTypeTextAuthorityError, + WebDriverBiDiTypeTextCommand, WebDriverBiDiTypeTextCommandError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +struct AdmittedTextField { + registry: BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_context: String, + handle: AdmittedNodeHandle, + remote: WebDriverBiDiRemoteNodeReference, +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn admitted_text_field( + external_context: &str, + shared_id: &str, +) -> Result> { + let mut registry = BrowserAuthorityRegistry::new(); + let browser_session = registry.register_session("webdriver-session")?; + let browsing_context = registry.register_context(browser_session, external_context)?; + let origin = Origin::parse("https://app.example").map_err(|error| { + io::Error::other(format!("fixture origin rejected unexpectedly: {error:?}")) + })?; + let epoch = registry.bind_context_origin(browser_session, browsing_context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task name"), 1)?; + let locate = WebDriverBiDiLocateNodesCommand::new(41, external_context, &query)?; + let escaped_shared_id = shared_id.replace('\\', "\\\\").replace('"', "\\\""); + let document = BoundedWebDriverBiDiResponseDocument::new(&format!( + r#"{{"type":"success","id":41,"result":{{"nodes":[{{"type":"node","sharedId":"{escaped_shared_id}"}}]}}}}"#, + ))?; + let handle = locate + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("locateNodes fixture did not bind its node"))?; + let remote = WebDriverBiDiRemoteNodeReference::new("node", Some(shared_id))?; + Ok(AdmittedTextField { + registry, + browser_session, + browsing_context, + external_context: external_context.to_owned(), + handle, + remote, + }) +} + +#[test] +fn type_text_command_focuses_exact_admitted_node_before_keyboard_input() +-> Result<(), Box> { + let fixture = admitted_text_field("context-a", "shared-input-42")?; + let command = WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + &fixture.external_context, + "Az", + &fixture.handle, + &fixture.remote, + &fixture.registry, + )?; + + assert_eq!(command.command_id(), 42); + assert_eq!(command.method(), WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD); + assert_eq!(command.browsing_context(), "context-a"); + assert_eq!(command.text_bytes(), 2); + assert_eq!( + command.as_json(), + r#"{"id":42,"method":"input.performActions","params":{"context":"context-a","actions":[{"type":"pointer","id":"originweave-mouse","parameters":{"pointerType":"mouse"},"actions":[{"type":"pointerMove","x":0,"y":0,"origin":{"type":"element","element":{"sharedId":"shared-input-42"}}},{"type":"pointerDown","button":0},{"type":"pointerUp","button":0},{"type":"pause"},{"type":"pause"},{"type":"pause"},{"type":"pause"}]},{"type":"key","id":"originweave-keyboard","actions":[{"type":"pause"},{"type":"pause"},{"type":"pause"},{"type":"keyDown","value":"A"},{"type":"keyUp","value":"A"},{"type":"keyDown","value":"z"},{"type":"keyUp","value":"z"}]}]}}"# + ); + Ok(()) +} + +#[test] +fn type_text_command_escapes_protocol_identifiers_and_keyboard_characters() +-> Result<(), Box> { + let fixture = admitted_text_field(r#"context-"quoted"\path"#, r#"node-"quoted"\path"#)?; + let command = WebDriverBiDiTypeTextCommand::new_for_current_node( + MAX_WEBDRIVER_BIDI_COMMAND_ID, + &fixture.external_context, + r#""\é"#, + &fixture.handle, + &fixture.remote, + &fixture.registry, + )?; + + assert!(command.as_json().contains(r#"context-\"quoted\"\\path"#)); + assert!(command.as_json().contains(r#"node-\"quoted\"\\path"#)); + assert!(command.as_json().contains(r#""value":"\"""#)); + assert!(command.as_json().contains(r#""value":"\\""#)); + assert!(command.as_json().contains(r#""value":"é""#)); + assert_eq!(command.text_bytes(), 4); + Ok(()) +} + +#[test] +fn type_text_command_rejects_unbounded_or_protocol_dangerous_text() -> Result<(), Box> { + let fixture = admitted_text_field("context-a", "shared-input-42")?; + let overlong = "a".repeat(MAX_WEBDRIVER_BIDI_TYPE_TEXT_BYTES + 1); + let format_text = format!("a{}b", UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS[0]); + + for (text, expected) in [ + ("", WebDriverBiDiTypeTextCommandError::EmptyText), + ( + overlong.as_str(), + WebDriverBiDiTypeTextCommandError::TextTooLong, + ), + ( + "line\nbreak", + WebDriverBiDiTypeTextCommandError::InvalidText, + ), + ( + format_text.as_str(), + WebDriverBiDiTypeTextCommandError::InvalidText, + ), + ] { + assert_eq!( + WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + &fixture.external_context, + text, + &fixture.handle, + &fixture.remote, + &fixture.registry, + ), + Err(WebDriverBiDiTypeTextAuthorityError::Command(expected)) + ); + } + + assert_eq!( + WebDriverBiDiTypeTextCommand::new_for_current_node( + MAX_WEBDRIVER_BIDI_COMMAND_ID + 1, + &fixture.external_context, + "a", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ), + Err(WebDriverBiDiTypeTextAuthorityError::Command( + WebDriverBiDiTypeTextCommandError::InvalidCommandId, + )) + ); + Ok(()) +} + +#[test] +fn type_text_command_rejects_wrong_context_and_unadmitted_node() -> Result<(), Box> { + let fixture = admitted_text_field("context-a", "shared-input-42")?; + let wrong_context = WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + "context-b", + "safe text", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ) + .err() + .ok_or("expected wrong external context rejection")?; + assert_eq!( + wrong_context, + WebDriverBiDiTypeTextAuthorityError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch, + ) + ); + assert!(wrong_context.source().is_some()); + assert!(wrong_context.to_string().contains("browser authority")); + + let forged = WebDriverBiDiRemoteNodeReference::new("node", Some("unadmitted-node"))?; + let wrong_node = WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + &fixture.external_context, + "safe text", + &fixture.handle, + &forged, + &fixture.registry, + ) + .err() + .ok_or("expected unadmitted sharedId rejection")?; + assert_eq!( + wrong_node, + WebDriverBiDiTypeTextAuthorityError::NodeExternalIdentifierMismatch + ); + assert!(wrong_node.source().is_none()); + assert!(wrong_node.to_string().contains("wire node identifier")); + Ok(()) +} + +#[test] +fn type_text_command_rejects_changed_origin_authority() -> Result<(), Box> { + let mut fixture = admitted_text_field("context-a", "shared-input-42")?; + fixture + .registry + .advance_document(fixture.browsing_context)?; + let changed_origin = Origin::parse("https://changed.example").map_err(|error| { + io::Error::other(format!( + "changed fixture origin rejected unexpectedly: {error:?}" + )) + })?; + fixture.registry.bind_context_origin( + fixture.browser_session, + fixture.browsing_context, + &changed_origin, + )?; + + let error = WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + &fixture.external_context, + "safe text", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ) + .err() + .ok_or("expected changed-origin rejection")?; + assert!(matches!( + error, + WebDriverBiDiTypeTextAuthorityError::BrowserAuthority(_) + )); + assert!(error.source().is_some()); + assert!(error.to_string().contains("browser authority")); + Ok(()) +} + +#[test] +fn type_text_command_rejects_stale_document_authority() -> Result<(), Box> { + let mut fixture = admitted_text_field("context-a", "shared-input-42")?; + let observed = fixture.handle.document_epoch(); + let current = fixture + .registry + .advance_document(fixture.browsing_context)?; + let origin = fixture.handle.origin().clone(); + fixture.registry.bind_context_origin( + fixture.browser_session, + fixture.browsing_context, + &origin, + )?; + + let error = WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + &fixture.external_context, + "safe text", + &fixture.handle, + &fixture.remote, + &fixture.registry, + ) + .err() + .ok_or("expected stale document rejection")?; + assert_eq!( + error, + WebDriverBiDiTypeTextAuthorityError::NodeHandle(NodeHandleError::StaleDocumentEpoch { + observed, + current, + }) + ); + assert!(error.source().is_some()); + assert!(error.to_string().contains("node authority")); + Ok(()) +} + +#[test] +fn type_text_error_contracts_expose_only_typed_sources() { + for error in [ + WebDriverBiDiTypeTextCommandError::InvalidCommandId, + WebDriverBiDiTypeTextCommandError::EmptyText, + WebDriverBiDiTypeTextCommandError::TextTooLong, + WebDriverBiDiTypeTextCommandError::InvalidText, + ] { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + let authority = WebDriverBiDiTypeTextAuthorityError::Command(error); + assert!(authority.source().is_some()); + assert!(!authority.to_string().is_empty()); + } +} + +#[test] +fn type_text_command_debug_redacts_typed_text_and_wire_payload() -> Result<(), Box> { + let fixture = admitted_text_field("context-a", "shared-input-42")?; + let command = WebDriverBiDiTypeTextCommand::new_for_current_node( + 42, + &fixture.external_context, + "buyer-private-marker", + &fixture.handle, + &fixture.remote, + &fixture.registry, + )?; + + let debug = format!("{command:?}"); + assert!(!debug.contains("buyer-private-marker")); + assert!(!debug.contains("originweave-keyboard")); + assert!(debug.contains("command_id: 42")); + assert!(debug.contains("text_bytes: 20")); + Ok(()) +} diff --git a/docs/doctoring.md b/docs/doctoring.md index 120ea4041..9de4e3554 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -4,6 +4,16 @@ This document records external evidence that changes OriginWeave architecture, t ## Decision trace +### Text-command descendant preservation + +At `009f9a41`, the #266 text-command descendant reproduced all three inherited +pointer session/replacement-reply failures with the canonical real socket regressions. +Ordinary integration `d9503b30` adopts #265 `e94a2372`, preserving the child text +constructor and its eight privacy/authority tests byte-for-byte. The 21 focused tests +pass after adoption. This reuses the existing sender, registry and sealed-reader +boundaries; it adds no new standard, transport, dependency or accepted architecture. +Text dispatch, causal observations and protected-main acceptance remain separate. + ### Pointer outbound authority and received-reply integration Child #265 at `ddce7248` already checked the admitted node and canonical registry session diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 42ab72f7d..df0d982b4 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -1,32 +1,62 @@ # Action Post-Condition Evidence Traceability -- **Documentation status:** Active-PR evidence dossier -- **Canonical owner:** PR #44 (`docs: reconcile architecture documentation fitness`) -- **Protected-main baseline:** `67af7c87589edc2039545af335c95064d9b8391c` +## Pointer session and reply adoption — 2026-09-07 + +Ordinary merge `d9503b30` adopts #265 `e94a2372` without changing this child's +text-command source, exports or eight existing text/privacy tests. Three real socket +regressions at `009f9a41` first reproduced foreign-session pointer dispatch and +replacement success/error replies consuming the original request (0/3 passing). +The integrated sender keeps current-node, outbound-session and monotonic typed +dispatch checks; its replies require the exact sending connection. Foreign replies +leave both pending commands intact, and the original reply completes only its own. + +All 21 focused pointer, navigation-postcondition and text-command tests pass. The +child's revised issue-#28 dossier remains intact alongside the parent's dated receipt +history. This is still bounded non-secret text construction, not typed text dispatch +or observed browser success. The existing #267 transport owns the next integration; +this change does not duplicate it. Full combined-head verification, hosted checks +and actual visual inspection are separate gates, and none of the historical heads +below supplies protected-main, policy, browser-authentication or release acceptance. + +## Parent-adoption checkpoint — 2026-09-06 + +Ordinary integration `5a722867` preserves the text-input and diagnostic-privacy delta +from `cc9980c0` while adopting pointer parent `7147893c96ca95c9b5b275d8011c5bfe99aab065`. +The inherited foreign-session socket regression first failed at `18a64573`; the +parent's canonical session guard, current-node pointer revalidation, typed dispatch, +deadline safeguards and four restored navigation-postcondition tests are retained. +Text-command source and its existing eight regression bodies are unchanged. + +This text slice still constructs a bounded command; it does not implement a typed +text transport, revalidate text authority at dispatch, authorize actions, or prove a +browser state change. Parent pointer/subscription safeguards do not supply those +missing text boundaries. Current protected main is `87c4daa1830bac5a5228b6036752ad5633232085`. +The dossier below retains its earlier revision-specific observations; old exact-head +CI and screenshots are not combined-head acceptance. Keep Draft pending fresh full +checks, visual inspection, parent-first protected integration and runtime evidence. + +- **Documentation status:** Active-stack evidence dossier; protected-main truth is called out separately +- **Canonical owner:** issue #28 (`Complete the first real Chromium agent vertical slice`) +- **Protected-main baseline:** `542ca1e9c0a863595b8b6697790005d2471f5413` +- **Active stack tip at this revision:** PR #266 (`feat/core: add node-bound WebDriver BiDi text input`) - **Capability maturity:** **PARTIAL** - **Governing decisions:** Accepted ADR 0003 plus Proposed ADR 0106 preserve provenance-native evidence and separation of action execution from verification. ## 1. Why this dossier exists -OriginWeave's protected-main API contract already defines a durable product rule: returning from a browser command is not equivalent to successful action completion. A state-changing action becomes successful only after the declared or derived post-condition is observed and verified. Protected main also provides generic credential-safe provenance with explicit verification state, but that design rule was not yet represented by a reusable typed action-outcome evidence object. +OriginWeave has a durable product rule: returning from a browser command is not equivalent to successful action completion. A state-changing action becomes successful only after the declared or derived post-condition is observed and verified. This dossier tracks the executable pieces that narrow the first Chromium vertical-slice gap without promoting active pull-request behavior to protected-main shipped truth. -This dossier records the active implementation evidence that narrows that gap. It does not promote active pull requests to protected-main shipped truth and it does not claim that a real Chromium adapter already observes the post-condition after dispatch. +Every exact branch head below is volatile evidence. If a contributor head, live base, dependency, review, or check state moves, its recorded evidence must be revalidated on the new exact state before it is reused. -## 2. Protected-main design and implementation boundary +## 2. Protected-main truth -Protected `main` already provides: +Protected `main` at `542ca1e9c0a863595b8b6697790005d2471f5413` already contains the controlled local Agent Task fixture from merged PR #65. The checked-in fixture provides a labelled synthetic text field, submit control, deterministic `idle` → `submitted` state transition, and hidden untrusted page instruction used by hostile-content regressions. Its presence on protected main is test-infrastructure truth; it is not proof of a production browser runtime. -- typed `ActionKind` and immutable `ActionIntentDigest` values; -- canonical `Origin` authority values; -- credential-safe `ProvenanceRecord` with explicit `VerificationResult`; -- API/TRD requirements that state-changing success waits for an observed post-condition; and -- provenance architecture that keeps observation, policy, execution, and verification as distinct authorities. +Protected main also retains the generic authority/evidence primitives and design requirements that keep observation, policy, execution, and verification separate. It still does not by itself establish the complete pinned-Chromium observation → policy → action → post-condition → evidence → teardown chain required by issue #28. -The generic value primitives are **IMPLEMENTED_ON_PROTECTED_MAIN**. The complete action dispatch → observation → independent verification → successful outcome chain remains **PARTIAL** because protected main does not yet contain the real Chromium runtime that composes them end to end. +## 3. Current executable evidence -## 3. Active executable evidence - -### Pointer-click connection-bound receipt checkpoint +### Historical pointer-click connection-bound receipt checkpoint Ordinary merge `0234b587d1bca9286eb5b597f9dab33be47ff518` integrates #257 `9451fd8a23dec95b31749376bc78c2eaca977fe8` with #258's sealed received-message @@ -47,7 +77,7 @@ remaining outbound session-authority and real-browser postcondition gaps. Full exact-head verification, hosted acceptance and protected integration remain separate requirements; focused loopback success does not establish them. -### Pointer-click originating-connection prerequisite +### Historical pointer-click originating-connection prerequisite PR #258 test-only head `8193fcd50125d9e9a43b4755e0f7626801b74374`, on PR #257 `8f1507346f65798a6bf4eaf370d65a2d406a6f44`, reproduced a replacement @@ -64,38 +94,53 @@ must also retain unrelated requests and allow the original connection's response Outbound session authority, browser authentication, observed click effects, protected-main acceptance, and release evidence remain separate and unproven. -### PR #64 — verified, temporally ordered post-condition becomes typed action-outcome evidence +### PR #64 — verified action-outcome and interruption evidence **Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` -Exact head `2c45411ed9aa0eecca2d06c85659db9f4bb85e4d` adds `VerifiedActionOutcomeEvidence` in the existing credential-safe evidence crate. It binds: +PR #64 remains open on exact head `5021d142583cb5a8e393248048bb824762a98056` against protected main. Its typed evidence boundary binds verified post-condition provenance to an action intent and keeps retry eligibility fail-closed around exact browser authority, cleanup/finalization state, and possible external effects. -1. the exact typed `ActionKind`; -2. canonical target `Origin`; -3. complete immutable `ActionIntentDigest`; -4. a bounded first-slice `PostConditionKind` (`UrlChanged`, `NodeStateChanged`, `DialogStateChanged`, or `NetworkMutationObserved`); -5. caller-supplied action-dispatch and post-condition-observation timestamps that must come from one monotonic clock domain; and -6. the exact `ProvenanceRecord` used as the post-condition proof. +The branch is not protected-main shipped truth. Its current outstanding failures are central review/provider evidence rather than a verified source-level vulnerability: the exact-head OpenCode path lacks an authenticated qualifying review verdict and Strix has returned provider/backend failures. Those states do not become passing evidence and do not justify a local product workaround that weakens the central gate. -Construction fails closed unless the supplied provenance has `VerificationResult::Verified`. Both `Unverified` and `Rejected` observations are rejected as `PostConditionNotVerified`. An observation timestamp earlier than dispatch is rejected as `PostConditionPredatesDispatch`; equal ticks remain valid for coarse monotonic clocks. +### PRs #261–#264 — committed-navigation lifecycle and subscription authority -On this exact head, CI run `31441848670`, Security Scan run `31441848649`, SAST Semgrep run `31441848615`, exact owned production function/line/region/branch coverage, strict Clippy, rustdoc and CodeRabbit exact-head status are successful. GitHub reports the PR mergeable and Ready for review; no formal reviews or inline review threads are currently returned. +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_STACK` -### PR #65 — controlled hostile local workflow fixture +The current issue-#28 navigation stack is dependency ordered: -**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- PR #261 exact `da84955d74ff12b158a8cb2e75eadf218c787f46`: canonical origin binding after an admitted committed-navigation observation and exact pre-action document epoch; +- PR #262 exact `9df2fc23abf42133beaebab4f5466fbdc942d336`: typed, context-scoped `session.subscribe` for `browsingContext.navigationCommitted` with bounded WebSocket transport and exact correlation; +- PR #263 exact `24fc763f0c4ae4e0dd2c62b9dca4b5bc0d23a94b`: typed unsubscribe consuming the validated opaque subscription receipt; and +- PR #264 exact `9c4116b23e5b35e50bb66fff9f72d52bba3adbd0`: admission of committed-navigation events only while the exact typed subscription authority remains active. + +Each remains Draft and mergeable at this revision. Their exact native CI evidence is branch-local and is not transferred to descendants or protected main. Organization-required central checks that are absent from an exact stacked head remain absent evidence rather than implicit success. + +### PR #265 — pointer input revalidated against admitted node authority + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_STACK` + +PR #265 exact `ffa70ee0f499b86ff51837fb95733fd5cf57ff89` binds pointer-click serialization and transport to a registry-issued `AdmittedNodeHandle`, the exact admitted WebDriver BiDi `sharedId`, current browser session/context/origin/document epoch, and a non-cloneable validated `TypedInput` protocol-use proof immediately before correlation and network I/O. Cross-registry handles, stale nodes, changed origin authority, wrong external contexts, and caller-selected unadmitted node identifiers fail closed. + +The branch remains Draft and mergeable. Native CI and Manifest V3 compatibility were successful on that exact head, but neither automation nor author activity counts as independent approval. + +### PR #266 — node-bound non-secret text input and diagnostic redaction + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_STACK` -Test-only head `d2580305f05aba93d10b5342ec1886d601c6752e` was based directly on the protected-main baseline and intentionally required a checked-in `tests/fixtures/agent_task_basic/index.html` before that fixture existed. CI run `31445088008`, Rust contracts job `93637443229`, checked out that exact head and failed with three `FileNotFoundError` results for the missing fixture, establishing the intended fail-first boundary. +PR #266 is stacked directly on PR #265. The current implementation adds a deterministic WebDriver BiDi `input.performActions` text-input command that: -Exact head `0888fe3a6ef6da547a37fd075733cc73dc52b2ab` adds the smallest controlled fixture satisfying the contract: a labelled semantic field, submit control, deterministic `idle` → `submitted` observable state change carrying only synthetic text, one explicitly hidden/untrusted prompt-injection marker, and no password/OTP/API-key/secret collection surface. +1. revalidates the exact registry-issued browser session, external browsing-context identifier, canonical origin, current document epoch, node provenance, and admitted `sharedId` before serialization; +2. focuses the exact admitted element with an element-origin primary-button sequence before keyboard input; +3. accepts only non-empty, bounded, protocol-safe non-secret text; and +4. keeps secrets on the separately governed broker/fill path rather than this public text-input surface. -On that unchanged exact head, CI run `31445201739` succeeds; Rust contracts job `93637824750` passes repository contracts, formatting, locked workspace check, full tests, strict Clippy and rustdoc; Production coverage job `93637824824` passes exact owned production function/line/region/branch enforcement; Security Scan run `31445201774`, SAST Semgrep run `31445201669` and CodeRabbit exact-head status succeed. GitHub reports the PR mergeable and Ready for review with no formal reviews or inline review threads currently returned. +A current-source privacy defect was found and repaired test-first on this same canonical branch. The original derived `Debug` implementation exposed the complete serialized command, including buyer-provided typed text. Exact RED head `11ada2a54fc3f9fc3225e654670319bc5fa6f0b2` added a diagnostic regression that failed because the private marker was present. The production repair replaced derived `Debug` with a metadata-only representation that retains command id, method, and text byte count while omitting typed text, browsing-context identifiers, admitted node identifiers, and the serialized wire payload. -This remains controlled test infrastructure rather than browser-execution evidence. The fixture itself does not establish WebDriver BiDi/CDP transport, Chromium semantic extraction, policy dispatch, native input, post-condition provenance, profile teardown or process attribution. +The pre-documentation exact repaired head `1958720a2f0f7e33e40bcea0073c486f37ad278d` passed CI run `33451284736` and Manifest V3 Compatibility run `33451284820`; Rust contracts included formatting, workspace checks, full tests, strict Clippy, and public API documentation, while Production coverage passed exact owned-production function/line/region/branch enforcement. A later documentation-only head must obtain its own fresh exact-head evidence before these results can be treated as current for the PR. ## 4. Non-transitive success semantics -The intended first-slice chain is: +The intended first-slice chain remains: ```text typed action intent @@ -103,55 +148,54 @@ typed action intent -> real browser input/event -> observed bounded post-condition -> independently verified provenance --> temporally ordered VerifiedActionOutcomeEvidence +-> temporally ordered verified action outcome ``` -The active PR implements only the final typed evidence boundary. The following implications are explicitly invalid: +The following implications remain invalid: ```text command return -/> successful action completion protocol acknowledgement -/> successful action completion -Unverified -/> successful action completion -Rejected -/> successful action completion -caller-supplied timestamp ordering -/> proof of trusted clock provenance -VerifiedActionOutcomeEvidence type existence -/> proof of real Chromium execution +subscription receipt -/> event occurrence +admitted node handle -/> policy authorization +successful pointer/text serialization -/> successful browser state change +Unverified or Rejected provenance -/> successful action completion +caller-supplied timestamp ordering -/> trusted clock provenance +typed evidence object existence -/> proof of real Chromium execution controlled fixture success -/> proof of real Chromium execution ``` -PR #64 now rejects a caller-supplied observation timestamp that predates caller-supplied dispatch time, but the type cannot independently prove the clock source, that a real browser actually dispatched the action, that the supplied provenance belongs to the claimed browser target/node, or that the observed state was caused by that action. PR #65 supplies deterministic hostile input and a post-condition target but no browser execution. Those claims remain the responsibility of the real adapter/runtime composition under issue #28. +The active navigation/input stack narrows browser transport and node-lifetime authority, but it does not prove that a dispatched input caused the declared post-condition. The verified outcome boundary remains separate, and the final runtime must compose real browser execution with post-dispatch observation and credential-safe provenance without inheriting ambient browser, policy, destination, secret, or model authority. -## 5. Active prerequisite graph for issue #28 +## 5. Current issue #28 dependency shape -The first real Chromium vertical slice remains distributed across bounded active prerequisites rather than one shipped runtime: +The first real Chromium vertical slice remains distributed rather than shipped as one protected-main runtime. Current relevant boundaries include: -- PR #40 — protocol/browser identifiers → OriginWeave session/context/origin/document/node authority; -- PR #52 — bounded semantic node observation with explicit source-channel provenance; -- PR #57 — typed semantic-node query contract; -- PR #58 — authority-bound semantic node action target; -- PR #49 — ephemeral compatibility-profile lifecycle regression stacked on #43; -- PR #51 — bounded browser-task telemetry plus one explicitly supplied Linux PID `VmRSS` sampler; Chromium process discovery/process-set attribution remains outside that slice; -- PR #64 — verified and caller-timestamp-ordered post-condition action-outcome evidence; and -- PR #65 — controlled hostile local Agent Task workflow fixture, gate-clean and Ready for review. +- protected-main controlled hostile workflow fixture from merged PR #65; +- browser protocol/session/context/origin/document/node authority primitives already represented in the repository; +- PR #64 verified post-condition and interruption evidence; +- PRs #261–#264 committed-navigation origin/subscription/admission lifecycle; +- PR #265 send-time pointer input revalidation against admitted node authority; and +- PR #266 node-bound non-secret text input with privacy-safe diagnostics. -These active PRs are non-shipped evidence. They do not themselves compose WebDriver BiDi/CDP transport, trusted Chromium process attribution, policy-authorized real input dispatch, causal post-condition observation, or deterministic end-to-end teardown/recovery into one protected-main runtime. +These pieces do not transfer evidence across heads. A descendant must be revalidated after any parent movement, and protected-main shipment requires fresh integrated acceptance after dependency-ordered merge by an authorized integrator. ## 6. Remaining issue #28 boundary This dossier does **not** close issue #28. Material remaining work includes: -- pinned stock Chromium exercised as one reproducible end-to-end Agent Task runtime path, not only extension compatibility fixtures; -- isolated Agent Task profile/context lifecycle and cleanup in the production vertical path; -- versioned WebDriver BiDi adapter plus explicitly bounded CDP observation fallback where needed; +- one reproducible pinned stock-Chromium Agent Task path that composes the current authority kernels rather than proving them only in isolated protocol fixtures; +- isolated task profile/context lifecycle and deterministic cleanup in that production vertical path; - real semantic observation feeding typed query and policy-authorized typed action; -- real browser input dispatch followed by post-dispatch observation of the declared condition; -- hostile/stale/cross-session/cross-context/cross-origin/prompt-injection/secret-leak/crash/oversize regressions; -- deterministic failure/recovery evidence and task teardown; -- Chromium process discovery/process-set attribution composed into resource telemetry; and -- protected-main integration plus fresh acceptance before any active-PR capability becomes shipped truth. +- real pointer/text dispatch followed by an independently observed declared post-condition and verified credential-safe evidence; +- hostile stale/cross-session/cross-context/cross-origin/prompt-injection/secret-leak/crash/oversize regressions across the integrated runtime; +- deterministic renderer/tab/process failure and recovery evidence; +- Chromium process-set discovery/attribution composed into resource telemetry; and +- fresh protected-main security, coverage, rustdoc, browser compatibility, provenance, review, rollback, and operational acceptance before release claims. ## 7. Documentation fitness consequence -The ADR/PRD/TRD/Architecture/UML/ERD graph remains **DESIGN-SUFFICIENT / PROTECTED-MAIN-PARTIAL**. PR #64 narrows a typed evidence gap already governed by existing provenance/action-success decisions, while PR #65 supplies controlled test infrastructure for the eventual real-browser proof. Neither introduces a new trust domain, deployed component, persistence owner, database schema, or independent architecture decision, so a new ADR or physical ERD entity would overstate the implementation. Detailed real-Chromium dispatch/post-condition sequence diagrams should be reconciled when the executable adapter chain stabilizes rather than manufacturing as-built detail before that runtime exists. +The documentation graph remains **DESIGN-SUFFICIENT / PROTECTED-MAIN-PARTIAL**. The active stack materially narrows WebDriver BiDi navigation and typed-input authority, but it introduces no new OriginWeave-owned durable database schema or persistence owner. A physical ERD entity would therefore overstate the implementation. Detailed as-built sequence diagrams should be reconciled when the executable pinned-Chromium composition is stable enough that they describe measured runtime behavior rather than anticipated integration. ## 8. Pointer descendant reply integration