mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:10:11 +00:00
* Add generic host-verified webhook ingress for tools * Stabilize trace E2E test rig and approval behavior * Fix webhook security issues from review feedback - Reject tools without webhook_capability() (was unauthenticated RCE) - Remove secret-in-query-string fallback (leak via logs/referrers) - Require approval for event_emit tool (escalation via routine triggers) - Simplify header_value() (HeaderMap already case-insensitive) - Redact internal errors from webhook HTTP responses - Remove unused hmac_timestamp_tolerance_secs field - Add regression test for tool without webhook capability [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * Harden webhook ingress: require auth mechanism, body limit layer, health check - Reject webhook capabilities that declare no auth mechanism (empty WebhookCapability would previously allow unauthenticated access) - Add DefaultBodyLimit layer to reject oversized payloads before buffering - Health check (GET) now verifies tool has webhook_capability(), not just existence - Add regression tests for all three fixes [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix auto_approve_tools inconsistency between dispatcher and thread_ops dispatcher.rs skips all approval checks (including Always) when auto_approve_tools is true, but thread_ops.rs still required approval for Always tools. This caused deferred tool calls to unexpectedly halt in test rigs and auto-approve configurations. Match dispatcher behavior: short-circuit all approval when auto_approve_tools is enabled. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
222 lines
7.2 KiB
Rust
222 lines
7.2 KiB
Rust
//! Shared assertion helpers for E2E tests.
|
|
//!
|
|
//! Extracted from `e2e_spot_checks.rs` so they can be reused across all E2E
|
|
//! test files. Mirrors the assertion types from `nearai/benchmarks` SpotSuite.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use regex::Regex;
|
|
|
|
use crate::support::trace_llm::TraceExpects;
|
|
|
|
/// Assert the response contains all `needles` (case-insensitive).
|
|
pub fn assert_response_contains(response: &str, needles: &[&str]) {
|
|
let lower = response.to_lowercase();
|
|
for needle in needles {
|
|
assert!(
|
|
lower.contains(&needle.to_lowercase()),
|
|
"response_contains: missing \"{needle}\" in response: {response}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Assert the response matches the given regex `pattern`.
|
|
pub fn assert_response_matches(response: &str, pattern: &str) {
|
|
let re = Regex::new(pattern).expect("invalid regex pattern");
|
|
assert!(
|
|
re.is_match(response),
|
|
"response_matches: /{pattern}/ did not match response: {response}"
|
|
);
|
|
}
|
|
|
|
/// Assert that all `expected` tool names appear in `started`.
|
|
pub fn assert_tools_used(started: &[String], expected: &[&str]) {
|
|
for tool in expected {
|
|
assert!(
|
|
started.iter().any(|s| s == tool),
|
|
"tools_used: \"{tool}\" not called, got: {started:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Assert that none of the `forbidden` tool names appear in `started`.
|
|
pub fn assert_tools_not_used(started: &[String], forbidden: &[&str]) {
|
|
for tool in forbidden {
|
|
assert!(
|
|
!started.iter().any(|s| s == tool),
|
|
"tools_not_used: \"{tool}\" was called, got: {started:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Assert at most `max` tool calls were started.
|
|
pub fn assert_max_tool_calls(started: &[String], max: usize) {
|
|
assert!(
|
|
started.len() <= max,
|
|
"max_tool_calls: expected <= {max}, got {}. Tools: {started:?}",
|
|
started.len()
|
|
);
|
|
}
|
|
|
|
/// Assert ALL completed tools succeeded. Panics listing failed tools.
|
|
pub fn assert_all_tools_succeeded(completed: &[(String, bool)]) {
|
|
let failed: Vec<&str> = completed
|
|
.iter()
|
|
.filter(|(_, success)| !*success)
|
|
.map(|(name, _)| name.as_str())
|
|
.collect();
|
|
assert!(
|
|
failed.is_empty(),
|
|
"Expected all tools to succeed, but these failed: {failed:?}. All: {completed:?}"
|
|
);
|
|
}
|
|
|
|
/// Assert a specific tool completed successfully at least once.
|
|
pub fn assert_tool_succeeded(completed: &[(String, bool)], tool_name: &str) {
|
|
let found = completed
|
|
.iter()
|
|
.any(|(name, success)| name == tool_name && *success);
|
|
assert!(
|
|
found,
|
|
"Expected '{tool_name}' to complete successfully, got: {completed:?}"
|
|
);
|
|
}
|
|
|
|
/// Assert the response does NOT contain any of `forbidden` (case-insensitive).
|
|
pub fn assert_response_not_contains(response: &str, forbidden: &[&str]) {
|
|
let lower = response.to_lowercase();
|
|
for needle in forbidden {
|
|
assert!(
|
|
!lower.contains(&needle.to_lowercase()),
|
|
"response_not_contains: found \"{needle}\" in response: {response}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Assert that `expected` tools appear in `started` in the given order.
|
|
///
|
|
/// The tools need not be consecutive — only relative ordering is checked.
|
|
/// For example, `assert_tool_order(started, &["write_file", "read_file"])`
|
|
/// passes if `write_file` appears before `read_file`, even with other tools
|
|
/// in between.
|
|
pub fn assert_tool_order(started: &[String], expected: &[&str]) {
|
|
let mut search_from = 0;
|
|
for tool in expected {
|
|
let pos = started[search_from..]
|
|
.iter()
|
|
.position(|s| s == tool)
|
|
.map(|p| p + search_from);
|
|
match pos {
|
|
Some(idx) => search_from = idx + 1,
|
|
None => {
|
|
panic!(
|
|
"assert_tool_order: \"{tool}\" not found after position {search_from} \
|
|
in: {started:?}. Expected order: {expected:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Verify all expectations from a `TraceExpects` against actual data.
|
|
///
|
|
/// `label` is used in assertion messages to identify context (e.g. "top-level" or "turn 0").
|
|
/// `responses` are the response content strings, `started` are tool names started,
|
|
/// `completed` are (name, success) pairs, `results` are (name, preview) pairs.
|
|
pub fn verify_expects(
|
|
expects: &TraceExpects,
|
|
responses: &[String],
|
|
started: &[String],
|
|
completed: &[(String, bool)],
|
|
results: &[(String, String)],
|
|
label: &str,
|
|
) {
|
|
if expects.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// min_responses
|
|
if let Some(min) = expects.min_responses {
|
|
assert!(
|
|
responses.len() >= min,
|
|
"[{label}] min_responses: expected >= {min}, got {}",
|
|
responses.len()
|
|
);
|
|
}
|
|
|
|
// response_contains / response_not_contains / response_matches — checked against joined response
|
|
let joined = responses.join("\n");
|
|
|
|
if !expects.response_contains.is_empty() {
|
|
let needles: Vec<&str> = expects
|
|
.response_contains
|
|
.iter()
|
|
.map(|s| s.as_str())
|
|
.collect();
|
|
assert_response_contains(&joined, &needles);
|
|
}
|
|
|
|
if !expects.response_not_contains.is_empty() {
|
|
let forbidden: Vec<&str> = expects
|
|
.response_not_contains
|
|
.iter()
|
|
.map(|s| s.as_str())
|
|
.collect();
|
|
assert_response_not_contains(&joined, &forbidden);
|
|
}
|
|
|
|
if let Some(ref pattern) = expects.response_matches {
|
|
assert_response_matches(&joined, pattern);
|
|
}
|
|
|
|
// tools_used
|
|
if !expects.tools_used.is_empty() {
|
|
let expected: Vec<&str> = expects.tools_used.iter().map(|s| s.as_str()).collect();
|
|
assert_tools_used(started, &expected);
|
|
}
|
|
|
|
// tools_not_used
|
|
if !expects.tools_not_used.is_empty() {
|
|
let forbidden: Vec<&str> = expects.tools_not_used.iter().map(|s| s.as_str()).collect();
|
|
assert_tools_not_used(started, &forbidden);
|
|
}
|
|
|
|
// all_tools_succeeded
|
|
if expects.all_tools_succeeded == Some(true) {
|
|
let failed: Vec<&str> = completed
|
|
.iter()
|
|
.filter(|(_, success)| !*success)
|
|
.map(|(name, _)| name.as_str())
|
|
.collect();
|
|
assert!(
|
|
failed.is_empty(),
|
|
"[{label}] Expected all tools to succeed, failed={failed:?}, completed={completed:?}, results={results:?}"
|
|
);
|
|
}
|
|
|
|
// max_tool_calls
|
|
if let Some(max) = expects.max_tool_calls {
|
|
assert_max_tool_calls(started, max);
|
|
}
|
|
|
|
// tools_order
|
|
if !expects.tools_order.is_empty() {
|
|
let expected: Vec<&str> = expects.tools_order.iter().map(|s| s.as_str()).collect();
|
|
assert_tool_order(started, &expected);
|
|
}
|
|
|
|
// tool_results_contain
|
|
for (tool_name, substring) in &expects.tool_results_contain {
|
|
let found = results.iter().find(|(name, _)| name == tool_name);
|
|
assert!(
|
|
found.is_some(),
|
|
"[{label}] tool_results_contain: no result for tool \"{tool_name}\", got: {results:?}"
|
|
);
|
|
let (_, preview) = found.unwrap();
|
|
assert!(
|
|
preview.to_lowercase().contains(&substring.to_lowercase()),
|
|
"[{label}] tool_results_contain: tool \"{tool_name}\" result does not contain \"{substring}\", got: \"{preview}\""
|
|
);
|
|
}
|
|
}
|