@@ -711,12 +711,13 @@ fn invoke_websocket_workflow_step(
711711 evidence : & EvidenceStore ,
712712 workflow_started : Instant ,
713713) -> Result < WebSocketStepRun , WorkflowError > {
714- let mut websocket_plan = step. websocket_plan . clone ( ) . ok_or_else ( || {
714+ let sealed_websocket_plan = step. websocket_plan . as_ref ( ) . ok_or_else ( || {
715715 WorkflowError :: Invalid ( format ! (
716716 "WebSocket step {:?} has no sealed child plan" ,
717717 step. step_id
718718 ) )
719719 } ) ?;
720+ let mut websocket_plan = sealed_websocket_plan. clone ( ) ;
720721 if !step. websocket_bindings . is_empty ( ) {
721722 let mut source = step. source_document . clone ( ) ;
722723 for binding in & step. websocket_bindings {
@@ -739,14 +740,13 @@ fn invoke_websocket_workflow_step(
739740 step : step. step_id . clone ( ) ,
740741 reason : error. to_string ( ) ,
741742 } ) ?;
742- if websocket_plan. operation != step. operation
743- && source. get ( "operationId" ) . and_then ( Value :: as_str) != Some ( & step. operation )
744- {
745- return Err ( WorkflowError :: StepPlan {
746- step : step. step_id . clone ( ) ,
747- reason : "runtime binding changed the WebSocket operation identity" . into ( ) ,
748- } ) ;
749- }
743+ validate_rebuilt_websocket_plan (
744+ & step. step_id ,
745+ & step. operation ,
746+ & source,
747+ sealed_websocket_plan,
748+ & websocket_plan,
749+ ) ?;
750750 }
751751 store_websocket_plan ( store_root, & websocket_plan) . map_err ( |error| WorkflowError :: StepPlan {
752752 step : step. step_id . clone ( ) ,
@@ -783,16 +783,6 @@ fn invoke_websocket_workflow_step(
783783 }
784784 Ok ( WebSocketConnectResult :: Denied ( denial) ) => {
785785 attempts. push ( serde_json:: to_value ( & denial) ?) ;
786- if let Some ( action) = unconditional_retry ( & step. on_failure )
787- && retries < retry_limit ( action)
788- {
789- retries += 1 ;
790- let delay = retry_delay ( action) ;
791- if !delay. is_zero ( ) {
792- sleep_retry_delay ( workflow_started, options. timeout , delay) ?;
793- }
794- continue ;
795- }
796786 break WebSocketConnectResult :: Denied ( denial) ;
797787 }
798788 Ok ( WebSocketConnectResult :: Connected ( _) ) => {
@@ -858,6 +848,36 @@ fn invoke_websocket_workflow_step(
858848 } )
859849}
860850
851+ fn validate_rebuilt_websocket_plan (
852+ step_id : & str ,
853+ operation : & str ,
854+ source : & Value ,
855+ sealed : & kahea_core:: WebSocketPlan ,
856+ rebuilt : & kahea_core:: WebSocketPlan ,
857+ ) -> Result < ( ) , WorkflowError > {
858+ if rebuilt. operation != operation
859+ && source. get ( "operationId" ) . and_then ( Value :: as_str) != Some ( operation)
860+ {
861+ return Err ( WorkflowError :: StepPlan {
862+ step : step_id. into ( ) ,
863+ reason : "runtime binding changed the WebSocket operation identity" . into ( ) ,
864+ } ) ;
865+ }
866+ if rebuilt. risk != sealed. risk {
867+ return Err ( WorkflowError :: StepPlan {
868+ step : step_id. into ( ) ,
869+ reason : "runtime binding changed the sealed WebSocket risk" . into ( ) ,
870+ } ) ;
871+ }
872+ if rebuilt. required_grants != sealed. required_grants {
873+ return Err ( WorkflowError :: StepPlan {
874+ step : step_id. into ( ) ,
875+ reason : "runtime binding changed the sealed WebSocket required capabilities" . into ( ) ,
876+ } ) ;
877+ }
878+ Ok ( ( ) )
879+ }
880+
861881fn remaining_step_timeout (
862882 workflow_started : Instant ,
863883 workflow_timeout : std:: time:: Duration ,
@@ -951,13 +971,12 @@ fn evaluate_websocket_output(
951971 . map_or ( Value :: Null , |close| Value :: String ( close. reason . clone ( ) ) ) ) ;
952972 }
953973 "$websocket.close.initiator" => {
954- return observation
974+ return Ok ( observation
955975 . close
956976 . as_ref ( )
957977 . map ( serde_json:: to_value)
958978 . transpose ( ) ?
959- . map ( |close| close[ "initiator" ] . clone ( ) )
960- . ok_or_else ( || WorkflowError :: Invalid ( "WebSocket close data is absent" . into ( ) ) ) ;
979+ . map_or ( Value :: Null , |close| close[ "initiator" ] . clone ( ) ) ) ;
961980 }
962981 "$websocket.evidence.transcript" => {
963982 return Ok ( observation
@@ -1957,10 +1976,10 @@ fn value_array(value: &Value, key: &str) -> Vec<Value> {
19571976#[ cfg( test) ]
19581977mod tests {
19591978 use super :: * ;
1960- use std:: io:: { Read , Write } ;
1961- use std:: net:: TcpListener ;
1979+ use std:: io:: { self , Read , Write } ;
1980+ use std:: net:: { TcpListener , TcpStream } ;
19621981 use std:: thread;
1963- use std:: time:: { SystemTime , UNIX_EPOCH } ;
1982+ use std:: time:: { Duration , SystemTime , UNIX_EPOCH } ;
19641983 use tungstenite:: Message ;
19651984
19661985 fn temporary_root ( label : & str ) -> PathBuf {
@@ -1995,7 +2014,7 @@ mod tests {
19952014 let listener = TcpListener :: bind ( "127.0.0.1:0" ) . unwrap ( ) ;
19962015 let port = listener. local_addr ( ) . unwrap ( ) . port ( ) ;
19972016 let worker = thread:: spawn ( move || {
1998- let ( stream, _ ) = listener . accept ( ) . unwrap ( ) ;
2017+ let stream = accept_test_connection ( & listener ) ;
19992018 let mut socket = tungstenite:: accept ( stream) . unwrap ( ) ;
20002019 let message = socket. read ( ) . unwrap ( ) ;
20012020 assert_eq ! ( message. to_text( ) . unwrap( ) , expected) ;
@@ -2007,6 +2026,30 @@ mod tests {
20072026 ( port, worker)
20082027 }
20092028
2029+ fn accept_test_connection ( listener : & TcpListener ) -> TcpStream {
2030+ const TEST_IO_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
2031+ listener. set_nonblocking ( true ) . unwrap ( ) ;
2032+ let deadline = Instant :: now ( ) + TEST_IO_TIMEOUT ;
2033+ loop {
2034+ match listener. accept ( ) {
2035+ Ok ( ( stream, _) ) => {
2036+ stream. set_nonblocking ( false ) . unwrap ( ) ;
2037+ stream. set_read_timeout ( Some ( TEST_IO_TIMEOUT ) ) . unwrap ( ) ;
2038+ stream. set_write_timeout ( Some ( TEST_IO_TIMEOUT ) ) . unwrap ( ) ;
2039+ return stream;
2040+ }
2041+ Err ( error) if error. kind ( ) == io:: ErrorKind :: WouldBlock => {
2042+ assert ! (
2043+ Instant :: now( ) < deadline,
2044+ "test WebSocket peer did not connect within five seconds"
2045+ ) ;
2046+ thread:: sleep ( Duration :: from_millis ( 5 ) ) ;
2047+ }
2048+ Err ( error) => panic ! ( "test WebSocket accept failed: {error}" ) ,
2049+ }
2050+ }
2051+ }
2052+
20102053 #[ test]
20112054 fn two_step_workflow_resolves_output_into_a_sealed_subplan ( ) {
20122055 let listener = TcpListener :: bind ( "127.0.0.1:0" ) . unwrap ( ) ;
@@ -2219,8 +2262,68 @@ paths:
22192262 )
22202263 ) ;
22212264
2265+ let sealed_websocket_plan = first. steps [ 1 ] . websocket_plan . as_ref ( ) . unwrap ( ) ;
2266+ let mut changed_risk = sealed_websocket_plan. clone ( ) ;
2267+ changed_risk. risk = RiskClass :: Destructive ;
2268+ assert ! (
2269+ validate_rebuilt_websocket_plan(
2270+ & first. steps[ 1 ] . step_id,
2271+ & first. steps[ 1 ] . operation,
2272+ & first. steps[ 1 ] . source_document,
2273+ sealed_websocket_plan,
2274+ & changed_risk,
2275+ )
2276+ . unwrap_err( )
2277+ . to_string( )
2278+ . contains( "sealed WebSocket risk" )
2279+ ) ;
2280+ let mut changed_grants = sealed_websocket_plan. clone ( ) ;
2281+ changed_grants
2282+ . required_grants
2283+ . push ( "unexpected:grant" . into ( ) ) ;
2284+ assert ! (
2285+ validate_rebuilt_websocket_plan(
2286+ & first. steps[ 1 ] . step_id,
2287+ & first. steps[ 1 ] . operation,
2288+ & first. steps[ 1 ] . source_document,
2289+ sealed_websocket_plan,
2290+ & changed_grants,
2291+ )
2292+ . unwrap_err( )
2293+ . to_string( )
2294+ . contains( "required capabilities" )
2295+ ) ;
2296+
22222297 let root = temporary_root ( "denial" ) ;
22232298 let evidence = EvidenceStore :: open ( root. join ( "store" ) ) . unwrap ( ) ;
2299+ let denied_step_options = InvokeOptions {
2300+ grants : first
2301+ . required_grants
2302+ . iter ( )
2303+ . filter ( |grant| grant. as_str ( ) != "websocket:connect" )
2304+ . cloned ( )
2305+ . collect ( ) ,
2306+ timeout : Duration :: from_millis ( 100 ) ,
2307+ ..InvokeOptions :: default ( )
2308+ } ;
2309+ let step_outputs = BTreeMap :: from ( [ (
2310+ "create" . into ( ) ,
2311+ BTreeMap :: from ( [ ( "invoice_id" . into ( ) , Value :: String ( "invoice-1" . into ( ) ) ) ] ) ,
2312+ ) ] ) ;
2313+ let denied_step = invoke_websocket_workflow_step (
2314+ & first. steps [ 1 ] ,
2315+ & first. input ,
2316+ & step_outputs,
2317+ & denied_step_options,
2318+ & configuration,
2319+ & root,
2320+ & evidence,
2321+ Instant :: now ( ) ,
2322+ )
2323+ . unwrap ( ) ;
2324+ assert_eq ! ( denied_step. exit, 4 ) ;
2325+ assert_eq ! ( denied_step. attempts. len( ) , 1 ) ;
2326+
22242327 let options = InvokeOptions {
22252328 grants : first
22262329 . required_grants
@@ -2303,6 +2406,55 @@ paths:
23032406 fs:: remove_dir_all ( root) . unwrap ( ) ;
23042407 }
23052408
2409+ #[ test]
2410+ fn absent_websocket_close_fields_are_null ( ) {
2411+ let root = temporary_root ( "absent-close" ) ;
2412+ let evidence = EvidenceStore :: open ( root. join ( "store" ) ) . unwrap ( ) ;
2413+ let observation = WebSocketObservation {
2414+ protocol : PROTOCOL . into ( ) ,
2415+ kind : "websocket-observation" . into ( ) ,
2416+ version : VERSION . into ( ) ,
2417+ config_fingerprint : String :: new ( ) ,
2418+ policy_fingerprint : String :: new ( ) ,
2419+ source_fingerprints : Vec :: new ( ) ,
2420+ tool_version : env ! ( "CARGO_PKG_VERSION" ) . into ( ) ,
2421+ plan : String :: new ( ) ,
2422+ outcome : Outcome :: Passed ,
2423+ handshake_status : Some ( 101 ) ,
2424+ negotiated_subprotocol : None ,
2425+ handshake_latency_ms : None ,
2426+ session_duration_ms : None ,
2427+ transcript : None ,
2428+ handshake : None ,
2429+ trace : None ,
2430+ close : None ,
2431+ terminal_cause : kahea_core:: WebSocketTerminalCause :: Completed ,
2432+ counters : kahea_core:: WebSocketCounters :: default ( ) ,
2433+ resolved_origin : None ,
2434+ http_version : None ,
2435+ secret_refs : Vec :: new ( ) ,
2436+ runtime : String :: new ( ) ,
2437+ exit : 0 ,
2438+ } ;
2439+ for expression in [
2440+ "$websocket.close.code" ,
2441+ "$websocket.close.reason" ,
2442+ "$websocket.close.initiator" ,
2443+ ] {
2444+ assert_eq ! (
2445+ evaluate_websocket_output(
2446+ & Value :: String ( expression. into( ) ) ,
2447+ & observation,
2448+ & evidence
2449+ )
2450+ . unwrap( ) ,
2451+ Value :: Null
2452+ ) ;
2453+ }
2454+ drop ( evidence) ;
2455+ fs:: remove_dir_all ( root) . unwrap ( ) ;
2456+ }
2457+
23062458 #[ test]
23072459 fn http_output_binds_into_websocket_and_extracts_bounded_message_evidence ( ) {
23082460 let root = temporary_root ( "http-ws" ) ;
0 commit comments