Skip to content

Commit 3057a37

Browse files
committed
fix(workflow): address WebSocket review findings
1 parent 553f5f6 commit 3057a37

2 files changed

Lines changed: 201 additions & 30 deletions

File tree

‎crates/kahea-exec/src/websocket.rs‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,8 @@ fn connect_websocket_resolving_cancellable(
427427
FailureDetails::default(),
428428
);
429429
}
430-
let invocation_deadline = started
431-
.checked_add(options.timeout)
432-
.unwrap_or_else(Instant::now);
433-
let total_deadline = deadline(started, plan.limits.total_timeout_ms).min(invocation_deadline);
430+
let total_deadline =
431+
bounded_total_deadline(started, plan.limits.total_timeout_ms, options.timeout);
434432
let connect_deadline = deadline(started, plan.limits.connect_timeout_ms).min(total_deadline);
435433
let target =
436434
Url::parse(&plan.target).map_err(|error| ExecError::InvalidTarget(error.to_string()))?;
@@ -2321,6 +2319,19 @@ fn deadline(started: Instant, milliseconds: u64) -> Instant {
23212319
.unwrap_or(started)
23222320
}
23232321

2322+
fn bounded_total_deadline(
2323+
started: Instant,
2324+
plan_total_timeout_ms: u64,
2325+
invocation_timeout: Duration,
2326+
) -> Instant {
2327+
let plan_deadline = deadline(started, plan_total_timeout_ms);
2328+
started
2329+
.checked_add(invocation_timeout)
2330+
.map_or(plan_deadline, |invocation_deadline| {
2331+
plan_deadline.min(invocation_deadline)
2332+
})
2333+
}
2334+
23242335
#[derive(Clone, Copy)]
23252336
struct DeadlineState {
23262337
active_deadline: Instant,
@@ -3702,6 +3713,14 @@ mod tests {
37023713
assert!(!expectation_matches_text(&action, "not-json"));
37033714

37043715
let origin = Instant::now();
3716+
assert_eq!(
3717+
bounded_total_deadline(origin, 250, Duration::MAX),
3718+
deadline(origin, 250)
3719+
);
3720+
assert_eq!(
3721+
bounded_total_deadline(origin, 250, Duration::from_millis(50)),
3722+
origin + Duration::from_millis(50)
3723+
);
37053724
assert_eq!(
37063725
select_deadline(
37073726
origin + Duration::from_millis(30),

‎crates/kahea-workflow/src/lib.rs‎

Lines changed: 178 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
861881
fn 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)]
19581977
mod 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

Comments
 (0)