Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b6cf832
test(core): require node-bound BiDi text input
seonghobae Aug 31, 2026
4139c1d
feat(core): implement node-bound BiDi text command
seonghobae Aug 31, 2026
4b6eb28
feat(core): export node-bound BiDi text command
seonghobae Aug 31, 2026
425b7f1
test(core): cover BiDi text authority and bounds
seonghobae Aug 31, 2026
9174f33
style(core): apply canonical rustfmt to BiDi text input
seonghobae Aug 31, 2026
7865ba5
style(core): apply canonical rustfmt to BiDi text tests
seonghobae Aug 31, 2026
3ada719
fix(test): restore exact BiDi authority target construction
seonghobae Aug 31, 2026
c2602a4
test(core): cover changed-origin BiDi text rejection
seonghobae Aug 31, 2026
6a46a19
style(core): apply rustfmt to changed-origin regression
seonghobae Aug 31, 2026
11ada2a
test(core): redact typed text from debug output
seonghobae Aug 31, 2026
1958720
fix(core): redact type-text debug payload
seonghobae Aug 31, 2026
55603cd
docs: refresh action post-condition traceability
seonghobae Aug 31, 2026
cc9980c
docs: record node-bound BiDi text input
seonghobae Aug 31, 2026
18a6457
test(network): reproduce inherited text-stack session mismatch
seonghobae Sep 6, 2026
5a72286
fix(core): preserve text input during pointer parent adoption
seonghobae Sep 6, 2026
eb6c236
docs: distinguish text construction from inherited dispatch safeguards
seonghobae Sep 6, 2026
009f9a4
test(network): replay pointer session and reply failures in text desc…
seonghobae Sep 6, 2026
d9503b3
fix(network): adopt pointer provenance into text command descendant
seonghobae Sep 7, 2026
e3885f6
docs: record text descendant pointer safeguard adoption
seonghobae Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions crates/originweave-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
262 changes: 262 additions & 0 deletions crates/originweave-core/src/webdriver_bidi_type_text.rs
Original file line number Diff line number Diff line change
@@ -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<Self, WebDriverBiDiTypeTextAuthorityError> {
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<Self, WebDriverBiDiTypeTextCommandError> {
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('"');
}
Loading
Loading