Files
optimclaw/src/channels/relay/channel.rs
T
52ca9d6588 feat: receive relay events via webhook callbacks (#1254)
* feat: receive relay events via webhook callbacks instead of SSE

Replace the SSE pull model with push-based webhook callbacks from
channel-relay. Eliminates the reconnect loop, stream token auth,
and SSE parser — events arrive via HTTP POST to /relay/events.

- Add webhook handler with HMAC signature verification
- Simplify RelayChannel to use mpsc from webhook handler
- Remove SSE connect/reconnect/parse logic from RelayClient
- Add register_callback() to RelayClient for callback URL registration
- Update activation flow to create event channel and register callback
- Wire relay webhook endpoint into web gateway

* fix: address review feedback on webhook callback PR

- Return 503 when relay event channel is full/closed (enables retry)
- Reject malformed timestamps with 400 instead of proceeding
- Allow relay activation without settings store (no-store/ephemeral mode)
- Check installed_relay_extensions set in is_relay_channel for no-db mode
- Fix staging test constructors for new RelayChannel signature

* security: adapt relay client to new channel-relay auth model

Adapts the relay integration to the hardened channel-relay security model:

- Switch from X-API-Key header to Authorization: Bearer sk-agent-*
  for all relay API calls (chat-api token verification)
- Remove register_callback() — PUT /callbacks endpoint removed
- Remove event_callback_url from initiate_oauth() — parameter removed
- Make signing_secret a required field in RelayConfig (new env var:
  CHANNEL_RELAY_SIGNING_SECRET)
- Update integration tests for Bearer auth and removed endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: use server-side approval tokens, remove caller-supplied routing

- Approval flow now calls POST /approvals to register server-side
  record, then embeds only the opaque approval_token in button value
- Remove instance_id parameter from proxy_provider() — channel-relay
  no longer accepts it (uses verified identity)
- Remove instance_id and user_id from initiate_oauth() — channel-relay
  derives them from the Bearer token
- Add create_approval() to RelayClient

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: pass webhook_url during OAuth so callback_url is set on connection

The channel-relay OAuth flow now accepts webhook_url to set the
callback_url during connection creation. IronClaw computes its webhook
URL from callback_base + webhook_path and passes it during initiate_oauth.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: remove webhook_url from OAuth initiation

Channel-relay now derives the callback URL from chat-api's instance_url.
IronClaw no longer supplies webhook_url during OAuth — the relay is the
authority on where events get delivered.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: remove all URL params from OAuth initiation

IronClaw no longer supplies any URLs to channel-relay. The relay
derives all URLs from the trusted instance_url in chat-api.
initiate_oauth() takes no parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: restore CSRF nonce for OAuth callback validation

Re-add nonce generation and secret storage in auth_channel_relay.
The nonce is passed to channel-relay as state_nonce param (not a URL).
Channel-relay embeds it in the signed state and appends it to the
redirect URL so IronClaw's callback handler can validate and activate.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: per-instance callback signing secrets

relay_signing_secret() now prefers OPENCLAW_GATEWAY_TOKEN (per-instance)
over the shared CHANNEL_RELAY_SIGNING_SECRET. A compromised instance
can no longer forge callbacks to other instances on the same relay.
CHANNEL_RELAY_SIGNING_SECRET is now optional in RelayConfig.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: clean per-instance callback secrets, no shared secrets, no fallbacks

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: pass team_id to get_signing_secret for workspace-scoped lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: remove sender_id from create_approval — relay derives it

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: remove stale relay sender_id validation

* fix: harden relay webhook activation lifecycle

---------

Co-authored-by: Pierre <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 11:53:46 -07:00

679 lines
22 KiB
Rust

//! Channel trait implementation for channel-relay webhook callbacks.
//!
//! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
//! (pushed through an mpsc channel by the webhook handler), converts them
//! to `IncomingMessage`s, and sends responses via the relay's provider-specific
//! proxy API (Slack).
use std::collections::HashMap;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::channels::relay::client::{ChannelEvent, RelayClient};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
/// Default channel name for the Slack relay integration.
pub const DEFAULT_RELAY_NAME: &str = "slack-relay";
/// The messaging provider backing a relay channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayProvider {
Slack,
}
impl RelayProvider {
/// Provider string used in proxy API routes and metadata.
pub fn as_str(&self) -> &'static str {
match self {
Self::Slack => "slack",
}
}
/// The default channel name for this provider.
pub fn channel_name(&self) -> &'static str {
match self {
Self::Slack => DEFAULT_RELAY_NAME,
}
}
}
/// Channel implementation that receives events from channel-relay via webhook callbacks.
pub struct RelayChannel {
client: RelayClient,
provider: RelayProvider,
team_id: String,
instance_id: String,
/// Sender side of the event channel — shared with the webhook handler.
event_tx: mpsc::Sender<ChannelEvent>,
/// Receiver side — taken once by `start()`.
event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
}
impl RelayChannel {
/// Create a new relay channel for Slack (default provider).
pub fn new(
client: RelayClient,
team_id: String,
instance_id: String,
event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self {
Self::new_with_provider(
client,
RelayProvider::Slack,
team_id,
instance_id,
event_tx,
event_rx,
)
}
/// Create a new relay channel with a specific provider.
pub fn new_with_provider(
client: RelayClient,
provider: RelayProvider,
team_id: String,
instance_id: String,
event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self {
Self {
client,
provider,
team_id,
instance_id,
event_tx,
event_rx: tokio::sync::Mutex::new(Some(event_rx)),
}
}
/// Get a clone of the event sender for wiring into the webhook endpoint.
pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
self.event_tx.clone()
}
/// Build a provider-appropriate proxy body for sending a message.
fn build_send_body(
&self,
channel_id: &str,
text: &str,
thread_id: Option<&str>,
) -> (String, serde_json::Value) {
match self.provider {
RelayProvider::Slack => {
let mut body = serde_json::json!({
"channel": channel_id,
"text": text,
});
if let Some(tid) = thread_id {
body["thread_ts"] = serde_json::Value::String(tid.to_string());
}
("chat.postMessage".to_string(), body)
}
}
}
/// Send a message via the provider proxy.
async fn proxy_send(
&self,
team_id: &str,
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
self.client
.proxy_provider(self.provider.as_str(), team_id, method, body)
.await
}
}
#[async_trait]
impl Channel for RelayChannel {
fn name(&self) -> &str {
self.provider.channel_name()
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let channel_name = self.name().to_string();
// Take the receiver (can only start once)
let mut event_rx =
self.event_rx
.lock()
.await
.take()
.ok_or_else(|| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: "RelayChannel already started".to_string(),
})?;
let (tx, rx) = mpsc::channel(64);
let provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone();
// Spawn a task that reads events from the webhook handler and converts to IncomingMessage
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
continue;
}
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
continue;
}
tracing::info!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
);
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
let msg = if let Some(ref thread_id) = event.thread_id {
msg.with_thread(thread_id)
} else {
msg.with_thread(&event.channel_id)
};
if tx.send(msg).await.is_err() {
tracing::info!("Relay channel receiver dropped, stopping");
return;
}
}
tracing::info!("Relay event channel closed");
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream))
}
async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
let metadata = &msg.metadata;
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
let channel_id = metadata
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: channel_name.clone(),
reason: "Missing channel_id in message metadata".to_string(),
})?;
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| metadata.get("thread_id").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(channel_id, &response.content, thread_id);
self.proxy_send(team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
async fn send_status(
&self,
status: StatusUpdate,
metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
// Only handle ApprovalNeeded — all other variants are no-ops
let StatusUpdate::ApprovalNeeded {
request_id,
tool_name,
description,
parameters,
allow_always: _,
} = status
else {
return Ok(());
};
// Only send buttons in DMs (dispatcher gates upstream, but guard here too)
let event_type = metadata
.get("event_type")
.and_then(|v| v.as_str())
.unwrap_or("");
if event_type != "direct_message" {
tracing::warn!(
tool = %tool_name,
event_type,
"Approval requested in non-DM, skipping buttons"
);
return Ok(());
}
// Extract required metadata — error if missing
let channel_id = metadata
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: self.name().to_string(),
reason: "Missing channel_id for approval buttons".into(),
})?;
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
// Register server-side approval record and get opaque token.
// The button value contains ONLY the token — no routing fields.
let approval_token = self
.client
.create_approval(team_id, channel_id, thread_id, &request_id)
.await
.map_err(|e| ChannelError::SendFailed {
name: self.name().to_string(),
reason: format!("Failed to register approval: {e}"),
})?;
let value_payload = serde_json::json!({
"approval_token": approval_token,
});
let value_str = value_payload.to_string();
// Parameters are already redacted via redact_params() in dispatcher.rs
let params_display =
serde_json::to_string_pretty(&parameters).unwrap_or_else(|_| parameters.to_string());
let blocks = serde_json::json!([
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(
"*Tool approval required*\n`{tool_name}`: {description}\n```{params_display}```"
)
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "Approve" },
"style": "primary",
"action_id": "approve_tool",
"value": value_str,
},
{
"type": "button",
"text": { "type": "plain_text", "text": "Deny" },
"style": "danger",
"action_id": "deny_tool",
"value": value_str,
}
]
}
]);
let mut body = serde_json::json!({
"channel": channel_id,
"text": format!("Tool approval required: {tool_name} - {description}"),
"blocks": blocks,
});
if let Some(tid) = thread_id {
body["thread_ts"] = serde_json::Value::String(tid.to_string());
}
self.proxy_send(team_id, "chat.postMessage", body)
.await
.map_err(|e| ChannelError::SendFailed {
name: self.name().to_string(),
reason: e.to_string(),
})?;
Ok(())
}
async fn broadcast(
&self,
target: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| response.metadata.get("thread_ts").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(target, &response.content, thread_id);
self.proxy_send(&self.team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
self.client
.list_connections(&self.instance_id)
.await
.map_err(|_| ChannelError::HealthCheckFailed {
name: self.name().to_string(),
})?;
Ok(())
}
fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap<String, String> {
let mut ctx = HashMap::new();
if let Some(sender) = metadata.get("sender_name").and_then(|v| v.as_str()) {
ctx.insert("sender".to_string(), sender.to_string());
}
if let Some(sender_id) = metadata.get("sender_id").and_then(|v| v.as_str()) {
ctx.insert("sender_uuid".to_string(), sender_id.to_string());
}
if let Some(channel_id) = metadata.get("channel_id").and_then(|v| v.as_str()) {
ctx.insert("group".to_string(), channel_id.to_string());
}
ctx.insert("platform".to_string(), self.provider.as_str().to_string());
ctx
}
async fn shutdown(&self) -> Result<(), ChannelError> {
// Relay cleanup is driven by the extension manager dropping the shared
// sender and removing the channel from the channel manager.
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_client() -> RelayClient {
RelayClient::new(
"http://localhost:3001".into(),
secrecy::SecretString::from("key".to_string()),
30,
)
.expect("client")
}
fn make_channel() -> RelayChannel {
let (tx, rx) = mpsc::channel(64);
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
}
#[test]
fn relay_channel_name() {
let channel = make_channel();
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
}
#[test]
fn conversation_context_extracts_metadata() {
let channel = make_channel();
let metadata = serde_json::json!({
"sender_name": "bob",
"sender_id": "U123",
"channel_id": "C456",
});
let ctx = channel.conversation_context(&metadata);
assert_eq!(ctx.get("sender"), Some(&"bob".to_string()));
assert_eq!(ctx.get("sender_uuid"), Some(&"U123".to_string()));
assert_eq!(ctx.get("platform"), Some(&"slack".to_string()));
}
#[test]
fn metadata_shape_includes_event_type_and_sender_name() {
let metadata = serde_json::json!({
"team_id": "T123",
"channel_id": "C456",
"sender_id": "U789",
"sender_name": "alice",
"event_type": "direct_message",
"thread_id": null,
"provider": "slack",
});
assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message")
);
assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice")
);
}
#[test]
fn build_send_body_slack() {
let channel = make_channel();
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456");
assert_eq!(body["text"], "hello");
assert_eq!(body["thread_ts"], "1234567.890");
}
#[tokio::test]
async fn start_processes_events() {
let (tx, rx) = mpsc::channel(64);
let channel =
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
let mut stream = channel.start().await.unwrap();
// Send an event
tx.send(ChannelEvent {
id: "1".into(),
event_type: "message".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: Some("alice".into()),
content: Some("hello".into()),
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "hello");
assert_eq!(msg.user_id, "U789");
}
#[tokio::test]
async fn start_skips_non_message_events() {
let (tx, rx) = mpsc::channel(64);
let channel =
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
let mut stream = channel.start().await.unwrap();
// Send a non-message event (should be skipped)
tx.send(ChannelEvent {
id: "1".into(),
event_type: "reaction".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
// Send a real message
tx.send(ChannelEvent {
id: "2".into(),
event_type: "message".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: None,
content: Some("real message".into()),
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "real message");
}
#[tokio::test]
async fn test_send_status_non_approval_is_noop() {
let channel = make_channel();
let metadata = serde_json::json!({});
let result = channel
.send_status(
StatusUpdate::ToolStarted {
name: "echo".into(),
},
&metadata,
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_status_approval_non_dm_skips() {
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "message",
"channel_id": "C456",
"sender_id": "U789",
});
let result = channel
.send_status(
StatusUpdate::ApprovalNeeded {
request_id: "req1".into(),
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
.await;
// Non-DM approval requests are silently skipped (no HTTP call)
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_status_approval_dm_missing_channel_id_errors() {
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"sender_id": "U789",
});
let result = channel
.send_status(
StatusUpdate::ApprovalNeeded {
request_id: "req1".into(),
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("channel_id"),
"expected channel_id error, got: {err}"
);
}
#[tokio::test]
async fn test_send_status_approval_dm_without_sender_id_is_ok() {
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"channel_id": "C456",
});
let result = channel
.send_status(
StatusUpdate::ApprovalNeeded {
request_id: "req1".into(),
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
!err.contains("sender_id"),
"sender_id should not be required anymore, got: {err}"
);
}
}