From 7689d2729401a0c95ad40616824fb17e66b21960 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 04:49:39 -0700 Subject: [PATCH 1/9] test(network): require typed session.end response admission --- .../webdriver_bidi_session_end_response.rs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_session_end_response.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs new file mode 100644 index 000000000..a28f4c5de --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs @@ -0,0 +1,126 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndResult, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, +}; + +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"; +const END_RESPONSE: &[u8] = + br#"{"type":"success","id":7,"result":{"vendorExtension":{"clean":true}}}"#; + +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, + "session.end 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_success_accepts_extensible_empty_result_and_consumes_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 command = read_masked_text_frame(&mut stream)?; + if command != br#"{"id":7,"method":"session.end","params":{}}"# { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected session.end command", + )); + } + stream.write_all(&[0x81, END_RESPONSE.len() as u8])?; + stream.write_all(END_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))?; + + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + let established = WebDriverBiDiSessionEndCommand::new(7)?.send( + established, + &mut correlation, + WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + assert_eq!(correlation.outstanding_count(), 1); + + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let text = match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "session.end response produced unexpected assembly state: {other:?}" + )) + .into()); + } + }; + + let result = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation)?; + assert_eq!(result.command_id(), 7); + assert_eq!(correlation.outstanding_count(), 0); + + server + .join() + .map_err(|_| io::Error::other("session.end response test server panicked"))??; + Ok(()) +} From 8b597de328a521832de74392653594ade849a24b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 04:51:57 -0700 Subject: [PATCH 2/9] feat(network): admit typed session.end response --- .../webdriver_bidi_session_end_response.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_session_end_response.rs diff --git a/crates/originweave-network/src/webdriver_bidi_session_end_response.rs b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs new file mode 100644 index 000000000..f208e9f80 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs @@ -0,0 +1,136 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, + WebDriverBiDiWebSocketTextMessage, +}; + +/// Typed protocol acknowledgment for one correlated WebDriver BiDi `session.end` command. +/// +/// WebDriver BiDi defines `session.EndResult` as the extensible `EmptyResult` object. The common +/// local-end envelope parser already validates the complete JSON document and requires a success +/// `result` object, so this command-specific boundary intentionally retains no generic result body +/// and accepts extension members. This value proves only that the remote end returned a correlated +/// protocol success; it does not prove Chromium process exit, profile deletion, resource release, +/// or any other OriginWeave operational teardown postcondition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiSessionEndResult { + command_id: u64, +} + +impl WebDriverBiDiSessionEndResult { + /// Parse one bounded local-end message and consume its exact outstanding command on response. + /// + /// Complete JSON and common WebDriver BiDi envelope validation occur before correlation state + /// can be consumed. Successful responses retain only the matched command id. A correlatable + /// protocol-error response consumes its matching id and returns a typed remote failure, while + /// events, null-id errors, malformed envelopes, and unknown ids fail closed without consuming + /// unrelated outstanding state. + pub fn parse_and_correlate( + message: &WebDriverBiDiWebSocketTextMessage, + correlation: &mut WebDriverBiDiCommandCorrelation, + ) -> Result { + let envelope = WebDriverBiDiJsonEnvelope::parse(message) + .map_err(|source| WebDriverBiDiSessionEndResponseError::Envelope { source })?; + let completed = correlation + .correlate_response(&envelope) + .map_err(|source| WebDriverBiDiSessionEndResponseError::Correlation { source })?; + + match completed.outcome() { + WebDriverBiDiCorrelatedResponseOutcome::Success => Ok(Self { + command_id: completed.command_id(), + }), + WebDriverBiDiCorrelatedResponseOutcome::Error => { + Err(WebDriverBiDiSessionEndResponseError::RemoteProtocolError { + command_id: completed.command_id(), + }) + } + } + } + + /// Return the exact local command identifier consumed by this protocol acknowledgment. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } +} + +/// Fail-closed failures while admitting one typed WebDriver BiDi `session.end` response. +#[derive(Debug)] +pub enum WebDriverBiDiSessionEndResponseError { + /// Common local-end JSON envelope validation failed before correlation state was touched. + Envelope { + /// Exact common-envelope validation failure. + source: WebDriverBiDiJsonEnvelopeError, + }, + /// Exact command-response correlation failed without consuming unrelated state. + Correlation { + /// Exact typed correlation failure. + source: WebDriverBiDiCommandCorrelationError, + }, + /// The remote end returned a correlatable WebDriver BiDi protocol error for this command. + RemoteProtocolError { + /// Exact local command identifier consumed by the protocol-error response. + command_id: u64, + }, +} + +impl fmt::Display for WebDriverBiDiSessionEndResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Envelope { .. } => { + formatter.write_str("WebDriver BiDi session.end envelope is invalid") + } + Self::Correlation { .. } => { + formatter.write_str("WebDriver BiDi session.end response correlation failed") + } + Self::RemoteProtocolError { .. } => { + formatter.write_str("WebDriver BiDi session.end returned a protocol error") + } + } + } +} + +impl Error for WebDriverBiDiSessionEndResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Envelope { source } => Some(source), + Self::Correlation { source } => Some(source), + Self::RemoteProtocolError { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_errors_have_stable_messages_and_typed_sources() { + let envelope = WebDriverBiDiSessionEndResponseError::Envelope { + source: WebDriverBiDiJsonEnvelopeError::InvalidJson, + }; + assert_eq!( + envelope.to_string(), + "WebDriver BiDi session.end envelope is invalid" + ); + assert!(envelope.source().is_some()); + + let correlation = WebDriverBiDiSessionEndResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandNotOutstanding, + }; + assert_eq!( + correlation.to_string(), + "WebDriver BiDi session.end response correlation failed" + ); + assert!(correlation.source().is_some()); + + let remote = WebDriverBiDiSessionEndResponseError::RemoteProtocolError { command_id: 7 }; + assert_eq!( + remote.to_string(), + "WebDriver BiDi session.end returned a protocol error" + ); + assert!(remote.source().is_none()); + } +} From c19fd170e25ec782f4f37043cc08a99ab5f7881d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 04:52:30 -0700 Subject: [PATCH 3/9] feat(network): export typed session.end response --- crates/originweave-network/src/lib.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 3b14bd575..35b1f7cc3 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -9,9 +9,8 @@ //! unmasked server-frame reads, assembles bounded WebDriver BiDi text messages, //! classifies complete local-end JSON envelopes, tracks bounded command-response //! 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. +//! and admits typed correlated status and end responses without exposing generic +//! JSON bodies or granting browser, TLS, policy, secret, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -21,6 +20,7 @@ mod webdriver_bidi_command_correlation; mod webdriver_bidi_connection; mod webdriver_bidi_json_envelope; mod webdriver_bidi_session_end_command; +mod webdriver_bidi_session_end_response; mod webdriver_bidi_session_status_command; mod webdriver_bidi_session_status_response; mod webdriver_bidi_websocket_frame; @@ -51,6 +51,9 @@ pub use webdriver_bidi_json_envelope::{ pub use webdriver_bidi_session_end_command::{ WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndCommandError, }; +pub use webdriver_bidi_session_end_response::{ + WebDriverBiDiSessionEndResponseError, WebDriverBiDiSessionEndResult, +}; pub use webdriver_bidi_session_status_command::{ WebDriverBiDiSessionStatusCommand, WebDriverBiDiSessionStatusCommandError, }; From 4d0d4dbf5a85f0a02feedba73e3b1974cda3eb2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 05:01:38 -0700 Subject: [PATCH 4/9] style(network): apply canonical session.end rustfmt --- .../src/webdriver_bidi_session_end_response.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_session_end_response.rs b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs index f208e9f80..10d183d9f 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs @@ -2,8 +2,8 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, - WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, WebDriverBiDiJsonEnvelopeError, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, }; /// Typed protocol acknowledgment for one correlated WebDriver BiDi `session.end` command. From 54b9744932da0f416418b53ef8b1d085ee1f3c8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 05:02:57 -0700 Subject: [PATCH 5/9] test(network): cover session.end fail-closed responses --- .../webdriver_bidi_session_end_response.rs | 110 ++++++++++++++++-- 1 file changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs index a28f4c5de..b6207ea22 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs @@ -8,17 +8,24 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndResult, + WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, + WebDriverBiDiSessionEndResponseError, WebDriverBiDiSessionEndResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiWebSocketTextMessage, }; 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"; -const END_RESPONSE: &[u8] = +const END_SUCCESS_RESPONSE: &[u8] = br#"{"type":"success","id":7,"result":{"vendorExtension":{"clean":true}}}"#; +const END_REMOTE_ERROR_RESPONSE: &[u8] = + br#"{"type":"error","id":7,"error":"unknown error","message":"remote refused"}"#; +const END_UNKNOWN_ID_RESPONSE: &[u8] = + br#"{"type":"success","id":8,"result":{"vendorExtension":true}}"#; +const END_MALFORMED_RESPONSE: &[u8] = br#"{"type":"success","id":7}"#; fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; @@ -63,9 +70,15 @@ fn read_masked_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -#[test] -fn session_end_success_accepts_extensible_empty_result_and_consumes_exact_correlation() --> Result<(), Box> { +fn send_end_and_read_response( + response: &'static [u8], +) -> Result< + ( + WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiCommandCorrelation, + ), + Box, +> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -79,8 +92,8 @@ fn session_end_success_accepts_extensible_empty_result_and_consumes_exact_correl "unexpected session.end command", )); } - stream.write_all(&[0x81, END_RESPONSE.len() as u8])?; - stream.write_all(END_RESPONSE) + stream.write_all(&[0x81, response.len() as u8])?; + stream.write_all(response) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -101,7 +114,6 @@ fn session_end_success_accepts_extensible_empty_result_and_consumes_exact_correl WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), Duration::from_millis(500), )?; - assert_eq!(correlation.outstanding_count(), 1); let (_established, frame) = established.read_frame(Duration::from_millis(500))?; let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); @@ -115,12 +127,88 @@ fn session_end_success_accepts_extensible_empty_result_and_consumes_exact_correl } }; + server + .join() + .map_err(|_| io::Error::other("session.end response test server panicked"))??; + Ok((text, correlation)) +} + +#[test] +fn session_end_success_accepts_extensible_empty_result_and_consumes_exact_correlation() +-> Result<(), Box> { + let (text, mut correlation) = send_end_and_read_response(END_SUCCESS_RESPONSE)?; + assert_eq!(correlation.outstanding_count(), 1); + let result = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation)?; assert_eq!(result.command_id(), 7); assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} - server - .join() - .map_err(|_| io::Error::other("session.end response test server panicked"))??; +#[test] +fn session_end_remote_error_consumes_only_the_correlated_command() -> Result<(), Box> { + let (text, mut correlation) = send_end_and_read_response(END_REMOTE_ERROR_RESPONSE)?; + let parsed = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation); + let error = match parsed { + Ok(_) => return Err(io::Error::other("remote error was accepted as session.end success").into()), + Err(error) => error, + }; + + assert!(matches!( + error, + WebDriverBiDiSessionEndResponseError::RemoteProtocolError { command_id: 7 } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi session.end returned a protocol error" + ); + assert!(error.source().is_none()); + assert_eq!(correlation.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn malformed_session_end_envelope_fails_before_consuming_correlation() -> Result<(), Box> +{ + let (text, mut correlation) = send_end_and_read_response(END_MALFORMED_RESPONSE)?; + let parsed = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation); + let error = match parsed { + Ok(_) => return Err(io::Error::other("malformed session.end response was accepted").into()), + Err(error) => error, + }; + + assert!(matches!( + error, + WebDriverBiDiSessionEndResponseError::Envelope { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi session.end envelope is invalid" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} + +#[test] +fn unknown_session_end_response_id_does_not_consume_the_outstanding_command() +-> Result<(), Box> { + let (text, mut correlation) = send_end_and_read_response(END_UNKNOWN_ID_RESPONSE)?; + let parsed = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation); + let error = match parsed { + Ok(_) => return Err(io::Error::other("unknown session.end response id was accepted").into()), + Err(error) => error, + }; + + assert!(matches!( + error, + WebDriverBiDiSessionEndResponseError::Correlation { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi session.end response correlation failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); Ok(()) } From 24e3ca70aa0a44c1f3bab339f12327a147b24650 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 05:06:15 -0700 Subject: [PATCH 6/9] style(network): apply canonical session.end test rustfmt --- .../tests/webdriver_bidi_session_end_response.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs index b6207ea22..4ba54e8e8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs @@ -150,7 +150,9 @@ fn session_end_remote_error_consumes_only_the_correlated_command() -> Result<(), let (text, mut correlation) = send_end_and_read_response(END_REMOTE_ERROR_RESPONSE)?; let parsed = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation); let error = match parsed { - Ok(_) => return Err(io::Error::other("remote error was accepted as session.end success").into()), + Ok(_) => { + return Err(io::Error::other("remote error was accepted as session.end success").into()); + } Err(error) => error, }; @@ -173,7 +175,9 @@ fn malformed_session_end_envelope_fails_before_consuming_correlation() -> Result let (text, mut correlation) = send_end_and_read_response(END_MALFORMED_RESPONSE)?; let parsed = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation); let error = match parsed { - Ok(_) => return Err(io::Error::other("malformed session.end response was accepted").into()), + Ok(_) => { + return Err(io::Error::other("malformed session.end response was accepted").into()); + } Err(error) => error, }; @@ -196,7 +200,9 @@ fn unknown_session_end_response_id_does_not_consume_the_outstanding_command() let (text, mut correlation) = send_end_and_read_response(END_UNKNOWN_ID_RESPONSE)?; let parsed = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation); let error = match parsed { - Ok(_) => return Err(io::Error::other("unknown session.end response id was accepted").into()), + Ok(_) => { + return Err(io::Error::other("unknown session.end response id was accepted").into()); + } Err(error) => error, }; From e12bd0e637b65bb73b4b4cba2be2e43dbfed8fe7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 05:08:14 -0700 Subject: [PATCH 7/9] style(network): converge session.end canonical rustfmt --- .../tests/webdriver_bidi_session_end_response.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs index 4ba54e8e8..042f5137b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs @@ -151,7 +151,9 @@ fn session_end_remote_error_consumes_only_the_correlated_command() -> Result<(), let parsed = WebDriverBiDiSessionEndResult::parse_and_correlate(&text, &mut correlation); let error = match parsed { Ok(_) => { - return Err(io::Error::other("remote error was accepted as session.end success").into()); + return Err( + io::Error::other("remote error was accepted as session.end success").into(), + ); } Err(error) => error, }; From 6aaf7f3fd15b5f14dc7b320c29a40efe0e618391 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:14:20 +0900 Subject: [PATCH 8/9] test(network): reject replacement session-end replies Co-Authored-By: OpenAI Codex Signed-off-by: Seongho Bae --- .../webdriver_bidi_session_end_response.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs index 042f5137b..0f34a17f5 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs @@ -27,6 +27,24 @@ const END_UNKNOWN_ID_RESPONSE: &[u8] = br#"{"type":"success","id":8,"result":{"vendorExtension":true}}"#; const END_MALFORMED_RESPONSE: &[u8] = br#"{"type":"success","id":7}"#; +#[test] +fn replacement_end_replies_preserve_original_pending_request_and_recovery() +-> Result<(), Box> { + for response in [END_SUCCESS_RESPONSE, END_REMOTE_ERROR_RESPONSE] { + let (original, mut pending) = send_end_and_read_response(END_SUCCESS_RESPONSE)?; + let (replacement, _) = send_end_and_read_response(response)?; + assert!(matches!( + WebDriverBiDiSessionEndResult::parse_and_correlate(&replacement, &mut pending), + Err(WebDriverBiDiSessionEndResponseError::Correlation { .. }) + )); + assert_eq!(pending.outstanding_count(), 1); + let result = WebDriverBiDiSessionEndResult::parse_and_correlate(&original, &mut pending)?; + assert_eq!(result.command_id(), 7); + assert_eq!(pending.outstanding_count(), 0); + } + Ok(()) +} + fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut request = Vec::new(); From 363a78e36e7690e9ed5bf49829567e00e2ec5d59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:17:51 +0900 Subject: [PATCH 9/9] fix(network): bind session-end replies to their sending connection Co-Authored-By: OpenAI Codex Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/webdriver_bidi_session_end_command.rs | 9 ++- .../webdriver_bidi_session_end_response.rs | 16 +++-- .../webdriver_bidi_session_end_response.rs | 70 ++++++++++++++++--- docs/doctoring.md | 6 ++ 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b3bfae9..6127f0374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Session-ending replies from a replacement connection can no longer complete the original pending request. The original reply remains usable, and a protocol acknowledgment still does not prove browser shutdown or cleanup. - 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. - Typed `session.end` response admission that consumes only the exact outstanding command-kind correlation after complete envelope validation, preserves remote protocol errors as failures, and does not claim browser-process exit or resource cleanup from a protocol acknowledgment. diff --git a/crates/originweave-network/src/webdriver_bidi_session_end_command.rs b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs index 29fbe05aa..e9d3f7174 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_end_command.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_end_command.rs @@ -41,7 +41,8 @@ impl WebDriverBiDiSessionEndCommand { /// 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. + /// side effect. Correlation then binds the command to this connection before the first possible + /// frame write. Only a reply received on this same connection can complete that registration. /// 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 @@ -62,7 +63,11 @@ impl WebDriverBiDiSessionEndCommand { }); } correlation - .register_command_for(self.command_id, WebDriverBiDiCommandKind::SessionEnd) + .register_command_for_connection( + self.command_id, + WebDriverBiDiCommandKind::SessionEnd, + established.transport_evidence().connection_generation(), + ) .map_err(|source| WebDriverBiDiSessionEndCommandError::Correlation { source })?; let message = self.serialized(); match established.write_text_frame(&message, masking_key, frame_timeout) { diff --git a/crates/originweave-network/src/webdriver_bidi_session_end_response.rs b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs index 9ab083230..cd284f134 100644 --- a/crates/originweave-network/src/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/src/webdriver_bidi_session_end_response.rs @@ -3,7 +3,7 @@ use std::{error::Error, fmt}; use crate::{ WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, WebDriverBiDiCommandKind, WebDriverBiDiCorrelatedResponseOutcome, WebDriverBiDiJsonEnvelope, - WebDriverBiDiJsonEnvelopeError, WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiJsonEnvelopeError, WebDriverBiDiReceivedTextMessage, }; /// Typed protocol acknowledgment for one correlated WebDriver BiDi `session.end` command. @@ -26,15 +26,21 @@ impl WebDriverBiDiSessionEndResult { /// can be consumed. Successful responses retain only the matched command id. A correlatable /// protocol-error response consumes its matching id and returns a typed remote failure, while /// events, null-id errors, malformed envelopes, unknown ids, and command-kind mismatches fail - /// closed without consuming unrelated outstanding state. + /// closed without consuming unrelated outstanding state. Only a sealed reply from the same + /// connection that registered the command can consume it; a replacement connection cannot + /// complete the request even when its session and command identifiers match. pub fn parse_and_correlate( - message: &WebDriverBiDiWebSocketTextMessage, + message: &WebDriverBiDiReceivedTextMessage, correlation: &mut WebDriverBiDiCommandCorrelation, ) -> Result { - let envelope = WebDriverBiDiJsonEnvelope::parse(message) + let envelope = WebDriverBiDiJsonEnvelope::parse(message.message()) .map_err(|source| WebDriverBiDiSessionEndResponseError::Envelope { source })?; let completed = correlation - .correlate_response_for(&envelope, WebDriverBiDiCommandKind::SessionEnd) + .correlate_response_for_connection( + &envelope, + WebDriverBiDiCommandKind::SessionEnd, + message.connection_generation(), + ) .map_err(|source| WebDriverBiDiSessionEndResponseError::Correlation { source })?; match completed.outcome() { diff --git a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs index 0f34a17f5..610526556 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_end_response.rs @@ -8,12 +8,12 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, - WebDriverBiDiSessionEndResponseError, WebDriverBiDiSessionEndResult, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiCommandCorrelation, WebDriverBiDiCommandCorrelationError, + WebDriverBiDiConnectionMessageRead, WebDriverBiDiReceivedTextMessage, + WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndResponseError, + WebDriverBiDiSessionEndResult, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiWebSocketMessageReader, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -27,6 +27,50 @@ const END_UNKNOWN_ID_RESPONSE: &[u8] = br#"{"type":"success","id":8,"result":{"vendorExtension":true}}"#; const END_MALFORMED_RESPONSE: &[u8] = br#"{"type":"success","id":7}"#; +#[test] +fn unbound_end_command_cannot_consume_a_connection_bound_reply() -> Result<(), Box> { + use originweave_network::WebDriverBiDiCommandKind; + + let (message, mut original) = send_end_and_read_response(END_SUCCESS_RESPONSE)?; + let mut unbound = WebDriverBiDiCommandCorrelation::new(); + unbound.register_command_for(7, WebDriverBiDiCommandKind::SessionEnd)?; + assert!(matches!( + WebDriverBiDiSessionEndResult::parse_and_correlate(&message, &mut unbound), + Err(WebDriverBiDiSessionEndResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::CommandConnectionProvenanceMissing { + command_id: 7, + }, + }) + )); + assert_eq!(unbound.outstanding_count(), 1); + let result = WebDriverBiDiSessionEndResult::parse_and_correlate(&message, &mut original)?; + assert_eq!(result.command_id(), 7); + assert_eq!(original.outstanding_count(), 0); + Ok(()) +} + +#[test] +fn event_and_null_id_error_preserve_the_sent_end_command() -> Result<(), Box> { + for (document, expected) in [ + ( + br#"{"type":"event","method":"log.entryAdded","params":{}}"#.as_slice(), + WebDriverBiDiCommandCorrelationError::EventIsNotResponse, + ), + ( + br#"{"type":"error","id":null,"error":"unknown error","message":"remote"}"#.as_slice(), + WebDriverBiDiCommandCorrelationError::UncorrelatableErrorResponse, + ), + ] { + let (message, mut correlation) = send_end_and_read_response(document)?; + assert!(matches!( + WebDriverBiDiSessionEndResult::parse_and_correlate(&message, &mut correlation), + Err(WebDriverBiDiSessionEndResponseError::Correlation { source }) if source == expected + )); + assert_eq!(correlation.outstanding_count(), 1); + } + Ok(()) +} + #[test] fn replacement_end_replies_preserve_original_pending_request_and_recovery() -> Result<(), Box> { @@ -35,7 +79,11 @@ fn replacement_end_replies_preserve_original_pending_request_and_recovery() let (replacement, _) = send_end_and_read_response(response)?; assert!(matches!( WebDriverBiDiSessionEndResult::parse_and_correlate(&replacement, &mut pending), - Err(WebDriverBiDiSessionEndResponseError::Correlation { .. }) + Err(WebDriverBiDiSessionEndResponseError::Correlation { + source: WebDriverBiDiCommandCorrelationError::ResponseConnectionMismatch { + command_id: 7 + } + }) )); assert_eq!(pending.outstanding_count(), 1); let result = WebDriverBiDiSessionEndResult::parse_and_correlate(&original, &mut pending)?; @@ -92,7 +140,7 @@ fn send_end_and_read_response( response: &'static [u8], ) -> Result< ( - WebDriverBiDiWebSocketTextMessage, + WebDriverBiDiReceivedTextMessage, WebDriverBiDiCommandCorrelation, ), Box, @@ -133,10 +181,10 @@ fn send_end_and_read_response( Duration::from_millis(500), )?; - let (_established, frame) = established.read_frame(Duration::from_millis(500))?; - let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); - let text = match assembler.push_frame(frame)? { - WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + let text = match WebDriverBiDiWebSocketMessageReader::new(established) + .read_next(Duration::from_millis(500))? + { + WebDriverBiDiConnectionMessageRead::Text { message, .. } => message, other => { return Err(io::Error::other(format!( "session.end response produced unexpected assembly state: {other:?}" diff --git a/docs/doctoring.md b/docs/doctoring.md index 88aca96b9..42a289dd1 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -138,6 +138,12 @@ On 7 September 2026, the session-end sender stack reproduced the inherited statu 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. +### Current session-end response connection binding + +On 7 September 2026, regression `6aaf7f3f` reproduced replacement-reply acceptance on #252. Ordinary adoption of #251 `924ad97551750d4a901ded38b89488cc5438e54f` retained the failure, confirming that parent status safeguards alone did not repair the end-response consumer. The end sender now uses the existing connection-bound registration before writing, and the response parser accepts the existing sealed received-text type and checks that connection through shared correlation before consuming pending state. The original sender preflight retirement and ambiguous-write behavior, result shape, command-id accessor and remote-error classification remain unchanged. No new authority accessor or duplicate reader is introduced. + +Real loopback tests require exact connection-mismatch rejection for replacement success and error replies, unchanged original pending state, and subsequent completion using the original received reply. They also reject unbound registrations, events and null-id errors without consuming the pending command. These fixtures use separate listener endpoints and retain receipts after their server threads finish; they do not prove same-endpoint replacement rejection or liveness of an open original connection. This supersedes the earlier unbound-response description for the current #252 branch only. A correlated acknowledgment still does not prove browser-process exit, profile deletion, resource cleanup, browser policy authority, protected integration or release acceptance. Exact-head local and hosted verification remain separate. + ## 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