diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b28b0c3..ad12a72bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- The session-ending command stack now retains the status-reply protections from its current parent. A reply from a replacement connection is rejected while the original pending status request remains recoverable; sending the end command still does not prove that the browser session ended. +- Typed outbound WebDriver BiDi `session.end` over the bounded client WebSocket stream: it serializes only the standards-defined method with empty params, rejects invalid frame deadlines before correlation registration, retires only the just-registered id when frame preflight proves no command bytes were emitted, preserves exact command-kind correlation across ambiguous writes, and does not treat frame-write success as proof that the browser session ended. - Regression checks now exercise fragmented browser replies, interleaved control messages, and rejected replies without losing a pending request. These checks do not establish browser readiness or release acceptance. - The typed browser-status response stack now includes its verified command and opening-exchange prerequisites, including the release-record check that previously did not execute; parsing remains bounded and does not grant browser authority or prove operational readiness. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. @@ -58,6 +60,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Carried the verified status-response prerequisites into the session-end sender, preserving its command behavior and making the inherited release-record check execute in the existing test suite. - Kept the `session.status` frame-failure coverage contract focused on observable correlation state, avoiding assertion-internal uncovered branches without weakening preflight retirement or ambiguous-write retention checks. - Made the command-correlation release-record check run in the existing CI test suite, preserving its exact bounds and authority exclusions; carried the verified message-parent fixture repairs into the correlation stack. - Carried the verified parent fixture and release-check repairs into the session-status sender without changing command or correlation behavior. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index f84912a65..ac8e7f79d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -8,10 +8,10 @@ //! the RFC 6455 opening exchange, provides bounded masked client writes and //! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, //! classifies complete local-end JSON envelopes, tracks bounded command-response -//! correlation, sends one narrowly typed `session.status` command, and admits its -//! required readiness result through one command-specific correlated parser without -//! exposing generic JSON bodies or granting browser, TLS, policy, secret, or Agent -//! authority. +//! correlation, sends narrowly typed `session.status` and `session.end` commands, +//! and admits the required readiness result through one command-specific correlated +//! parser without exposing generic JSON bodies or granting browser, TLS, policy, +//! secret, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -21,6 +21,7 @@ mod webdriver_bidi_command_correlation; mod webdriver_bidi_connection; mod webdriver_bidi_json_envelope; mod webdriver_bidi_received_message; +mod webdriver_bidi_session_end_command; mod webdriver_bidi_session_status_command; mod webdriver_bidi_session_status_response; mod webdriver_bidi_websocket_frame; @@ -53,6 +54,9 @@ pub use webdriver_bidi_received_message::{ WebDriverBiDiConnectionMessageRead, WebDriverBiDiConnectionMessageReadError, WebDriverBiDiReceivedTextMessage, WebDriverBiDiWebSocketMessageReader, }; +pub use webdriver_bidi_session_end_command::{ + WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndCommandError, +}; pub use webdriver_bidi_session_status_command::{ WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusCommandError, }; diff --git a/crates/originweave-network/src/webdriver_bidi_session_end_command.rs b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs new file mode 100644 index 000000000..29fbe05aa --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs @@ -0,0 +1,241 @@ +use std::{error::Error, fmt, time::Duration}; + +use crate::{ + MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_END_METHOD: &str = "session.end"; + +/// One bounded WebDriver BiDi `session.end` command. +/// +/// The command is deliberately concrete rather than a generic JSON or arbitrary-method escape +/// hatch. It carries only a WebDriver BiDi `js-uint` correlation identifier and always serializes +/// the standards-defined empty parameter map. Successfully writing the frame does not claim that +/// the remote session ended; callers must wait for a separately validated correlated response. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiSessionEndCommand { + command_id: u64, +} + +impl WebDriverBiDiSessionEndCommand { + /// Construct one `session.end` command with a JavaScript-safe correlation identifier. + pub fn new(command_id: u64) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_JS_UINT { + return Err(WebDriverBiDiSessionEndCommandError::CommandIdOutOfRange { + command_id, + maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, + }); + } + Ok(Self { command_id }) + } + + /// Return the exact local correlation identifier serialized for this command. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Register and write this exact command on an already established verified BiDi stream. + /// + /// Locally invalid frame deadlines fail before correlation registration and before any remote + /// side effect. Correlation then registers the command before the first possible frame write. + /// A frame-owner preflight rejection that proves no write began retires this exact command + /// again. Once frame emission can have begun, a later failure leaves the identifier outstanding + /// because partial or full emission is ambiguous. A successful write also leaves the identifier + /// outstanding until a later correlated response proves completion. + pub fn send( + self, + established: WebDriverBiDiWebSocketEstablished, + correlation: &mut WebDriverBiDiCommandCorrelation, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiSessionEndCommandError::FrameWrite { + source: WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + }); + } + correlation + .register_command_for(self.command_id, WebDriverBiDiCommandKind::SessionEnd) + .map_err(|source| WebDriverBiDiSessionEndCommandError::Correlation { source })?; + let message = self.serialized(); + match established.write_text_frame(&message, masking_key, frame_timeout) { + Ok(established) => Ok(established), + Err(source) => Err(map_frame_failure(correlation, self.command_id, source)), + } + } + + fn serialized(self) -> String { + format!( + "{{\"id\":{},\"method\":\"{SESSION_END_METHOD}\",\"params\":{{}}}}", + self.command_id + ) + } +} + +fn map_frame_failure( + correlation: &mut WebDriverBiDiCommandCorrelation, + command_id: u64, + source: WebDriverBiDiWebSocketFrameError, +) -> WebDriverBiDiSessionEndCommandError { + if matches!( + source, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + ) { + let _retirement = + correlation.retire_command_for(command_id, WebDriverBiDiCommandKind::SessionEnd); + } + WebDriverBiDiSessionEndCommandError::FrameWrite { source } +} + +/// Fail-closed errors while constructing or sending one typed `session.end` command. +#[derive(Debug)] +pub enum WebDriverBiDiSessionEndCommandError { + /// The requested command identifier is outside WebDriver BiDi's `js-uint` range. + CommandIdOutOfRange { + /// Rejected command identifier. + command_id: u64, + /// Largest JavaScript-safe identifier admitted by this boundary. + maximum_command_id: u64, + }, + /// The bounded local correlation registry rejected the command before network I/O. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// Frame preflight validation or a later write operation failed. + FrameWrite { + /// Exact typed bounded WebSocket frame validation/write failure. + source: WebDriverBiDiWebSocketFrameError, + }, +} + +impl fmt::Display for WebDriverBiDiSessionEndCommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandIdOutOfRange { .. } => formatter + .write_str("WebDriver BiDi session.end command id is outside the js-uint range"), + Self::Correlation { .. } => { + formatter.write_str("WebDriver BiDi session.end command correlation was rejected") + } + Self::FrameWrite { .. } => { + formatter.write_str("WebDriver BiDi session.end command frame write failed") + } + } + } +} + +impl Error for WebDriverBiDiSessionEndCommandError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::CommandIdOutOfRange { .. } => None, + Self::Correlation { source } => Some(source), + Self::FrameWrite { source } => Some(source), + } + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + + #[test] + fn constructor_enforces_the_webdriver_bidi_js_uint_range() { + let accepted = WebDriverBiDiSessionEndCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT); + assert_eq!( + accepted.ok().map(|command| command.command_id()), + Some(MAX_WEBDRIVER_BIDI_JS_UINT) + ); + + let rejected = WebDriverBiDiSessionEndCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT + 1); + assert_eq!( + rejected.err().map(|error| error.to_string()).as_deref(), + Some("WebDriver BiDi session.end command id is outside the js-uint range") + ); + } + + #[test] + fn command_serialization_is_static_and_exact() { + let command = WebDriverBiDiSessionEndCommand { command_id: 42 }; + assert_eq!(command.command_id(), 42); + assert_eq!( + command.serialized(), + r#"{"id":42,"method":"session.end","params":{}}"# + ); + } + + #[test] + fn command_errors_have_stable_messages_and_typed_sources() { + let range = WebDriverBiDiSessionEndCommandError::CommandIdOutOfRange { + command_id: MAX_WEBDRIVER_BIDI_JS_UINT + 1, + maximum_command_id: MAX_WEBDRIVER_BIDI_JS_UINT, + }; + assert_eq!( + range.to_string(), + "WebDriver BiDi session.end command id is outside the js-uint range" + ); + assert!(range.source().is_none()); + + let correlation = WebDriverBiDiSessionEndCommandError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandAlreadyOutstanding, + }; + assert_eq!( + correlation.to_string(), + "WebDriver BiDi session.end command correlation was rejected" + ); + assert!(correlation.source().is_some()); + + let frame = WebDriverBiDiSessionEndCommandError::FrameWrite { + source: WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 0, + source: io::Error::other("test frame failure"), + }, + }; + assert_eq!( + frame.to_string(), + "WebDriver BiDi session.end command frame write failed" + ); + assert!(frame.source().is_some()); + } + + #[test] + fn only_frame_preflight_malformed_errors_retire_registered_correlation() { + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + assert!( + correlation + .register_command_for(1, WebDriverBiDiCommandKind::SessionEnd) + .is_ok() + ); + let preflight = WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "test preflight rejection", + }; + assert_eq!( + map_frame_failure(&mut correlation, 1, preflight).to_string(), + "WebDriver BiDi session.end command frame write failed" + ); + assert_eq!(correlation.outstanding_count(), 0); + + assert!( + correlation + .register_command_for(2, WebDriverBiDiCommandKind::SessionEnd) + .is_ok() + ); + let ambiguous = WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::other("test ambiguous write failure"), + }; + assert_eq!( + map_frame_failure(&mut correlation, 2, ambiguous).to_string(), + "WebDriver BiDi session.end command frame write failed" + ); + assert_eq!(correlation.outstanding_count(), 1); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_command.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_command.rs new file mode 100644 index 000000000..71eeecc7c --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_command.rs @@ -0,0 +1,164 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandKind, WebDriverBiDiSessionEndCommand, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + let length = usize::from(header[1] & 0x7f); + if length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly required extended framing", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +#[test] +fn session_end_command_writes_the_exact_typed_frame_without_claiming_completion() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + if command != br#"{"id":11,"method":"session.end","params":{}}"# { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "unexpected session.end command: {}", + String::from_utf8_lossy(&command) + ), + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let command = WebDriverBiDiSessionEndCommand::new(11)?; + let _established = command.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + Duration::from_millis(500), + )?; + + assert_eq!(correlation.outstanding_count(), 1); + correlation.retire_command_for(11, WebDriverBiDiCommandKind::SessionEnd)?; + assert_eq!(correlation.outstanding_count(), 0); + server + .join() + .map_err(|_| io::Error::other("session.end command test server panicked"))??; + Ok(()) +} + +#[test] +fn session_end_reused_mask_key_rejection_retires_exact_correlation() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let seed = read_masked_text_frame(&mut stream)?; + if seed != b"{}" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected seed frame before reused-key regression", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let repeated_key = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let established = + established.write_text_frame("{}", repeated_key, Duration::from_millis(500))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let command = WebDriverBiDiSessionEndCommand::new(13)?; + assert!( + command + .send( + established, + &mut correlation, + repeated_key, + Duration::from_millis(500), + ) + .is_err() + ); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("reused-mask-key session.end server panicked"))??; + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_command_failures.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_command_failures.rs new file mode 100644 index 000000000..7bcc9fe02 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_command_failures.rs @@ -0,0 +1,136 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + MAX_WEBDRIVER_BIDI_JS_UINT, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiCommandCorrelation, + WebDriverBiDiCommandKind, WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndCommandError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +type HandshakeOnlyServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn establish_with_handshake_only_server() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn session_end_rejects_ids_above_the_webdriver_bidi_js_uint_range() { + let rejected = WebDriverBiDiSessionEndCommand::new(MAX_WEBDRIVER_BIDI_JS_UINT + 1); + assert_eq!( + rejected.err().map(|error| error.to_string()).as_deref(), + Some("WebDriver BiDi session.end command id is outside the js-uint range") + ); +} + +#[test] +fn session_end_rejects_duplicate_correlation_before_any_frame_write() -> Result<(), Box> +{ + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(7, WebDriverBiDiCommandKind::SessionEnd)?; + let command = WebDriverBiDiSessionEndCommand::new(7)?; + + let error = command + .send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + ) + .err() + .ok_or_else(|| io::Error::other("duplicate correlation unexpectedly sent session.end"))?; + assert!(matches!( + error, + WebDriverBiDiSessionEndCommandError::Correlation { .. } + )); + assert_eq!(correlation.outstanding_count(), 1); + + server + .join() + .map_err(|_| io::Error::other("duplicate-correlation session.end server panicked"))??; + Ok(()) +} + +#[test] +fn session_end_rejects_invalid_frame_timeout_before_correlation_registration() +-> Result<(), Box> { + for (command_id, frame_timeout) in [ + (11, Duration::ZERO), + (12, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_millis(1)), + ] { + let (established, server) = establish_with_handshake_only_server()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let command = WebDriverBiDiSessionEndCommand::new(command_id)?; + + let error = command + .send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]), + frame_timeout, + ) + .err() + .ok_or_else(|| { + io::Error::other("invalid frame timeout unexpectedly sent session.end") + })?; + assert!(matches!( + error, + WebDriverBiDiSessionEndCommandError::FrameWrite { .. } + )); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("invalid-timeout session.end server panicked"))??; + } + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs index 6e733163c..6779da6c1 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_status_response.rs @@ -224,3 +224,24 @@ fn empty_status_result_fails_before_consuming_the_outstanding_command() -> Resul assert_eq!(correlation.outstanding_count(), 1); Ok(()) } + +#[test] +fn replacement_status_reply_preserves_original_pending_request_and_recovery() +-> Result<(), Box> { + let (original, mut pending) = send_status_and_read_response(STATUS_RESPONSE)?; + let (replacement, _replacement_pending) = send_status_and_read_response(STATUS_RESPONSE)?; + + assert!(matches!( + WebDriverBiDiSessionStatusResult::parse_and_correlate(&replacement, &mut pending), + Err(WebDriverBiDiSessionStatusResponseError::Correlation { + source: originweave_network::WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 7, + }, + }) + )); + assert_eq!(pending.outstanding_count(), 1); + let completed = WebDriverBiDiSessionStatusResult::parse_and_correlate(&original, &mut pending)?; + assert_eq!(completed.command_id(), 7); + assert_eq!(pending.outstanding_count(), 0); + Ok(()) +} diff --git a/docs/doctoring.md b/docs/doctoring.md index b72c975ec..ffe0f82ee 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -10,7 +10,7 @@ The live WebDriver BiDi Editor's Draft dated 3 September 2026 defines the curren The same Editor's 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 Editor's 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. -The current Editor's Draft defines `session.status` as a static command whose command type is the exact method `session.status` with `EmptyParams`; the result contains `ready` as a boolean and `message` as text. OriginWeave's first outbound command slice therefore serializes only that standards-defined method and empty parameter object. A successful WebSocket frame write is transport progress, not command completion: the command remains outstanding until a later admitted WebDriver BiDi response is parsed and correlated to the exact command id. Local validation failures that prove no command bytes could have been emitted may release only the corresponding just-registered correlation; partial or ambiguous write failures retain correlation because remote receipt cannot be disproved. Regression coverage verifies that distinction from the resulting correlation count rather than from an internal error wrapper. +The current Editor's Draft defines `session.status` as a static command whose command type is the exact method `session.status` with `EmptyParams`; the result contains `ready` as a boolean and `message` as text. It also defines `session.end` with `EmptyParams` to terminate the session. OriginWeave's typed outbound command slices serialize only those standards-defined methods and empty parameter objects. A successful WebSocket frame write is transport progress, not command completion: the command remains outstanding until a later admitted WebDriver BiDi response is parsed and correlated to the exact command id. Local validation failures that prove no command bytes could have been emitted may release only the corresponding just-registered correlation; partial or ambiguous write failures retain correlation because remote receipt cannot be disproved. Regression coverage verifies that distinction from the resulting correlation count rather than from an internal error wrapper. 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. @@ -124,6 +124,16 @@ The #250 integration preserves all five child-owned production and Rust-test blo On 6 September 2026, that historical no-connection-provenance state was superseded for the current #250 branch by an executed real-socket regression at `6b102c1d860629d1b3e1a49cb0c33d94ca825adb`: CI `34044758402` / Rust job `101517723552` showed that a same-session replacement WebSocket could consume the original pending `session.status` command. Parent #249 `e77150f4de6534887098fb9de7e02ecea7fbb59c` now registers `SessionStatus` with the established connection generation before frame I/O. #250 adopts that parent by an ordinary two-parent non-force merge and its command-specific response parser accepts only `WebDriverBiDiReceivedTextMessage`, correlating both success and protocol-error envelopes against the received connection generation. The regression requires typed `ResponseConnectionMismatch { command_id: 7 }` and preserves the original outstanding command after rejection. Malformed success projection and invalid protocol-error shape still fail before correlation consumption. This is transport/correlation provenance only: it does not grant browser policy authority, prove Chromium post-conditions or runtime process/profile teardown, or establish protected-main/release acceptance; exact-current hosted gates remain independently required. +### Session-end command parent integration evidence + +The #251 integration adopts #250 `ec433b844a121f8554c062f92267991af9cacb6f` by ordinary merge and retains both release records. Native discovery on predecessor `86e8ad76838f2a64aa7e0cd56ba1f931c8d0c3dc` collected zero command-correlation release tests; the current parent supplies the existing discoverable TestCase and synchronized opening-exchange fixtures. The session-end sender, its public exports and both child-owned Rust integration tests remain unchanged. The sender still registers only the exact typed command, retires correlation only after proven preflight rejection, and leaves ambiguous writes outstanding. A successful frame write does not prove session termination, browser-process exit, profile deletion, authenticated connection provenance or real Chromium acceptance. Local verification and current-head hosted checks remain separate prerequisites; earlier stack results do not transfer. + +### Current session-end sender adoption of status-reply provenance + +On 7 September 2026, the session-end sender stack reproduced the inherited status-reply gap at `edec535b8d8b3c843f9f9baf8562f31ff5c7af23`: two real loopback connections using the same session and command id allowed the replacement reply to complete the original pending status request. The ordinary merge of #250 `bbdc6ace7a5932adf24836700f806850e6b230bc` preserves the session-end sender and both original sender integration-test files byte-for-byte while inheriting sealed status receipts and connection-aware correlation. The regression now requires the exact connection-mismatch error, unchanged pending count, and subsequent completion using the original received reply. The server fixtures have already finished; this proves retained reply/correlation recovery, not liveness of an open original stream or same-endpoint replacement coverage. + +The shared routing validation, result projection order, and parent connection safeguards are unchanged. This supersedes the earlier no-received-provenance description for the status path only: the session-end sender still makes no received-acknowledgment, process-exit, profile-cleanup, browser-policy or release-acceptance claim. End-response provenance belongs to its separate consumer. Local tests and exact-head hosted checks must be evaluated independently after this adoption. + ## References Amazon Web Services. (n.d.). *Set up the Amazon EKS Pod Identity Agent*. Retrieved August 6, 2026, from https://docs.aws.amazon.com/eks/latest/userguide/pod-id-agent-setup.html @@ -206,4 +216,4 @@ World Wide Web Consortium. (2026, August 5). *Accessible name and description co 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 \ No newline at end of file +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