mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* 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]>
152 lines
5.6 KiB
Rust
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"));
|
|
}
|
|
}
|