From 77aa507e6ef21dff24593f9ead029f8679fef756 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 06:14:04 -0700 Subject: [PATCH 1/5] test(network): require operational teardown assessment --- ...driver_bidi_session_teardown_assessment.rs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs b/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs new file mode 100644 index 000000000..4cd44ac08 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs @@ -0,0 +1,159 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, + WebDriverBiDiSessionEndResult, WebDriverBiDiSessionTeardownAssessment, + WebDriverBiDiSessionTeardownDisposition, WebDriverBiDiSessionTeardownObservations, + 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_SUCCESS_RESPONSE: &[u8] = br#"{"type":"success","id":7,"result":{}}"#; + +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) +} + +fn correlated_session_end_ack() -> 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 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_SUCCESS_RESPONSE.len() as u8])?; + stream.write_all(END_SUCCESS_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), + )?; + 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()); + } + }; + server + .join() + .map_err(|_| io::Error::other("session.end teardown test server panicked"))??; + Ok(WebDriverBiDiSessionEndResult::parse_and_correlate( + &text, + &mut correlation, + )?) +} + +#[test] +fn protocol_ack_does_not_claim_operational_teardown_without_all_observations() +-> Result<(), Box> { + let acknowledged = correlated_session_end_ack()?; + let assessment = WebDriverBiDiSessionTeardownAssessment::from_protocol_ack( + acknowledged, + WebDriverBiDiSessionTeardownObservations::new(true, false, true), + ); + + assert_eq!(assessment.command_id(), 7); + assert!(assessment.observations().transport_closed_observed()); + assert!(!assessment.observations().browser_process_exited_observed()); + assert!(assessment.observations().task_profile_removed_observed()); + assert!(!assessment.is_operationally_complete()); + assert_eq!( + assessment.disposition(), + WebDriverBiDiSessionTeardownDisposition::OperationalTeardownPending + ); + Ok(()) +} + +#[test] +fn correlated_ack_plus_all_operational_observations_is_complete() -> Result<(), Box> { + let acknowledged = correlated_session_end_ack()?; + let assessment = WebDriverBiDiSessionTeardownAssessment::from_protocol_ack( + acknowledged, + WebDriverBiDiSessionTeardownObservations::new(true, true, true), + ); + + assert!(assessment.is_operationally_complete()); + assert_eq!( + assessment.disposition(), + WebDriverBiDiSessionTeardownDisposition::OperationallyComplete + ); + assert_eq!(assessment.command_id(), 7); + Ok(()) +} From b5396ee3042ca24357a4b32bd80885f67a4646c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 06:17:06 -0700 Subject: [PATCH 2/5] test(network): format teardown assessment regression --- .../webdriver_bidi_session_teardown_assessment.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs b/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs index 4cd44ac08..708132993 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs @@ -8,12 +8,12 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, - WebDriverBiDiSessionEndResult, WebDriverBiDiSessionTeardownAssessment, - WebDriverBiDiSessionTeardownDisposition, WebDriverBiDiSessionTeardownObservations, - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketMessageAssembler, WebDriverBiDiWebSocketMessageAssembly, + WebDriverBiDiCommandCorrelation, WebDriverBiDiSessionEndCommand, WebDriverBiDiSessionEndResult, + WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownDisposition, + WebDriverBiDiSessionTeardownObservations, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; From 7b0880a265e5c2a44ba8331cb71aa0ea1bab43af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 06:18:53 -0700 Subject: [PATCH 3/5] feat(network): separate protocol ack from teardown evidence --- .../src/webdriver_bidi_session_teardown.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_session_teardown.rs diff --git a/crates/originweave-network/src/webdriver_bidi_session_teardown.rs b/crates/originweave-network/src/webdriver_bidi_session_teardown.rs new file mode 100644 index 000000000..d214f4c0a --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_session_teardown.rs @@ -0,0 +1,119 @@ +use crate::WebDriverBiDiSessionEndResult; + +/// Fail-closed operational disposition derived from explicit teardown observations. +/// +/// This value does not authenticate any observation or grant process, profile, browser, network, +/// policy, or Agent authority. `OperationallyComplete` means only that the caller supplied all +/// reviewed observation classes after a correlated `session.end` protocol acknowledgment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebDriverBiDiSessionTeardownDisposition { + /// One or more required operational teardown observations remain absent. + OperationalTeardownPending, + /// Every required operational teardown observation was supplied. + OperationallyComplete, +} + +/// Explicit operational observations required after a correlated WebDriver BiDi `session.end` ack. +/// +/// These booleans are deliberately observation facts, not authority or evidence provenance. The +/// trusted browser/process/profile owner remains responsible for producing and authenticating the +/// underlying observations before constructing this value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiSessionTeardownObservations { + transport_closed_observed: bool, + browser_process_exited_observed: bool, + task_profile_removed_observed: bool, +} + +impl WebDriverBiDiSessionTeardownObservations { + /// Construct the three explicit operational observations required by this boundary. + #[must_use] + pub const fn new( + transport_closed_observed: bool, + browser_process_exited_observed: bool, + task_profile_removed_observed: bool, + ) -> Self { + Self { + transport_closed_observed, + browser_process_exited_observed, + task_profile_removed_observed, + } + } + + /// Return whether closure of the exact session transport was observed. + #[must_use] + pub const fn transport_closed_observed(&self) -> bool { + self.transport_closed_observed + } + + /// Return whether exit of the owned browser process was observed. + #[must_use] + pub const fn browser_process_exited_observed(&self) -> bool { + self.browser_process_exited_observed + } + + /// Return whether removal of the owned task profile was observed. + #[must_use] + pub const fn task_profile_removed_observed(&self) -> bool { + self.task_profile_removed_observed + } + + const fn operationally_complete(&self) -> bool { + self.transport_closed_observed + & self.browser_process_exited_observed + & self.task_profile_removed_observed + } +} + +/// One correlated `session.end` acknowledgment kept separate from operational teardown evidence. +/// +/// A protocol acknowledgment alone is never operational completion. Callers must separately +/// provide all reviewed transport/process/profile observations, and the observations themselves +/// remain non-authoritative until authenticated by their owning runtime boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiSessionTeardownAssessment { + protocol_ack: WebDriverBiDiSessionEndResult, + observations: WebDriverBiDiSessionTeardownObservations, +} + +impl WebDriverBiDiSessionTeardownAssessment { + /// Bind one correlated protocol acknowledgment to separately supplied operational observations. + #[must_use] + pub const fn from_protocol_ack( + protocol_ack: WebDriverBiDiSessionEndResult, + observations: WebDriverBiDiSessionTeardownObservations, + ) -> Self { + Self { + protocol_ack, + observations, + } + } + + /// Return the exact command id proven by the correlated protocol acknowledgment. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.protocol_ack.command_id() + } + + /// Borrow the explicit operational observations bound to this assessment. + #[must_use] + pub const fn observations(&self) -> &WebDriverBiDiSessionTeardownObservations { + &self.observations + } + + /// Return whether all required operational teardown observations are present. + #[must_use] + pub const fn is_operationally_complete(&self) -> bool { + self.observations.operationally_complete() + } + + /// Return the fail-closed disposition for the currently supplied observations. + #[must_use] + pub const fn disposition(&self) -> WebDriverBiDiSessionTeardownDisposition { + if self.is_operationally_complete() { + WebDriverBiDiSessionTeardownDisposition::OperationallyComplete + } else { + WebDriverBiDiSessionTeardownDisposition::OperationalTeardownPending + } + } +} From 1dbf3a88afa7022b0a6c82a83dd5d0b47c4669a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 06:19:17 -0700 Subject: [PATCH 4/5] feat(network): expose teardown assessment boundary --- crates/originweave-network/src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 35b1f7cc3..ff640fac3 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -9,8 +9,9 @@ //! 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 typed correlated status and end responses without exposing generic -//! JSON bodies or granting browser, TLS, policy, secret, or Agent authority. +//! admits typed correlated status and end responses, and keeps protocol teardown +//! acknowledgment separate from explicit operational teardown observations without +//! exposing generic JSON bodies or granting browser, TLS, policy, secret, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -23,6 +24,7 @@ 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_session_teardown; mod webdriver_bidi_websocket_frame; mod webdriver_bidi_websocket_handshake; mod webdriver_bidi_websocket_message; @@ -61,6 +63,10 @@ pub use webdriver_bidi_session_status_response::{ MAX_WEBDRIVER_BIDI_SESSION_STATUS_MESSAGE_SIZE, WebDriverBiDiSessionStatusResponseError, WebDriverBiDiSessionStatusResult, }; +pub use webdriver_bidi_session_teardown::{ + WebDriverBiDiSessionTeardownAssessment, WebDriverBiDiSessionTeardownDisposition, + WebDriverBiDiSessionTeardownObservations, +}; pub use webdriver_bidi_websocket_frame::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, From bfd2044208e0acfdcb8a4b6caa12945f2cf940a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 06:31:32 -0700 Subject: [PATCH 5/5] test(network): require every teardown observation --- ...driver_bidi_session_teardown_assessment.rs | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs b/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs index 708132993..60cc40f61 100644 --- a/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs +++ b/crates/originweave-network/tests/webdriver_bidi_session_teardown_assessment.rs @@ -121,23 +121,43 @@ fn correlated_session_end_ack() -> Result Result<(), Box> { - let acknowledged = correlated_session_end_ack()?; - let assessment = WebDriverBiDiSessionTeardownAssessment::from_protocol_ack( - acknowledged, - WebDriverBiDiSessionTeardownObservations::new(true, false, true), - ); +fn every_missing_operational_observation_keeps_teardown_pending() -> Result<(), Box> { + let incomplete_observations = [ + (false, true, true), + (true, false, true), + (true, true, false), + ]; - assert_eq!(assessment.command_id(), 7); - assert!(assessment.observations().transport_closed_observed()); - assert!(!assessment.observations().browser_process_exited_observed()); - assert!(assessment.observations().task_profile_removed_observed()); - assert!(!assessment.is_operationally_complete()); - assert_eq!( - assessment.disposition(), - WebDriverBiDiSessionTeardownDisposition::OperationalTeardownPending - ); + for (transport_closed, browser_exited, profile_removed) in incomplete_observations { + let acknowledged = correlated_session_end_ack()?; + let assessment = WebDriverBiDiSessionTeardownAssessment::from_protocol_ack( + acknowledged, + WebDriverBiDiSessionTeardownObservations::new( + transport_closed, + browser_exited, + profile_removed, + ), + ); + + assert_eq!(assessment.command_id(), 7); + assert_eq!( + assessment.observations().transport_closed_observed(), + transport_closed + ); + assert_eq!( + assessment.observations().browser_process_exited_observed(), + browser_exited + ); + assert_eq!( + assessment.observations().task_profile_removed_observed(), + profile_removed + ); + assert!(!assessment.is_operationally_complete()); + assert_eq!( + assessment.disposition(), + WebDriverBiDiSessionTeardownDisposition::OperationalTeardownPending + ); + } Ok(()) }