use super::context::AgentContext;
use super::tool_registry::ToolHandler;
use async_trait::async_trait;
use dashmap::DashMap;
use hmac::{Hmac, Mac};
use nexo_config::types::remote_triggers::{RemoteTriggerEntry, REMOTE_TRIGGER_MAX_BODY_BYTES};
use nexo_llm::ToolDef;
use serde_json::{json, Value};
use sha2::Sha256;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
type HmacSha256 = Hmac<Sha256>;
#[derive(Default)]
pub struct RemoteTriggerRateLimiter {
buckets: DashMap<String, Mutex<std::collections::VecDeque<Instant>>>,
}
impl RemoteTriggerRateLimiter {
pub fn try_acquire(&self, trigger_name: &str, limit_per_minute: u32) -> bool {
if limit_per_minute == 0 {
return true;
}
let entry = self.buckets.entry(trigger_name.to_string()).or_default();
let mut q = entry.lock().unwrap();
let now = Instant::now();
let cutoff = now - Duration::from_secs(60);
while let Some(front) = q.front() {
if *front < cutoff {
q.pop_front();
} else {
break;
}
}
if q.len() < limit_per_minute as usize {
q.push_back(now);
true
} else {
false
}
}
}
#[async_trait]
pub trait RemoteTriggerSink: Send + Sync {
async fn post_webhook(
&self,
url: &str,
body: &str,
headers: Vec<(String, String)>,
timeout: Duration,
) -> anyhow::Result<u16>;
async fn publish_nats(&self, subject: &str, payload: &[u8]) -> anyhow::Result<()>;
}
pub struct ReqwestSink {
pub broker: nexo_broker::AnyBroker,
pub client: reqwest::Client,
}
impl ReqwestSink {
pub fn new(broker: nexo_broker::AnyBroker) -> Self {
Self {
broker,
client: reqwest::Client::new(),
}
}
}
#[async_trait]
impl RemoteTriggerSink for ReqwestSink {
async fn post_webhook(
&self,
url: &str,
body: &str,
headers: Vec<(String, String)>,
timeout: Duration,
) -> anyhow::Result<u16> {
let mut req = self
.client
.post(url)
.timeout(timeout)
.header("Content-Type", "application/json")
.body(body.to_string());
for (k, v) in headers {
req = req.header(k, v);
}
let res = req
.send()
.await
.map_err(|e| anyhow::anyhow!("webhook POST failed: {e}"))?;
let status = res.status().as_u16();
if status >= 400 {
let text = res.text().await.unwrap_or_default();
anyhow::bail!(
"webhook returned HTTP {status}: {body}",
body = text.chars().take(200).collect::<String>()
);
}
Ok(status)
}
async fn publish_nats(&self, subject: &str, payload: &[u8]) -> anyhow::Result<()> {
use nexo_broker::BrokerHandle;
let json: serde_json::Value = serde_json::from_slice(payload)
.map_err(|e| anyhow::anyhow!("NATS payload not JSON: {e}"))?;
let event = nexo_broker::Event::new(subject, "remote_trigger", json);
self.broker
.publish(subject, event)
.await
.map_err(|e| anyhow::anyhow!("NATS publish failed: {e}"))
}
}
pub struct RemoteTriggerTool {
sink: Arc<dyn RemoteTriggerSink>,
rate_limiter: Arc<RemoteTriggerRateLimiter>,
}
impl RemoteTriggerTool {
pub fn new(sink: Arc<dyn RemoteTriggerSink>) -> Self {
Self {
sink,
rate_limiter: Arc::new(RemoteTriggerRateLimiter::default()),
}
}
pub fn tool_def() -> ToolDef {
ToolDef {
name: "RemoteTrigger".to_string(),
description: "Publish a JSON payload to a pre-configured outbound destination — webhook (HTTP POST, optionally HMAC-SHA256 signed) or NATS subject. Destinations are named in YAML (`agents[].remote_triggers` or binding override); the model passes only the name and payload, never URLs or subjects. Per-destination rate limit + 256 KiB body cap apply.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the destination as configured in `remote_triggers[].name` for this session's effective policy."
},
"payload": {
"description": "JSON payload to send. Object / array / scalar — any JSON. Capped at 256 KiB serialised."
}
},
"required": ["name", "payload"]
}),
}
}
}
fn sign_body(secret: &[u8], body: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(body.as_bytes());
let bytes = mac.finalize().into_bytes();
let mut hex = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(hex, "{b:02x}");
}
format!("sha256={hex}")
}
#[async_trait]
impl ToolHandler for RemoteTriggerTool {
async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("RemoteTrigger requires `name` (string)"))?
.to_string();
let payload = args
.get("payload")
.ok_or_else(|| anyhow::anyhow!("RemoteTrigger requires `payload`"))?;
let effective = ctx.effective_policy();
let entry = effective
.remote_triggers
.iter()
.find(|e| e.name() == name)
.cloned()
.ok_or_else(|| {
anyhow::anyhow!(
"RemoteTrigger: no destination named `{name}` in this session allowlist. Operator must add it under `agents[].remote_triggers[]` or the matched `inbound_bindings[].remote_triggers[]` override."
)
})?;
let body = serde_json::to_string(payload)
.map_err(|e| anyhow::anyhow!("RemoteTrigger: payload not serialisable: {e}"))?;
if body.len() > REMOTE_TRIGGER_MAX_BODY_BYTES {
return Err(anyhow::anyhow!(
"RemoteTrigger: payload too large ({actual} bytes > max {max})",
actual = body.len(),
max = REMOTE_TRIGGER_MAX_BODY_BYTES
));
}
let rate_limit_key = match effective.binding_index {
Some(idx) => format!("{idx}:{}", entry.name()),
None => format!("agent:{}", entry.name()),
};
if !self
.rate_limiter
.try_acquire(&rate_limit_key, entry.rate_limit_per_minute())
{
return Err(anyhow::anyhow!(
"RemoteTrigger: rate limit exceeded for `{name}` ({} calls/min). Wait or raise `rate_limit_per_minute` in YAML.",
entry.rate_limit_per_minute()
));
}
match &entry {
RemoteTriggerEntry::Webhook {
url,
secret_env,
timeout_ms,
..
} => {
let now_unix = chrono::Utc::now().timestamp();
let mut headers: Vec<(String, String)> = Vec::with_capacity(3);
headers.push(("X-Nexo-Trigger-Name".to_string(), entry.name().to_string()));
headers.push(("X-Nexo-Timestamp".to_string(), now_unix.to_string()));
if let Some(env) = secret_env {
let secret = std::env::var(env).map_err(|_| {
anyhow::anyhow!(
"RemoteTrigger: secret_env `{env}` is not set; refusing to send unsigned"
)
})?;
headers.push((
"X-Nexo-Signature".to_string(),
sign_body(secret.as_bytes(), &body),
));
}
let timeout = Duration::from_millis(*timeout_ms);
let started = Instant::now();
let status = self.sink.post_webhook(url, &body, headers, timeout).await?;
Ok(json!({
"ok": true,
"kind": "webhook",
"name": name,
"status": status,
"signed": secret_env.is_some(),
"duration_ms": started.elapsed().as_millis() as u64,
"bytes_sent": body.len(),
}))
}
RemoteTriggerEntry::Nats { subject, .. } => {
let started = Instant::now();
self.sink.publish_nats(subject, body.as_bytes()).await?;
Ok(json!({
"ok": true,
"kind": "nats",
"name": name,
"duration_ms": started.elapsed().as_millis() as u64,
"bytes_sent": body.len(),
}))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::SessionManager;
use nexo_broker::AnyBroker;
use nexo_config::types::agents::{
AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
OutboundAllowlistConfig, WorkspaceGitConfig,
};
use std::sync::Arc;
use std::sync::Mutex;
#[derive(Default)]
#[allow(dead_code)]
struct CapturedCall {
url: String,
body: String,
headers: Vec<(String, String)>,
}
#[derive(Default)]
struct FakeSink {
webhook_calls: Mutex<Vec<CapturedCall>>,
nats_calls: Mutex<Vec<(String, Vec<u8>)>>,
force_status: Mutex<u16>,
}
impl FakeSink {
fn new() -> Arc<Self> {
Arc::new(Self {
force_status: Mutex::new(200),
..Default::default()
})
}
}
#[async_trait]
impl RemoteTriggerSink for FakeSink {
async fn post_webhook(
&self,
url: &str,
body: &str,
headers: Vec<(String, String)>,
_timeout: Duration,
) -> anyhow::Result<u16> {
self.webhook_calls.lock().unwrap().push(CapturedCall {
url: url.to_string(),
body: body.to_string(),
headers,
});
let s = *self.force_status.lock().unwrap();
if s >= 400 {
anyhow::bail!("simulated HTTP {s}");
}
Ok(s)
}
async fn publish_nats(&self, subject: &str, payload: &[u8]) -> anyhow::Result<()> {
self.nats_calls
.lock()
.unwrap()
.push((subject.to_string(), payload.to_vec()));
Ok(())
}
}
fn agent_config_with_triggers(triggers: Vec<RemoteTriggerEntry>) -> AgentConfig {
AgentConfig {
id: "a".into(),
model: ModelConfig {
provider: "x".into(),
model: "y".into(),
},
plugins: Vec::new(),
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: String::new(),
workspace: String::new(),
skills: Vec::new(),
skills_dir: "./skills".into(),
skill_overrides: Default::default(),
transcripts_dir: String::new(),
dreaming: DreamingYamlConfig::default(),
workspace_git: WorkspaceGitConfig::default(),
tool_rate_limits: None,
tool_args_validation: None,
extra_docs: Vec::new(),
inbound_bindings: Vec::new(),
allowed_tools: Vec::new(),
sender_rate_limit: None,
allowed_delegates: Vec::new(),
accept_delegates_from: Vec::new(),
description: String::new(),
google_auth: None,
credentials: Default::default(),
link_understanding: serde_json::Value::Null,
web_search: serde_json::Value::Null,
pairing_policy: serde_json::Value::Null,
language: None,
locale_prompts: Default::default(),
outbound_allowlist: OutboundAllowlistConfig::default(),
context_optimization: None,
dispatch_policy: Default::default(),
plan_mode: Default::default(),
remote_triggers: triggers,
lsp: nexo_config::types::lsp::LspPolicy::default(),
config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
team: nexo_config::types::team::TeamPolicy::default(),
proactive: Default::default(),
repl: Default::default(),
auto_dream: None,
assistant_mode: None,
away_summary: None,
brief: None,
channels: None,
auto_approve: false,
extract_memories: None,
event_subscribers: Vec::new(),
tenant_id: None,
extensions_config: std::collections::BTreeMap::new(),
active: true,
}
}
fn ctx_with_triggers(triggers: Vec<RemoteTriggerEntry>) -> AgentContext {
let cfg = agent_config_with_triggers(triggers);
AgentContext::new(
"a",
Arc::new(cfg),
AnyBroker::local(),
Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 8)),
)
}
fn ctx_with_binding_override(
agent_level: Vec<RemoteTriggerEntry>,
binding_level: Vec<RemoteTriggerEntry>,
binding_index: usize,
) -> AgentContext {
let cfg = Arc::new(agent_config_with_triggers(agent_level));
let mut eff = crate::agent::EffectiveBindingPolicy::from_agent_defaults(&cfg);
eff.binding_index = Some(binding_index);
eff.remote_triggers = binding_level;
AgentContext::new(
"a",
Arc::clone(&cfg),
AnyBroker::local(),
Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 8)),
)
.with_effective(Arc::new(eff))
}
fn webhook(name: &str, secret: Option<&str>, rate: u32) -> RemoteTriggerEntry {
RemoteTriggerEntry::Webhook {
name: name.into(),
url: format!("https://example.test/{name}"),
secret_env: secret.map(str::to_string),
timeout_ms: 5000,
rate_limit_per_minute: rate,
}
}
fn nats(name: &str, subject: &str, rate: u32) -> RemoteTriggerEntry {
RemoteTriggerEntry::Nats {
name: name.into(),
subject: subject.into(),
rate_limit_per_minute: rate,
}
}
fn tool(sink: Arc<dyn RemoteTriggerSink>) -> RemoteTriggerTool {
RemoteTriggerTool::new(sink)
}
#[tokio::test]
async fn refuses_name_not_in_allowlist() {
let ctx = ctx_with_triggers(vec![webhook("ops", None, 10)]);
let sink = FakeSink::new();
let err = tool(sink.clone())
.call(&ctx, json!({"name": "imaginary", "payload": {"a": 1}}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("not in allowlist") || err.contains("no destination"));
assert!(sink.webhook_calls.lock().unwrap().is_empty());
assert!(sink.nats_calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn binding_override_allowlist_wins_over_agent_level() {
let ctx = ctx_with_binding_override(
vec![webhook("agent_only", None, 10)],
vec![webhook("binding_only", None, 10)],
4,
);
let sink = FakeSink::new();
let t = tool(sink.clone());
t.call(&ctx, json!({"name": "binding_only", "payload": {"a": 1}}))
.await
.unwrap();
let err = t
.call(&ctx, json!({"name": "agent_only", "payload": {"a": 1}}))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("no destination"),
"agent-level destination must be hidden by binding override, got: {err}"
);
let calls = sink.webhook_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].url, "https://example.test/binding_only");
}
#[tokio::test]
async fn rate_limit_isolated_per_binding_for_same_trigger_name() {
let ctx_a = ctx_with_binding_override(vec![], vec![webhook("ops", None, 1)], 0);
let ctx_b = ctx_with_binding_override(vec![], vec![webhook("ops", None, 1)], 1);
let sink = FakeSink::new();
let t = tool(sink.clone());
t.call(&ctx_a, json!({"name": "ops", "payload": {}}))
.await
.unwrap();
t.call(&ctx_b, json!({"name": "ops", "payload": {}}))
.await
.unwrap();
let err_a = t
.call(&ctx_a, json!({"name": "ops", "payload": {}}))
.await
.unwrap_err()
.to_string();
let err_b = t
.call(&ctx_b, json!({"name": "ops", "payload": {}}))
.await
.unwrap_err()
.to_string();
assert!(err_a.contains("rate limit"), "got: {err_a}");
assert!(err_b.contains("rate limit"), "got: {err_b}");
assert_eq!(sink.webhook_calls.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn webhook_unsigned_emits_no_signature_header() {
let ctx = ctx_with_triggers(vec![webhook("ops", None, 10)]);
let sink = FakeSink::new();
let res = tool(sink.clone())
.call(&ctx, json!({"name": "ops", "payload": {"a": 1}}))
.await
.unwrap();
assert_eq!(res["ok"], true);
assert_eq!(res["kind"], "webhook");
assert_eq!(res["signed"], false);
let calls = sink.webhook_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert!(!calls[0]
.headers
.iter()
.any(|(k, _)| k == "X-Nexo-Signature"));
assert!(calls[0]
.headers
.iter()
.any(|(k, v)| k == "X-Nexo-Trigger-Name" && v == "ops"));
assert!(calls[0]
.headers
.iter()
.any(|(k, _)| k == "X-Nexo-Timestamp"));
}
#[tokio::test]
async fn webhook_signed_emits_valid_hmac() {
let env_var = "NEXO_TEST_RT_SECRET_VALID";
std::env::set_var(env_var, "topsecret");
let ctx = ctx_with_triggers(vec![webhook("ops", Some(env_var), 10)]);
let sink = FakeSink::new();
let _res = tool(sink.clone())
.call(&ctx, json!({"name": "ops", "payload": {"a": 1}}))
.await
.unwrap();
let calls = sink.webhook_calls.lock().unwrap();
let sig = calls[0]
.headers
.iter()
.find(|(k, _)| k == "X-Nexo-Signature")
.map(|(_, v)| v.clone())
.expect("signature header missing");
assert!(sig.starts_with("sha256="));
let expected = sign_body(b"topsecret", &calls[0].body);
assert_eq!(sig, expected);
std::env::remove_var(env_var);
}
#[tokio::test]
async fn webhook_missing_secret_env_refuses() {
let env_var = "NEXO_TEST_RT_SECRET_MISSING_PLEASE_DO_NOT_SET";
std::env::remove_var(env_var);
let ctx = ctx_with_triggers(vec![webhook("ops", Some(env_var), 10)]);
let sink = FakeSink::new();
let err = tool(sink.clone())
.call(&ctx, json!({"name": "ops", "payload": {"a": 1}}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("not set"), "got: {err}");
assert!(
sink.webhook_calls.lock().unwrap().is_empty(),
"must not send unsigned"
);
}
#[tokio::test]
async fn nats_publishes_to_subject() {
let ctx = ctx_with_triggers(vec![nats("ops", "agent.outbound.ops", 10)]);
let sink = FakeSink::new();
let res = tool(sink.clone())
.call(&ctx, json!({"name": "ops", "payload": {"x": 42}}))
.await
.unwrap();
assert_eq!(res["kind"], "nats");
let calls = sink.nats_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "agent.outbound.ops");
let body: Value = serde_json::from_slice(&calls[0].1).unwrap();
assert_eq!(body["x"], 42);
}
#[tokio::test]
async fn rate_limit_blocks_after_budget() {
let ctx = ctx_with_triggers(vec![webhook("ops", None, 2)]);
let sink = FakeSink::new();
let t = tool(sink.clone());
t.call(&ctx, json!({"name": "ops", "payload": {}}))
.await
.unwrap();
t.call(&ctx, json!({"name": "ops", "payload": {}}))
.await
.unwrap();
let err = t
.call(&ctx, json!({"name": "ops", "payload": {}}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("rate limit"), "got: {err}");
assert_eq!(sink.webhook_calls.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn rate_limit_zero_means_unlimited() {
let ctx = ctx_with_triggers(vec![webhook("ops", None, 0)]);
let sink = FakeSink::new();
let t = tool(sink.clone());
for _ in 0..50 {
t.call(&ctx, json!({"name": "ops", "payload": {}}))
.await
.unwrap();
}
assert_eq!(sink.webhook_calls.lock().unwrap().len(), 50);
}
#[tokio::test]
async fn payload_too_large_is_rejected_before_send() {
let ctx = ctx_with_triggers(vec![webhook("ops", None, 10)]);
let sink = FakeSink::new();
let big = "x".repeat(REMOTE_TRIGGER_MAX_BODY_BYTES + 1);
let err = tool(sink.clone())
.call(&ctx, json!({"name": "ops", "payload": big}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("too large"), "got: {err}");
assert!(sink.webhook_calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn webhook_4xx_propagates_as_error() {
let ctx = ctx_with_triggers(vec![webhook("ops", None, 10)]);
let sink = FakeSink::new();
*sink.force_status.lock().unwrap() = 503;
let err = tool(sink.clone())
.call(&ctx, json!({"name": "ops", "payload": {}}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("503"), "got: {err}");
}
#[tokio::test]
async fn missing_name_arg_errors() {
let ctx = ctx_with_triggers(vec![]);
let sink = FakeSink::new();
let err = tool(sink)
.call(&ctx, json!({"payload": {}}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("requires `name`"), "got: {err}");
}
#[tokio::test]
async fn missing_payload_arg_errors() {
let ctx = ctx_with_triggers(vec![]);
let sink = FakeSink::new();
let err = tool(sink)
.call(&ctx, json!({"name": "ops"}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("requires `payload`"), "got: {err}");
}
#[tokio::test]
async fn sign_body_is_deterministic_and_hex() {
let s1 = sign_body(b"k", "{}");
let s2 = sign_body(b"k", "{}");
assert_eq!(s1, s2);
assert!(s1.starts_with("sha256="));
let hex = s1.trim_start_matches("sha256=");
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
}