Files
optimclaw/src/config/relay.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

152 lines
5.6 KiB
Rust

//! Channel-relay service configuration.
use secrecy::SecretString;
/// Configuration for connecting to a channel-relay service.
#[derive(Clone)]
pub struct RelayConfig {
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
pub url: String,
/// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
pub api_key: SecretString,
/// Override for the OAuth callback URL (e.g., a tunnel URL).
pub callback_url: Option<String>,
/// Override for the instance identifier.
pub instance_id: Option<String>,
/// HTTP request timeout in seconds (default: 30).
pub request_timeout_secs: u64,
/// Path for the webhook callback endpoint (default: `/relay/events`).
pub webhook_path: String,
}
impl std::fmt::Debug for RelayConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RelayConfig")
.field("url", &self.url)
.field("api_key", &"[REDACTED]")
.field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("webhook_path", &self.webhook_path)
.finish()
}
}
impl RelayConfig {
/// Load relay config from environment variables.
///
/// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
/// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in.
/// The signing secret is fetched from channel-relay at activation time via
/// the authenticated `/relay/signing-secret` endpoint — no env var required.
pub fn from_env() -> Option<Self> {
Self::from_env_reader(|key| std::env::var(key).ok())
}
/// Build a config for tests without touching the process environment.
pub fn from_values(url: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
url: url.into(),
api_key: SecretString::from(api_key.into()),
callback_url: None,
instance_id: None,
request_timeout_secs: 30,
webhook_path: "/relay/events".into(),
}
}
/// Internal constructor that reads values through a closure, enabling safe testing.
fn from_env_reader(env: impl Fn(&str) -> Option<String>) -> Option<Self> {
let url = env("CHANNEL_RELAY_URL")?;
let api_key = SecretString::from(env("CHANNEL_RELAY_API_KEY")?);
Some(Self {
url,
api_key,
callback_url: env("IRONCLAW_OAUTH_CALLBACK_URL"),
instance_id: env("IRONCLAW_INSTANCE_ID"),
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(30),
webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_reader_returns_none_when_unset() {
let config = RelayConfig::from_env_reader(|_| None);
assert!(config.is_none());
}
#[test]
fn from_env_reader_requires_only_url_and_api_key() {
// Signing secret is fetched at activation time — only URL + API key needed.
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
_ => None,
});
assert!(
config.is_some(),
"relay config should load with just URL + API key"
);
}
#[test]
fn from_env_reader_loads_all_required() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
_ => None,
})
.expect("config should be Some");
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
assert_eq!(config.webhook_path, "/relay/events");
assert!(config.callback_url.is_none());
assert!(config.instance_id.is_none());
}
#[test]
fn from_env_reader_loads_overrides() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://relay:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("secret".into()),
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
"RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
_ => None,
})
.expect("config should be Some");
assert_eq!(
config.callback_url.as_deref(),
Some("https://tunnel.example.com")
);
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60);
assert_eq!(config.webhook_path, "/custom/events");
}
#[test]
fn from_values_builds_with_defaults() {
let config = RelayConfig::from_values("http://localhost:3001", "key");
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
}
#[test]
fn debug_redacts_secrets() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("super-secret"));
}
}