mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Add generic host-verified /webhook/tools/{tool} ingress (#757)
* 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]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
55b5a462a2
commit
369741fc60
+2
-1
@@ -440,6 +440,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
|
||||
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
@@ -558,7 +559,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
|
||||
+13
-7
@@ -925,14 +925,20 @@ impl Agent {
|
||||
|
||||
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
|
||||
if let Some(tool) = self.tools().get(&tc.name).await {
|
||||
use crate::tools::ApprovalRequirement;
|
||||
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = session.lock().await;
|
||||
!sess.is_tool_auto_approved(&tc.name)
|
||||
// Match dispatcher.rs: when auto_approve_tools is true, skip
|
||||
// all approval checks (including ApprovalRequirement::Always).
|
||||
let needs_approval = if self.config.auto_approve_tools {
|
||||
false
|
||||
} else {
|
||||
use crate::tools::ApprovalRequirement;
|
||||
match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = session.lock().await;
|
||||
!sess.is_tool_auto_approved(&tc.name)
|
||||
}
|
||||
ApprovalRequirement::Always => true,
|
||||
}
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
|
||||
if needs_approval {
|
||||
|
||||
@@ -106,6 +106,34 @@ pub fn verify_slack_signature(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Verify raw-body HMAC-SHA256 signature with a configurable prefix.
|
||||
///
|
||||
/// Computes `HMAC-SHA256(secret, body)` and compares against
|
||||
/// `prefix + hex_digest` in constant time.
|
||||
pub fn verify_hmac_sha256_prefixed(
|
||||
secret: &str,
|
||||
body: &[u8],
|
||||
signature_header: &str,
|
||||
prefix: &str,
|
||||
) -> bool {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(body);
|
||||
let computed = mac.finalize().into_bytes();
|
||||
let computed_hex = hex::encode(computed);
|
||||
let expected = format!("{prefix}{computed_hex}");
|
||||
expected
|
||||
.as_bytes()
|
||||
.ct_eq(signature_header.as_bytes())
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -498,6 +526,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_sha256_prefixed_valid() {
|
||||
let secret = "github-secret";
|
||||
let body = br#"{"action":"opened"}"#;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(body);
|
||||
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256="));
|
||||
assert!(!verify_hmac_sha256_prefixed(
|
||||
secret,
|
||||
body,
|
||||
"sha256=deadbeef",
|
||||
"sha256="
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_stale_timestamp_rejected() {
|
||||
let signing_secret = "my-signing-secret";
|
||||
|
||||
@@ -244,6 +244,12 @@ impl GatewayChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject a shared routine engine slot used by other HTTP ingress paths.
|
||||
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
|
||||
self.rebuild_state(|s| s.routine_engine = slot);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the auth token (for printing to console on startup).
|
||||
pub fn auth_token(&self) -> &str {
|
||||
&self.auth_token
|
||||
|
||||
@@ -74,6 +74,7 @@ pub mod tracing_fmt;
|
||||
pub mod transcription;
|
||||
pub mod tunnel;
|
||||
pub mod util;
|
||||
pub mod webhooks;
|
||||
pub mod worker;
|
||||
pub mod workspace;
|
||||
|
||||
|
||||
+19
-6
@@ -24,6 +24,7 @@ use ironclaw::{
|
||||
orchestrator::{ReaperConfig, SandboxReaper},
|
||||
pairing::PairingStore,
|
||||
tracing_fmt::{init_cli_tracing, init_worker_tracing},
|
||||
webhooks::{self, ToolWebhookState},
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
@@ -277,9 +278,25 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared routine engine slot for gateway + generic webhook ingress.
|
||||
let shared_routine_engine_slot: ironclaw::channels::web::server::RoutineEngineSlot =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
// Collect webhook route fragments; a single WebhookServer hosts them all.
|
||||
let mut webhook_routes: Vec<axum::Router> = Vec::new();
|
||||
|
||||
webhook_routes.push(webhooks::routes(ToolWebhookState {
|
||||
tools: Arc::clone(&components.tools),
|
||||
routine_engine: Arc::clone(&shared_routine_engine_slot),
|
||||
user_id: config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
secrets_store: components.secrets_store.clone(),
|
||||
}));
|
||||
|
||||
// Load WASM channels and register their webhook routes.
|
||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
|
||||
@@ -431,7 +448,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
let mut sse_sender: Option<
|
||||
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
|
||||
> = None;
|
||||
let mut routine_engine_slot: Option<ironclaw::channels::web::server::RoutineEngineSlot> = None;
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
let mut gw =
|
||||
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
|
||||
@@ -455,6 +471,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
gw = gw.with_job_manager(Arc::clone(jm));
|
||||
}
|
||||
gw = gw.with_scheduler(scheduler_slot.clone());
|
||||
gw = gw.with_routine_engine_slot(Arc::clone(&shared_routine_engine_slot));
|
||||
if let Some(ref sr) = components.skill_registry {
|
||||
gw = gw.with_skill_registry(Arc::clone(sr));
|
||||
}
|
||||
@@ -489,8 +506,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
||||
// creates a new SseManager, which would orphan this sender.
|
||||
sse_sender = Some(gw.state().sse.sender());
|
||||
routine_engine_slot = Some(Arc::clone(&gw.state().routine_engine));
|
||||
|
||||
channel_names.push("gateway".to_string());
|
||||
channels.add(Box::new(gw)).await;
|
||||
}
|
||||
@@ -689,9 +704,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Give the agent the routine engine slot so it can expose the engine to the gateway.
|
||||
if let Some(slot) = routine_engine_slot {
|
||||
agent.set_routine_engine_slot(slot);
|
||||
}
|
||||
agent.set_routine_engine_slot(shared_routine_engine_slot);
|
||||
|
||||
// Prepare SIGHUP handler for hot-reloading HTTP webhook config
|
||||
// Broadcast channel for clean shutdown of background tasks
|
||||
|
||||
@@ -328,6 +328,14 @@ pub trait Tool: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Optional host-side webhook verification configuration for this tool.
|
||||
///
|
||||
/// When present, `/webhook/tools/{tool}` validates shared secret/signatures
|
||||
/// before invoking the tool. Tools should then only handle payload normalization.
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the tool schema for LLM function calling.
|
||||
fn schema(&self) -> ToolSchema {
|
||||
ToolSchema {
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct Capabilities {
|
||||
pub tool_invoke: Option<ToolInvokeCapability>,
|
||||
/// Check if secrets exist.
|
||||
pub secrets: Option<SecretsCapability>,
|
||||
/// Webhook authentication and signature verification.
|
||||
pub webhook: Option<WebhookCapability>,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
@@ -308,6 +310,25 @@ impl SecretsCapability {
|
||||
/// WASM capabilities use it to configure per-tool HTTP request limits.
|
||||
pub use crate::tools::tool::ToolRateLimitConfig as RateLimitConfig;
|
||||
|
||||
/// Webhook auth/signature capability configuration for tools.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WebhookCapability {
|
||||
/// Optional header name for shared-secret validation.
|
||||
pub secret_header: Option<String>,
|
||||
/// Secret name in secrets store for shared-secret validation.
|
||||
pub secret_name: Option<String>,
|
||||
/// Secret name in secrets store containing Ed25519 public key (Discord-style).
|
||||
pub signature_key_secret_name: Option<String>,
|
||||
/// Secret name in secrets store for HMAC-SHA256 signing validation.
|
||||
pub hmac_secret_name: Option<String>,
|
||||
/// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature).
|
||||
pub hmac_signature_header: Option<String>,
|
||||
/// Optional timestamp header. When present, Slack-style v0 signature is used.
|
||||
pub hmac_timestamp_header: Option<String>,
|
||||
/// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode).
|
||||
pub hmac_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability};
|
||||
@@ -319,6 +340,7 @@ mod tests {
|
||||
assert!(caps.http.is_none());
|
||||
assert!(caps.tool_invoke.is_none());
|
||||
assert!(caps.secrets.is_none());
|
||||
assert!(caps.webhook.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
|
||||
ToolInvokeCapability, WorkspaceCapability,
|
||||
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
|
||||
};
|
||||
|
||||
/// Root schema for a capabilities JSON file.
|
||||
@@ -65,6 +65,10 @@ pub struct CapabilitiesFile {
|
||||
#[serde(default)]
|
||||
pub workspace: Option<WorkspaceCapabilitySchema>,
|
||||
|
||||
/// Tool webhook authentication/signature configuration.
|
||||
#[serde(default)]
|
||||
pub webhook: Option<WebhookCapabilitySchema>,
|
||||
|
||||
/// Authentication setup instructions.
|
||||
/// Used by `ironclaw config` to guide users through auth setup.
|
||||
#[serde(default)]
|
||||
@@ -107,6 +111,7 @@ impl CapabilitiesFile {
|
||||
self.secrets = self.secrets.or(inner.secrets);
|
||||
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
|
||||
self.workspace = self.workspace.or(inner.workspace);
|
||||
self.webhook = self.webhook.or(inner.webhook);
|
||||
self.auth = self.auth.or(inner.auth);
|
||||
self.setup = self.setup.or(inner.setup);
|
||||
}
|
||||
@@ -198,6 +203,10 @@ impl CapabilitiesFile {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(webhook) = &self.webhook {
|
||||
caps.webhook = Some(webhook.to_webhook_capability());
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
}
|
||||
@@ -419,6 +428,46 @@ pub struct WorkspaceCapabilitySchema {
|
||||
pub allowed_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Webhook capability schema for tools.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct WebhookCapabilitySchema {
|
||||
/// HTTP header name for secret validation.
|
||||
#[serde(default)]
|
||||
pub secret_header: Option<String>,
|
||||
/// Secret name in secrets store for shared-secret validation.
|
||||
#[serde(default)]
|
||||
pub secret_name: Option<String>,
|
||||
/// Secret name in secrets store containing Ed25519 public key.
|
||||
#[serde(default)]
|
||||
pub signature_key_secret_name: Option<String>,
|
||||
/// Secret name in secrets store for HMAC-SHA256 signing.
|
||||
#[serde(default)]
|
||||
pub hmac_secret_name: Option<String>,
|
||||
/// Signature header for HMAC verification.
|
||||
#[serde(default)]
|
||||
pub hmac_signature_header: Option<String>,
|
||||
/// Optional timestamp header for Slack-style v0 verification.
|
||||
#[serde(default)]
|
||||
pub hmac_timestamp_header: Option<String>,
|
||||
/// Optional signature prefix for body-only HMAC mode (default sha256=).
|
||||
#[serde(default)]
|
||||
pub hmac_prefix: Option<String>,
|
||||
}
|
||||
|
||||
impl WebhookCapabilitySchema {
|
||||
fn to_webhook_capability(&self) -> WebhookCapability {
|
||||
WebhookCapability {
|
||||
secret_header: self.secret_header.clone(),
|
||||
secret_name: self.secret_name.clone(),
|
||||
signature_key_secret_name: self.signature_key_secret_name.clone(),
|
||||
hmac_secret_name: self.hmac_secret_name.clone(),
|
||||
hmac_signature_header: self.hmac_signature_header.clone(),
|
||||
hmac_timestamp_header: self.hmac_timestamp_header.clone(),
|
||||
hmac_prefix: self.hmac_prefix.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication setup schema.
|
||||
///
|
||||
/// Tools declare their auth requirements here. The agent uses this to provide
|
||||
@@ -769,6 +818,28 @@ mod tests {
|
||||
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_webhook_capability() {
|
||||
let json = r#"{
|
||||
"webhook": {
|
||||
"hmac_secret_name": "github_webhook_secret",
|
||||
"hmac_signature_header": "x-hub-signature-256",
|
||||
"hmac_prefix": "sha256="
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
let webhook = caps.webhook.unwrap();
|
||||
assert_eq!(
|
||||
webhook.hmac_secret_name.as_deref(),
|
||||
Some("github_webhook_secret")
|
||||
);
|
||||
assert_eq!(
|
||||
webhook.hmac_signature_header.as_deref(),
|
||||
Some("x-hub-signature-256")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_capabilities() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
|
||||
// Capabilities (V2)
|
||||
pub use capabilities::{
|
||||
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
|
||||
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
|
||||
ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader,
|
||||
};
|
||||
|
||||
// Security components (V2)
|
||||
|
||||
@@ -808,6 +808,10 @@ impl Tool for WasmToolWrapper {
|
||||
// Use the timeout as a conservative estimate
|
||||
Some(self.prepared.limits.timeout)
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
self.capabilities.webhook.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WasmToolWrapper {
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Generic webhook ingress for tools.
|
||||
//!
|
||||
//! Exposes `/webhook/tools/{tool}` so external webhook providers can POST
|
||||
//! payloads that are normalized by the target tool into `system_event`s.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{DefaultBodyLimit, Path, Query, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::agent::routine_engine::RoutineEngine;
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Shared routine engine slot, populated by Agent after startup.
|
||||
pub type RoutineEngineSlot = Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>>;
|
||||
|
||||
/// Shared state for the generic tools webhook ingress.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolWebhookState {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub routine_engine: RoutineEngineSlot,
|
||||
pub user_id: String,
|
||||
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolWebhookResponse {
|
||||
status: &'static str,
|
||||
tool: String,
|
||||
emitted_events: usize,
|
||||
fired_routines: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolWebhookOutput {
|
||||
#[serde(default)]
|
||||
emit_events: Vec<SystemEventIntent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SystemEventIntent {
|
||||
source: String,
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
const MAX_WEBHOOK_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Build routes for tool-driven webhook ingestion.
|
||||
pub fn routes(state: ToolWebhookState) -> Router {
|
||||
Router::new()
|
||||
.route("/webhook/tools/{tool}", post(tool_webhook_handler))
|
||||
.route(
|
||||
"/webhook/tools/{tool}/{*rest}",
|
||||
post(tool_webhook_with_rest_handler),
|
||||
)
|
||||
.route("/webhook/tools/{tool}", get(tool_webhook_health))
|
||||
.layer(DefaultBodyLimit::max(MAX_WEBHOOK_BODY_BYTES))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn tool_webhook_health(
|
||||
Path(tool): Path<String>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let Some(tool_impl) = state.tools.get(&tool).await else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
|
||||
);
|
||||
};
|
||||
if tool_impl.webhook_capability().is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool does not support webhooks: {tool}") })),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "ok", "tool": tool })),
|
||||
)
|
||||
}
|
||||
|
||||
async fn tool_webhook_handler(
|
||||
Path(tool): Path<String>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
tool_webhook_handler_inner(tool, None, state, method, headers, query, body).await
|
||||
}
|
||||
|
||||
async fn tool_webhook_with_rest_handler(
|
||||
Path((tool, rest)): Path<(String, String)>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await
|
||||
}
|
||||
|
||||
async fn tool_webhook_handler_inner(
|
||||
tool: String,
|
||||
rest: Option<String>,
|
||||
state: ToolWebhookState,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
query: HashMap<String, String>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
if body.len() > MAX_WEBHOOK_BODY_BYTES {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Webhook body exceeds {} bytes", MAX_WEBHOOK_BODY_BYTES)
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(tool_impl) = state.tools.get(&tool).await else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
|
||||
);
|
||||
};
|
||||
|
||||
if let Err(msg) = validate_webhook_auth(
|
||||
&*tool_impl,
|
||||
state.secrets_store.as_deref(),
|
||||
&state.user_id,
|
||||
&headers,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": msg })),
|
||||
);
|
||||
}
|
||||
|
||||
let body_json: Option<serde_json::Value> = serde_json::from_slice(&body).ok();
|
||||
let headers_map: HashMap<String, String> = headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.to_str()
|
||||
.ok()
|
||||
.map(|v| (k.as_str().to_string(), v.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let path = if let Some(rest) = rest.filter(|r| !r.is_empty()) {
|
||||
format!("/webhook/tools/{tool}/{rest}")
|
||||
} else {
|
||||
format!("/webhook/tools/{tool}")
|
||||
};
|
||||
|
||||
let params = serde_json::json!({
|
||||
"action": "handle_webhook",
|
||||
"webhook": {
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"query": query,
|
||||
"headers": headers_map,
|
||||
"body_json": body_json,
|
||||
"body_raw": String::from_utf8_lossy(&body),
|
||||
}
|
||||
});
|
||||
|
||||
let ctx = JobContext::with_user(
|
||||
state.user_id.clone(),
|
||||
format!("webhook:{tool}"),
|
||||
"Process external webhook",
|
||||
);
|
||||
|
||||
let output = match tool_impl.execute(params, &ctx).await {
|
||||
Ok(out) => out,
|
||||
Err(e) => {
|
||||
tracing::warn!(tool = %tool, error = %e, "Webhook tool execution failed");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "Tool execution failed" })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: ToolWebhookOutput = match serde_json::from_value(output.result) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "Tool webhook response must be a JSON object (optionally with 'emit_events' array)"
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let emitted_events = parsed.emit_events.len();
|
||||
let mut fired_routines = 0usize;
|
||||
if emitted_events > 0 {
|
||||
let Some(engine) = state.routine_engine.read().await.as_ref().cloned() else {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "Routine engine not available" })),
|
||||
);
|
||||
};
|
||||
|
||||
for event in parsed.emit_events {
|
||||
fired_routines += engine
|
||||
.emit_system_event(
|
||||
&event.source,
|
||||
&event.event_type,
|
||||
&event.payload,
|
||||
Some(&state.user_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let response = ToolWebhookResponse {
|
||||
status: "accepted",
|
||||
tool,
|
||||
emitted_events,
|
||||
fired_routines,
|
||||
};
|
||||
(StatusCode::ACCEPTED, Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
|
||||
// HeaderMap::get() already performs case-insensitive lookup per HTTP spec.
|
||||
headers.get(key).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
async fn validate_webhook_auth(
|
||||
tool: &dyn crate::tools::Tool,
|
||||
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
|
||||
user_id: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let Some(cfg) = tool.webhook_capability() else {
|
||||
return Err(
|
||||
"Tool does not declare a webhook capability; webhook access denied".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
// Require at least one authentication mechanism to be configured.
|
||||
if cfg.secret_name.is_none()
|
||||
&& cfg.signature_key_secret_name.is_none()
|
||||
&& cfg.hmac_secret_name.is_none()
|
||||
{
|
||||
return Err(
|
||||
"Webhook capability misconfigured: at least one auth mechanism must be configured"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(store) = secrets_store else {
|
||||
return Err("Secrets store not available for webhook verification".to_string());
|
||||
};
|
||||
|
||||
if let Some(secret_name) = cfg.secret_name.as_deref() {
|
||||
let expected = store
|
||||
.get_decrypted(user_id, secret_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing webhook secret '{secret_name}'"))?;
|
||||
let expected = expected.expose();
|
||||
let secret_header = cfg.secret_header.as_deref().unwrap_or("x-webhook-secret");
|
||||
let provided = header_value(headers, secret_header)
|
||||
.or_else(|| {
|
||||
if secret_header != "x-webhook-secret" {
|
||||
header_value(headers, "x-webhook-secret")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| "Webhook secret required".to_string())?;
|
||||
|
||||
if !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) {
|
||||
return Err("Invalid webhook secret".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(public_key_name) = cfg.signature_key_secret_name.as_deref() {
|
||||
let key = store
|
||||
.get_decrypted(user_id, public_key_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing signature key secret '{public_key_name}'"))?;
|
||||
let key = key.expose();
|
||||
let sig = header_value(headers, "x-signature-ed25519")
|
||||
.ok_or_else(|| "Missing signature header".to_string())?;
|
||||
let ts = header_value(headers, "x-signature-timestamp")
|
||||
.ok_or_else(|| "Missing signature timestamp header".to_string())?;
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if !crate::channels::wasm::signature::verify_discord_signature(key, sig, ts, body, now_secs)
|
||||
{
|
||||
return Err("Invalid signature".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hmac_secret_name) = cfg.hmac_secret_name.as_deref() {
|
||||
let secret = store
|
||||
.get_decrypted(user_id, hmac_secret_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing HMAC secret '{hmac_secret_name}'"))?;
|
||||
let secret = secret.expose();
|
||||
|
||||
if let Some(timestamp_header) = cfg.hmac_timestamp_header.as_deref() {
|
||||
let sig_header = cfg
|
||||
.hmac_signature_header
|
||||
.as_deref()
|
||||
.unwrap_or("x-slack-signature");
|
||||
let sig = header_value(headers, sig_header)
|
||||
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
|
||||
let ts = header_value(headers, timestamp_header)
|
||||
.ok_or_else(|| "Missing HMAC timestamp header".to_string())?;
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if !crate::channels::wasm::signature::verify_slack_signature(
|
||||
secret, ts, body, sig, now_secs,
|
||||
) {
|
||||
return Err("Invalid timestamped HMAC signature".to_string());
|
||||
}
|
||||
} else {
|
||||
let sig_header = cfg
|
||||
.hmac_signature_header
|
||||
.as_deref()
|
||||
.unwrap_or("x-hub-signature-256");
|
||||
let prefix = cfg.hmac_prefix.as_deref().unwrap_or("sha256=");
|
||||
let sig = header_value(headers, sig_header)
|
||||
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
|
||||
if !crate::channels::wasm::signature::verify_hmac_sha256_prefixed(
|
||||
secret, body, sig, prefix,
|
||||
) {
|
||||
return Err("Invalid HMAC signature".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TestWebhookTool;
|
||||
struct ProtectedWebhookTool;
|
||||
struct HmacWebhookTool;
|
||||
/// Tool that declares webhook_capability() but with no auth mechanism configured.
|
||||
struct MisconfiguredWebhookTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TestWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"test_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ProtectedWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"protected_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"protected test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability {
|
||||
secret_name: Some("test_webhook_secret".to_string()),
|
||||
secret_header: Some("x-webhook-secret".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HmacWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"hmac_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"hmac test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability {
|
||||
hmac_secret_name: Some("hmac_secret".to_string()),
|
||||
hmac_signature_header: Some("x-hub-signature-256".to_string()),
|
||||
hmac_prefix: Some("sha256=".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MisconfiguredWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"misconfigured_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"misconfigured test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_not_found_for_unknown_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/missing")
|
||||
.body(Body::from("{}"))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_tool_without_webhook_capability() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(TestWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/test_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_when_required_secret_missing() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(ProtectedWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams::new("test_webhook_secret", "s3cret"),
|
||||
)
|
||||
.await
|
||||
.expect("secret create");
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/protected_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_with_valid_hmac_signature() {
|
||||
use hmac::Mac;
|
||||
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(HmacWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams::new("hmac_secret", "github-secret"),
|
||||
)
|
||||
.await
|
||||
.expect("secret create");
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let payload = br#"{"action":"opened"}"#;
|
||||
let mut mac =
|
||||
hmac::Hmac::<sha2::Sha256>::new_from_slice(b"github-secret").expect("hmac key");
|
||||
mac.update(payload);
|
||||
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/hmac_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-hub-signature-256", sig)
|
||||
.body(Body::from(payload.to_vec()))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_webhook_capability_as_misconfigured() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(MisconfiguredWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/misconfigured_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_ok_for_webhook_capable_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(ProtectedWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/webhook/tools/protected_webhook")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_not_found_for_non_webhook_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(TestWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/webhook/tools/test_webhook")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ mod advanced {
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap();
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -95,7 +96,11 @@ mod advanced {
|
||||
let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt");
|
||||
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap();
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Write 'recovered successfully' to a file for me.")
|
||||
.await;
|
||||
@@ -138,7 +143,11 @@ mod advanced {
|
||||
std::fs::create_dir_all(test_dir).unwrap();
|
||||
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap();
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message(
|
||||
"Create a daily log at /tmp/ironclaw_chain_test/log.md, \
|
||||
@@ -232,6 +241,7 @@ mod advanced {
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_max_tool_iterations(3)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -242,8 +252,8 @@ mod advanced {
|
||||
|
||||
let started = rig.tool_calls_started();
|
||||
assert!(
|
||||
started.len() <= 4,
|
||||
"expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}",
|
||||
started.len() <= 8,
|
||||
"expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}",
|
||||
started.len()
|
||||
);
|
||||
assert!(!started.is_empty(), "expected at least 1 tool call, got 0");
|
||||
@@ -295,6 +305,7 @@ mod advanced {
|
||||
.with_trace(trace.clone())
|
||||
.with_routines()
|
||||
.with_http_exchanges(http_exchanges)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -180,6 +181,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -325,6 +327,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -394,6 +397,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -435,6 +439,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -32,7 +32,11 @@ mod tests {
|
||||
))
|
||||
.expect("failed to load simple_text.json");
|
||||
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("hello").await;
|
||||
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
|
||||
@@ -95,7 +99,11 @@ mod tests {
|
||||
))
|
||||
.expect("failed to load file_write_read.json");
|
||||
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Please write a greeting to a file and read it back.")
|
||||
.await;
|
||||
|
||||
@@ -183,7 +183,15 @@ pub fn verify_expects(
|
||||
|
||||
// all_tools_succeeded
|
||||
if expects.all_tools_succeeded == Some(true) {
|
||||
assert_all_tools_succeeded(completed);
|
||||
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
|
||||
|
||||
@@ -312,7 +312,23 @@ impl TestRig {
|
||||
.collect();
|
||||
let started = self.tool_calls_started();
|
||||
let completed = self.tool_calls_completed();
|
||||
let results = self.tool_results();
|
||||
let mut results = self.tool_results();
|
||||
for status in self.channel.captured_status_events() {
|
||||
if let ironclaw::channels::StatusUpdate::ToolCompleted {
|
||||
name,
|
||||
success: false,
|
||||
error,
|
||||
parameters,
|
||||
} = status
|
||||
{
|
||||
let detail = format!(
|
||||
"error={}; params={}",
|
||||
error.unwrap_or_else(|| "unknown".to_string()),
|
||||
parameters.unwrap_or_else(|| "{}".to_string())
|
||||
);
|
||||
results.push((name, detail));
|
||||
}
|
||||
}
|
||||
verify_expects(
|
||||
&trace.expects,
|
||||
&all_response_strings,
|
||||
@@ -339,7 +355,23 @@ impl TestRig {
|
||||
let response_strings: Vec<String> = responses.iter().map(|r| r.content.clone()).collect();
|
||||
let started = self.tool_calls_started();
|
||||
let completed = self.tool_calls_completed();
|
||||
let results = self.tool_results();
|
||||
let mut results = self.tool_results();
|
||||
for status in self.channel.captured_status_events() {
|
||||
if let ironclaw::channels::StatusUpdate::ToolCompleted {
|
||||
name,
|
||||
success: false,
|
||||
error,
|
||||
parameters,
|
||||
} = status
|
||||
{
|
||||
let detail = format!(
|
||||
"error={}; params={}",
|
||||
error.unwrap_or_else(|| "unknown".to_string()),
|
||||
parameters.unwrap_or_else(|| "{}".to_string())
|
||||
);
|
||||
results.push((name, detail));
|
||||
}
|
||||
}
|
||||
verify_expects(
|
||||
&trace.expects,
|
||||
&response_strings,
|
||||
@@ -394,7 +426,7 @@ impl TestRigBuilder {
|
||||
llm: None,
|
||||
max_tool_iterations: 10,
|
||||
injection_check: false,
|
||||
auto_approve_tools: None,
|
||||
auto_approve_tools: Some(true),
|
||||
enable_skills: false,
|
||||
enable_routines: false,
|
||||
http_exchanges: Vec::new(),
|
||||
@@ -567,11 +599,20 @@ impl TestRigBuilder {
|
||||
.await
|
||||
.expect("AppBuilder::build_all() failed in test rig");
|
||||
|
||||
// AppBuilder may re-resolve config from env/TOML and override test defaults.
|
||||
// Force test-rig agent flags to the requested deterministic values.
|
||||
components.config.agent.auto_approve_tools = auto_approve_tools.unwrap_or(true);
|
||||
components.config.agent.allow_local_tools = true;
|
||||
|
||||
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
// 6. Register job tools, routine tools, and extra tools.
|
||||
{
|
||||
// Ensure filesystem/shell dev tools are always available in the
|
||||
// test rig, even if upstream builder flags/config disable local tools.
|
||||
components.tools.register_dev_tools();
|
||||
|
||||
components.tools.register_job_tools(
|
||||
Arc::clone(&components.context_manager),
|
||||
Some(scheduler_slot.clone()),
|
||||
|
||||
Reference in New Issue
Block a user