mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Implementing channels to be handled in wasm
This commit is contained in:
@@ -2,12 +2,37 @@
|
||||
//!
|
||||
//! Channels receive messages from external sources (CLI, HTTP, etc.)
|
||||
//! and convert them to a unified message format for the agent to process.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||
//! │ ChannelManager │
|
||||
//! │ │
|
||||
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ... │
|
||||
//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
//! │ │ │ │ │
|
||||
//! │ └─────────────────┴─────────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ select_all (futures) │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ MessageStream │
|
||||
//! └─────────────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # WASM Channels
|
||||
//!
|
||||
//! WASM channels allow dynamic loading of channel implementations at runtime.
|
||||
//! See the [`wasm`] module for details.
|
||||
|
||||
mod channel;
|
||||
pub mod cli;
|
||||
mod http;
|
||||
mod manager;
|
||||
mod repl;
|
||||
pub mod wasm;
|
||||
|
||||
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
pub use cli::{AppEvent, TuiChannel};
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Channel-specific capabilities for WASM channels.
|
||||
//!
|
||||
//! Defines the capability system that controls what a WASM channel can do.
|
||||
//! Channels have additional capabilities beyond tools: HTTP endpoint registration,
|
||||
//! message emission, and workspace write access within their namespace.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::tools::wasm::{Capabilities as ToolCapabilities, RateLimitConfig};
|
||||
|
||||
/// Minimum allowed polling interval (30 seconds).
|
||||
pub const MIN_POLL_INTERVAL_MS: u32 = 30_000;
|
||||
|
||||
/// Default emit rate limit.
|
||||
pub const DEFAULT_EMIT_RATE_PER_MINUTE: u32 = 100;
|
||||
pub const DEFAULT_EMIT_RATE_PER_HOUR: u32 = 5000;
|
||||
|
||||
/// Capabilities specific to WASM channels.
|
||||
///
|
||||
/// Extends tool capabilities with channel-specific permissions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelCapabilities {
|
||||
/// Base tool capabilities (HTTP, secrets, workspace_read, etc.).
|
||||
pub tool_capabilities: ToolCapabilities,
|
||||
|
||||
/// HTTP paths this channel can register for webhooks.
|
||||
/// Paths must start with "/webhook/" by convention.
|
||||
pub allowed_paths: Vec<String>,
|
||||
|
||||
/// Whether polling is allowed for this channel.
|
||||
pub allow_polling: bool,
|
||||
|
||||
/// Minimum poll interval in milliseconds.
|
||||
/// Enforced to be at least MIN_POLL_INTERVAL_MS.
|
||||
pub min_poll_interval_ms: u32,
|
||||
|
||||
/// Workspace prefix for this channel's storage.
|
||||
/// All workspace writes are automatically prefixed.
|
||||
/// Example: "channels/slack/" means writes to "state.json" become "channels/slack/state.json".
|
||||
pub workspace_prefix: String,
|
||||
|
||||
/// Rate limiting for emit_message calls.
|
||||
pub emit_rate_limit: EmitRateLimitConfig,
|
||||
|
||||
/// Maximum message content size in bytes.
|
||||
pub max_message_size: usize,
|
||||
|
||||
/// Callback timeout duration.
|
||||
pub callback_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for ChannelCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tool_capabilities: ToolCapabilities::default(),
|
||||
allowed_paths: Vec::new(),
|
||||
allow_polling: false,
|
||||
min_poll_interval_ms: MIN_POLL_INTERVAL_MS,
|
||||
workspace_prefix: String::new(),
|
||||
emit_rate_limit: EmitRateLimitConfig::default(),
|
||||
max_message_size: 64 * 1024, // 64 KB
|
||||
callback_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelCapabilities {
|
||||
/// Create capabilities for a channel with the given name.
|
||||
pub fn for_channel(name: &str) -> Self {
|
||||
Self {
|
||||
workspace_prefix: format!("channels/{}/", name),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an allowed HTTP path.
|
||||
pub fn with_path(mut self, path: impl Into<String>) -> Self {
|
||||
self.allowed_paths.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable polling with the given minimum interval.
|
||||
pub fn with_polling(mut self, min_interval_ms: u32) -> Self {
|
||||
self.allow_polling = true;
|
||||
self.min_poll_interval_ms = min_interval_ms.max(MIN_POLL_INTERVAL_MS);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the emit rate limit.
|
||||
pub fn with_emit_rate_limit(mut self, rate_limit: EmitRateLimitConfig) -> Self {
|
||||
self.emit_rate_limit = rate_limit;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the callback timeout.
|
||||
pub fn with_callback_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.callback_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the base tool capabilities.
|
||||
pub fn with_tool_capabilities(mut self, capabilities: ToolCapabilities) -> Self {
|
||||
self.tool_capabilities = capabilities;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a path is allowed for this channel.
|
||||
pub fn is_path_allowed(&self, path: &str) -> bool {
|
||||
self.allowed_paths.iter().any(|p| p == path)
|
||||
}
|
||||
|
||||
/// Validate and normalize a poll interval.
|
||||
///
|
||||
/// Returns the interval clamped to minimum, or an error if polling is disabled.
|
||||
pub fn validate_poll_interval(&self, interval_ms: u32) -> Result<u32, String> {
|
||||
if !self.allow_polling {
|
||||
return Err("Polling not allowed for this channel".to_string());
|
||||
}
|
||||
|
||||
Ok(interval_ms.max(self.min_poll_interval_ms))
|
||||
}
|
||||
|
||||
/// Prefix a workspace path for this channel.
|
||||
///
|
||||
/// Ensures all workspace writes are scoped to the channel's namespace.
|
||||
pub fn prefix_workspace_path(&self, path: &str) -> String {
|
||||
if self.workspace_prefix.is_empty() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}{}", self.workspace_prefix, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a workspace path is valid for this channel.
|
||||
///
|
||||
/// Paths cannot escape the channel's namespace.
|
||||
pub fn validate_workspace_path(&self, path: &str) -> Result<String, String> {
|
||||
// Block absolute paths
|
||||
if path.starts_with('/') {
|
||||
return Err("Absolute paths not allowed".to_string());
|
||||
}
|
||||
|
||||
// Block path traversal
|
||||
if path.contains("..") {
|
||||
return Err("Parent directory references not allowed".to_string());
|
||||
}
|
||||
|
||||
// Block null bytes
|
||||
if path.contains('\0') {
|
||||
return Err("Null bytes not allowed".to_string());
|
||||
}
|
||||
|
||||
// Prefix with channel namespace
|
||||
Ok(self.prefix_workspace_path(path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for an HTTP endpoint the channel wants to register.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpEndpointConfig {
|
||||
/// Path to register (e.g., "/webhook/slack").
|
||||
pub path: String,
|
||||
|
||||
/// HTTP methods to accept (e.g., ["POST"]).
|
||||
pub methods: Vec<String>,
|
||||
|
||||
/// Whether secret validation is required.
|
||||
pub require_secret: bool,
|
||||
}
|
||||
|
||||
impl HttpEndpointConfig {
|
||||
/// Create a POST webhook endpoint.
|
||||
pub fn post_webhook(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polling configuration returned by the channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PollConfig {
|
||||
/// Polling interval in milliseconds.
|
||||
pub interval_ms: u32,
|
||||
|
||||
/// Whether polling is enabled.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for PollConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interval_ms: MIN_POLL_INTERVAL_MS,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiting configuration for message emission.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmitRateLimitConfig {
|
||||
/// Maximum messages per minute.
|
||||
pub messages_per_minute: u32,
|
||||
|
||||
/// Maximum messages per hour.
|
||||
pub messages_per_hour: u32,
|
||||
}
|
||||
|
||||
impl Default for EmitRateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
messages_per_minute: DEFAULT_EMIT_RATE_PER_MINUTE,
|
||||
messages_per_hour: DEFAULT_EMIT_RATE_PER_HOUR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RateLimitConfig> for EmitRateLimitConfig {
|
||||
fn from(config: RateLimitConfig) -> Self {
|
||||
Self {
|
||||
messages_per_minute: config.requests_per_minute,
|
||||
messages_per_hour: config.requests_per_hour,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::channels::wasm::capabilities::{
|
||||
ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, MIN_POLL_INTERVAL_MS,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_default_capabilities() {
|
||||
let caps = ChannelCapabilities::default();
|
||||
assert!(caps.allowed_paths.is_empty());
|
||||
assert!(!caps.allow_polling);
|
||||
assert_eq!(caps.min_poll_interval_ms, MIN_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_channel() {
|
||||
let caps = ChannelCapabilities::for_channel("slack");
|
||||
assert_eq!(caps.workspace_prefix, "channels/slack/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_allowed() {
|
||||
let caps = ChannelCapabilities::default()
|
||||
.with_path("/webhook/slack")
|
||||
.with_path("/webhook/slack/events");
|
||||
|
||||
assert!(caps.is_path_allowed("/webhook/slack"));
|
||||
assert!(caps.is_path_allowed("/webhook/slack/events"));
|
||||
assert!(!caps.is_path_allowed("/webhook/telegram"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_interval_validation() {
|
||||
let caps = ChannelCapabilities::default().with_polling(60_000);
|
||||
|
||||
// Valid interval
|
||||
assert_eq!(caps.validate_poll_interval(90_000).unwrap(), 90_000);
|
||||
|
||||
// Too short, clamped to minimum
|
||||
assert_eq!(caps.validate_poll_interval(1000).unwrap(), 60_000);
|
||||
|
||||
// Polling disabled
|
||||
let no_poll_caps = ChannelCapabilities::default();
|
||||
assert!(no_poll_caps.validate_poll_interval(60_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_path_validation() {
|
||||
let caps = ChannelCapabilities::for_channel("slack");
|
||||
|
||||
// Valid path
|
||||
let result = caps.validate_workspace_path("state.json");
|
||||
assert_eq!(result.unwrap(), "channels/slack/state.json");
|
||||
|
||||
// Nested path
|
||||
let result = caps.validate_workspace_path("data/users.json");
|
||||
assert_eq!(result.unwrap(), "channels/slack/data/users.json");
|
||||
|
||||
// Block absolute paths
|
||||
let result = caps.validate_workspace_path("/etc/passwd");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Block path traversal
|
||||
let result = caps.validate_workspace_path("../secrets/key.txt");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Block null bytes
|
||||
let result = caps.validate_workspace_path("file\0.txt");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_http_endpoint_config() {
|
||||
let endpoint = HttpEndpointConfig::post_webhook("/webhook/slack");
|
||||
assert_eq!(endpoint.path, "/webhook/slack");
|
||||
assert_eq!(endpoint.methods, vec!["POST"]);
|
||||
assert!(endpoint.require_secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_rate_limit_default() {
|
||||
let limit = EmitRateLimitConfig::default();
|
||||
assert_eq!(limit.messages_per_minute, 100);
|
||||
assert_eq!(limit.messages_per_hour, 5000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Error types for WASM channels.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Error during WASM channel operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WasmChannelError {
|
||||
#[error("Channel {name} failed to start: {reason}")]
|
||||
StartupFailed { name: String, reason: String },
|
||||
|
||||
#[error("Channel {name} callback failed: {reason}")]
|
||||
CallbackFailed { name: String, reason: String },
|
||||
|
||||
#[error("Channel {name} WASM execution trapped: {reason}")]
|
||||
Trapped { name: String, reason: String },
|
||||
|
||||
#[error("Channel {name} callback '{callback}' timed out")]
|
||||
Timeout { name: String, callback: String },
|
||||
|
||||
#[error("Channel {name} execution panicked: {reason}")]
|
||||
ExecutionPanicked { name: String, reason: String },
|
||||
|
||||
#[error("Channel {name} emit rate limited")]
|
||||
EmitRateLimited { name: String },
|
||||
|
||||
#[error("Channel {name} HTTP path not allowed: {path}")]
|
||||
PathNotAllowed { name: String, path: String },
|
||||
|
||||
#[error("Channel {name} polling interval too short: {interval_ms}ms (minimum: {min_ms}ms)")]
|
||||
PollIntervalTooShort {
|
||||
name: String,
|
||||
interval_ms: u32,
|
||||
min_ms: u32,
|
||||
},
|
||||
|
||||
#[error("Channel {name} workspace path escape attempt: {path}")]
|
||||
WorkspaceEscape { name: String, path: String },
|
||||
|
||||
#[error("Channel {name} exhausted fuel limit ({limit})")]
|
||||
FuelExhausted { name: String, limit: u64 },
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("WASM file not found: {0}")]
|
||||
WasmNotFound(PathBuf),
|
||||
|
||||
#[error("Capabilities file not found: {0}")]
|
||||
CapabilitiesNotFound(PathBuf),
|
||||
|
||||
#[error("Invalid capabilities JSON: {0}")]
|
||||
InvalidCapabilities(String),
|
||||
|
||||
#[error("WASM compilation error: {0}")]
|
||||
Compilation(String),
|
||||
|
||||
#[error("WASM instantiation error: {0}")]
|
||||
Instantiation(String),
|
||||
|
||||
#[error("Invalid channel name: {0}")]
|
||||
InvalidName(String),
|
||||
|
||||
#[error("Channel {name} not found")]
|
||||
NotFound { name: String },
|
||||
|
||||
#[error("Channel module missing export: {0}")]
|
||||
MissingExport(String),
|
||||
|
||||
#[error("Invalid response from WASM: {0}")]
|
||||
InvalidResponse(String),
|
||||
|
||||
#[error("Runtime not initialized")]
|
||||
RuntimeNotInitialized,
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
impl From<crate::tools::wasm::WasmError> for WasmChannelError {
|
||||
fn from(err: crate::tools::wasm::WasmError) -> Self {
|
||||
WasmChannelError::Compilation(err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
//! Host state for WASM channel execution.
|
||||
//!
|
||||
//! Extends the base tool host state with channel-specific functionality:
|
||||
//! - Message emission (queueing messages to send to the agent)
|
||||
//! - Workspace write access (scoped to channel namespace)
|
||||
//! - Rate limiting for message emission
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::channels::wasm::capabilities::{ChannelCapabilities, EmitRateLimitConfig};
|
||||
use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::tools::wasm::{HostState, LogLevel};
|
||||
|
||||
/// Maximum emitted messages per callback execution.
|
||||
const MAX_EMITS_PER_EXECUTION: usize = 100;
|
||||
|
||||
/// Maximum message content size (64 KB).
|
||||
const MAX_MESSAGE_CONTENT_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// A message emitted by a WASM channel to be sent to the agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmittedMessage {
|
||||
/// User identifier within the channel.
|
||||
pub user_id: String,
|
||||
|
||||
/// Optional user display name.
|
||||
pub user_name: Option<String>,
|
||||
|
||||
/// Message content.
|
||||
pub content: String,
|
||||
|
||||
/// Optional thread ID for threaded conversations.
|
||||
pub thread_id: Option<String>,
|
||||
|
||||
/// Channel-specific metadata as JSON string.
|
||||
pub metadata_json: String,
|
||||
|
||||
/// Timestamp when the message was emitted.
|
||||
pub emitted_at_millis: u64,
|
||||
}
|
||||
|
||||
impl EmittedMessage {
|
||||
/// Create a new emitted message.
|
||||
pub fn new(user_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user_id: user_id.into(),
|
||||
user_name: None,
|
||||
content: content.into(),
|
||||
thread_id: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
emitted_at_millis: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the user name.
|
||||
pub fn with_user_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.user_name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the thread ID.
|
||||
pub fn with_thread_id(mut self, thread_id: impl Into<String>) -> Self {
|
||||
self.thread_id = Some(thread_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set metadata JSON.
|
||||
pub fn with_metadata(mut self, metadata_json: impl Into<String>) -> Self {
|
||||
self.metadata_json = metadata_json.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending workspace write operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingWorkspaceWrite {
|
||||
/// Full path (already prefixed with channel namespace).
|
||||
pub path: String,
|
||||
|
||||
/// Content to write.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Host state for WASM channel callbacks.
|
||||
///
|
||||
/// Maintains all side effects during callback execution and enforces limits.
|
||||
/// This is the channel-specific equivalent of HostState for tools.
|
||||
pub struct ChannelHostState {
|
||||
/// Base tool host state (logging, time, HTTP, etc.).
|
||||
base: HostState,
|
||||
|
||||
/// Channel name (for error messages).
|
||||
channel_name: String,
|
||||
|
||||
/// Channel capabilities.
|
||||
capabilities: ChannelCapabilities,
|
||||
|
||||
/// Emitted messages (queued for delivery).
|
||||
emitted_messages: Vec<EmittedMessage>,
|
||||
|
||||
/// Pending workspace writes.
|
||||
pending_writes: Vec<PendingWorkspaceWrite>,
|
||||
|
||||
/// Emit count for rate limiting within this execution.
|
||||
emit_count: u32,
|
||||
|
||||
/// Whether emit is still allowed (false after rate limit hit).
|
||||
emit_enabled: bool,
|
||||
|
||||
/// Count of emits dropped due to rate limiting.
|
||||
emits_dropped: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChannelHostState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChannelHostState")
|
||||
.field("channel_name", &self.channel_name)
|
||||
.field("emitted_messages_count", &self.emitted_messages.len())
|
||||
.field("pending_writes_count", &self.pending_writes.len())
|
||||
.field("emit_count", &self.emit_count)
|
||||
.field("emit_enabled", &self.emit_enabled)
|
||||
.field("emits_dropped", &self.emits_dropped)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelHostState {
|
||||
/// Create a new channel host state.
|
||||
pub fn new(channel_name: impl Into<String>, capabilities: ChannelCapabilities) -> Self {
|
||||
let base = HostState::new(capabilities.tool_capabilities.clone());
|
||||
|
||||
Self {
|
||||
base,
|
||||
channel_name: channel_name.into(),
|
||||
capabilities,
|
||||
emitted_messages: Vec::new(),
|
||||
pending_writes: Vec::new(),
|
||||
emit_count: 0,
|
||||
emit_enabled: true,
|
||||
emits_dropped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the channel name.
|
||||
pub fn channel_name(&self) -> &str {
|
||||
&self.channel_name
|
||||
}
|
||||
|
||||
/// Get the capabilities.
|
||||
pub fn capabilities(&self) -> &ChannelCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
/// Get the base host state for tool capabilities.
|
||||
pub fn base(&self) -> &HostState {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// Get mutable access to the base host state.
|
||||
pub fn base_mut(&mut self) -> &mut HostState {
|
||||
&mut self.base
|
||||
}
|
||||
|
||||
/// Emit a message from the channel.
|
||||
///
|
||||
/// Messages are queued and delivered after callback execution completes.
|
||||
/// Rate limiting is enforced per-execution and globally.
|
||||
pub fn emit_message(&mut self, msg: EmittedMessage) -> Result<(), WasmChannelError> {
|
||||
// Check per-execution limit
|
||||
if !self.emit_enabled {
|
||||
self.emits_dropped += 1;
|
||||
return Ok(()); // Silently drop, don't fail execution
|
||||
}
|
||||
|
||||
if self.emitted_messages.len() >= MAX_EMITS_PER_EXECUTION {
|
||||
self.emit_enabled = false;
|
||||
self.emits_dropped += 1;
|
||||
tracing::warn!(
|
||||
channel = %self.channel_name,
|
||||
limit = MAX_EMITS_PER_EXECUTION,
|
||||
"Channel emit limit reached, further messages dropped"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate message content size
|
||||
if msg.content.len() > MAX_MESSAGE_CONTENT_SIZE {
|
||||
tracing::warn!(
|
||||
channel = %self.channel_name,
|
||||
size = msg.content.len(),
|
||||
max = MAX_MESSAGE_CONTENT_SIZE,
|
||||
"Message content too large, truncating"
|
||||
);
|
||||
let mut truncated = msg.content[..MAX_MESSAGE_CONTENT_SIZE].to_string();
|
||||
truncated.push_str("... (truncated)");
|
||||
let msg = EmittedMessage {
|
||||
content: truncated,
|
||||
..msg
|
||||
};
|
||||
self.emitted_messages.push(msg);
|
||||
} else {
|
||||
self.emitted_messages.push(msg);
|
||||
}
|
||||
|
||||
self.emit_count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take all emitted messages (clears the queue).
|
||||
pub fn take_emitted_messages(&mut self) -> Vec<EmittedMessage> {
|
||||
std::mem::take(&mut self.emitted_messages)
|
||||
}
|
||||
|
||||
/// Get the number of emitted messages.
|
||||
pub fn emitted_count(&self) -> usize {
|
||||
self.emitted_messages.len()
|
||||
}
|
||||
|
||||
/// Get the number of emits dropped due to rate limiting.
|
||||
pub fn emits_dropped(&self) -> usize {
|
||||
self.emits_dropped
|
||||
}
|
||||
|
||||
/// Write to workspace (scoped to channel namespace).
|
||||
///
|
||||
/// Writes are queued and committed after callback execution completes.
|
||||
pub fn workspace_write(&mut self, path: &str, content: String) -> Result<(), WasmChannelError> {
|
||||
// Validate and prefix path
|
||||
let full_path = self
|
||||
.capabilities
|
||||
.validate_workspace_path(path)
|
||||
.map_err(|reason| WasmChannelError::WorkspaceEscape {
|
||||
name: self.channel_name.clone(),
|
||||
path: reason,
|
||||
})?;
|
||||
|
||||
self.pending_writes.push(PendingWorkspaceWrite {
|
||||
path: full_path,
|
||||
content,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take all pending workspace writes (clears the queue).
|
||||
pub fn take_pending_writes(&mut self) -> Vec<PendingWorkspaceWrite> {
|
||||
std::mem::take(&mut self.pending_writes)
|
||||
}
|
||||
|
||||
/// Get the number of pending workspace writes.
|
||||
pub fn pending_writes_count(&self) -> usize {
|
||||
self.pending_writes.len()
|
||||
}
|
||||
|
||||
/// Log a message (delegates to base).
|
||||
pub fn log(
|
||||
&mut self,
|
||||
level: LogLevel,
|
||||
message: String,
|
||||
) -> Result<(), crate::tools::wasm::WasmError> {
|
||||
self.base.log(level, message)
|
||||
}
|
||||
|
||||
/// Get current timestamp in milliseconds (delegates to base).
|
||||
pub fn now_millis(&self) -> u64 {
|
||||
self.base.now_millis()
|
||||
}
|
||||
|
||||
/// Read from workspace (delegates to base).
|
||||
pub fn workspace_read(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<String>, crate::tools::wasm::WasmError> {
|
||||
// Prefix the path with channel namespace before reading
|
||||
let full_path = self.capabilities.prefix_workspace_path(path);
|
||||
self.base.workspace_read(&full_path)
|
||||
}
|
||||
|
||||
/// Check if a secret exists (delegates to base).
|
||||
pub fn secret_exists(&self, name: &str) -> bool {
|
||||
self.base.secret_exists(name)
|
||||
}
|
||||
|
||||
/// Check if HTTP is allowed (delegates to base).
|
||||
pub fn check_http_allowed(&self, url: &str, method: &str) -> Result<(), String> {
|
||||
self.base.check_http_allowed(url, method)
|
||||
}
|
||||
|
||||
/// Record an HTTP request (delegates to base).
|
||||
pub fn record_http_request(&mut self) -> Result<(), String> {
|
||||
self.base.record_http_request()
|
||||
}
|
||||
|
||||
/// Take logs (delegates to base).
|
||||
pub fn take_logs(&mut self) -> Vec<crate::tools::wasm::LogEntry> {
|
||||
self.base.take_logs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiter for channel message emission.
|
||||
///
|
||||
/// Tracks emission rates across multiple executions.
|
||||
pub struct ChannelEmitRateLimiter {
|
||||
config: EmitRateLimitConfig,
|
||||
minute_window: RateWindow,
|
||||
hour_window: RateWindow,
|
||||
}
|
||||
|
||||
struct RateWindow {
|
||||
count: u32,
|
||||
window_start: u64,
|
||||
window_duration_ms: u64,
|
||||
}
|
||||
|
||||
impl RateWindow {
|
||||
fn new(duration_ms: u64) -> Self {
|
||||
Self {
|
||||
count: 0,
|
||||
window_start: 0,
|
||||
window_duration_ms: duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_and_record(&mut self, now_ms: u64, limit: u32) -> bool {
|
||||
// Reset window if expired
|
||||
if now_ms.saturating_sub(self.window_start) > self.window_duration_ms {
|
||||
self.count = 0;
|
||||
self.window_start = now_ms;
|
||||
}
|
||||
|
||||
if self.count >= limit {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.count += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ChannelEmitRateLimiter {
|
||||
/// Create a new rate limiter with the given config.
|
||||
pub fn new(config: EmitRateLimitConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
minute_window: RateWindow::new(60_000), // 1 minute
|
||||
hour_window: RateWindow::new(3_600_000), // 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an emit is allowed and record it if so.
|
||||
///
|
||||
/// Returns true if the emit is allowed, false if rate limited.
|
||||
pub fn check_and_record(&mut self) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Check both windows
|
||||
let minute_ok = self
|
||||
.minute_window
|
||||
.check_and_record(now, self.config.messages_per_minute);
|
||||
let hour_ok = self
|
||||
.hour_window
|
||||
.check_and_record(now, self.config.messages_per_hour);
|
||||
|
||||
minute_ok && hour_ok
|
||||
}
|
||||
|
||||
/// Get the current emission count for the minute window.
|
||||
pub fn minute_count(&self) -> u32 {
|
||||
self.minute_window.count
|
||||
}
|
||||
|
||||
/// Get the current emission count for the hour window.
|
||||
pub fn hour_count(&self) -> u32 {
|
||||
self.hour_window.count
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::channels::wasm::capabilities::{ChannelCapabilities, EmitRateLimitConfig};
|
||||
use crate::channels::wasm::host::{
|
||||
ChannelEmitRateLimiter, ChannelHostState, EmittedMessage, MAX_EMITS_PER_EXECUTION,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_emit_message_basic() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
let msg = EmittedMessage::new("user123", "Hello, world!");
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
assert_eq!(state.emitted_count(), 1);
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].user_id, "user123");
|
||||
assert_eq!(messages[0].content, "Hello, world!");
|
||||
|
||||
// Queue should be cleared
|
||||
assert_eq!(state.emitted_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_message_with_metadata() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
let msg = EmittedMessage::new("user123", "Hello")
|
||||
.with_user_name("John Doe")
|
||||
.with_thread_id("thread-1")
|
||||
.with_metadata(r#"{"key": "value"}"#);
|
||||
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
assert_eq!(messages[0].user_name, Some("John Doe".to_string()));
|
||||
assert_eq!(messages[0].thread_id, Some("thread-1".to_string()));
|
||||
assert_eq!(messages[0].metadata_json, r#"{"key": "value"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_per_execution_limit() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
// Fill up to limit
|
||||
for i in 0..MAX_EMITS_PER_EXECUTION {
|
||||
let msg = EmittedMessage::new("user", format!("Message {}", i));
|
||||
state.emit_message(msg).unwrap();
|
||||
}
|
||||
|
||||
// This should be dropped silently
|
||||
let msg = EmittedMessage::new("user", "Should be dropped");
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
assert_eq!(state.emitted_count(), MAX_EMITS_PER_EXECUTION);
|
||||
assert_eq!(state.emits_dropped(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_write_prefixing() {
|
||||
let caps = ChannelCapabilities::for_channel("slack");
|
||||
let mut state = ChannelHostState::new("slack", caps);
|
||||
|
||||
state
|
||||
.workspace_write("state.json", "{}".to_string())
|
||||
.unwrap();
|
||||
|
||||
let writes = state.take_pending_writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert_eq!(writes[0].path, "channels/slack/state.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_write_path_traversal_blocked() {
|
||||
let caps = ChannelCapabilities::for_channel("slack");
|
||||
let mut state = ChannelHostState::new("slack", caps);
|
||||
|
||||
// Try to escape namespace
|
||||
let result = state.workspace_write("../secrets.json", "{}".to_string());
|
||||
assert!(result.is_err());
|
||||
|
||||
// Absolute path
|
||||
let result = state.workspace_write("/etc/passwd", "{}".to_string());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter_basic() {
|
||||
let config = EmitRateLimitConfig {
|
||||
messages_per_minute: 10,
|
||||
messages_per_hour: 100,
|
||||
};
|
||||
let mut limiter = ChannelEmitRateLimiter::new(config);
|
||||
|
||||
// Should allow 10 messages
|
||||
for _ in 0..10 {
|
||||
assert!(limiter.check_and_record());
|
||||
}
|
||||
|
||||
// 11th should be blocked
|
||||
assert!(!limiter.check_and_record());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_name() {
|
||||
let caps = ChannelCapabilities::for_channel("telegram");
|
||||
let state = ChannelHostState::new("telegram", caps);
|
||||
|
||||
assert_eq!(state.channel_name(), "telegram");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! WASM channel loader for loading channels from files or directories.
|
||||
//!
|
||||
//! Loads WASM channel modules from the filesystem (default: ~/.near-agent/channels/).
|
||||
//! Each channel consists of:
|
||||
//! - `<name>.wasm` - The compiled WASM component
|
||||
//! - `<name>.capabilities.json` - Channel capabilities and configuration
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
||||
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
|
||||
/// Loads WASM channels from the filesystem.
|
||||
pub struct WasmChannelLoader {
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
}
|
||||
|
||||
impl WasmChannelLoader {
|
||||
/// Create a new loader with the given runtime.
|
||||
pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
|
||||
/// Load a single WASM channel from a file pair.
|
||||
///
|
||||
/// Expects:
|
||||
/// - `wasm_path`: Path to the `.wasm` file
|
||||
/// - `capabilities_path`: Path to the `.capabilities.json` file (optional)
|
||||
///
|
||||
/// If no capabilities file is provided, the channel gets minimal capabilities.
|
||||
pub async fn load_from_files(
|
||||
&self,
|
||||
name: &str,
|
||||
wasm_path: &Path,
|
||||
capabilities_path: Option<&Path>,
|
||||
) -> Result<WasmChannel, WasmChannelError> {
|
||||
// Validate name
|
||||
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||
return Err(WasmChannelError::InvalidName(name.to_string()));
|
||||
}
|
||||
|
||||
// Read WASM bytes
|
||||
if !wasm_path.exists() {
|
||||
return Err(WasmChannelError::WasmNotFound(wasm_path.to_path_buf()));
|
||||
}
|
||||
let wasm_bytes = fs::read(wasm_path).await?;
|
||||
|
||||
// Read capabilities file
|
||||
let (capabilities, config_json, description) = if let Some(cap_path) = capabilities_path {
|
||||
if cap_path.exists() {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?;
|
||||
|
||||
let caps = cap_file.to_capabilities();
|
||||
let config = cap_file.config_json();
|
||||
let desc = cap_file.description.clone();
|
||||
|
||||
(caps, config, desc)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file not found, using defaults"
|
||||
);
|
||||
(
|
||||
ChannelCapabilities::for_channel(name),
|
||||
"{}".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(
|
||||
ChannelCapabilities::for_channel(name),
|
||||
"{}".to_string(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// Prepare the module
|
||||
let prepared = self
|
||||
.runtime
|
||||
.prepare(name, &wasm_bytes, None, description)
|
||||
.await?;
|
||||
|
||||
// Create the channel
|
||||
let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json);
|
||||
|
||||
tracing::info!(
|
||||
name = name,
|
||||
wasm_path = %wasm_path.display(),
|
||||
"Loaded WASM channel from file"
|
||||
);
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
/// Load all WASM channels from a directory.
|
||||
///
|
||||
/// Scans the directory for `*.wasm` files and loads each one, looking for
|
||||
/// a matching `*.capabilities.json` sidecar file.
|
||||
///
|
||||
/// # Directory Layout
|
||||
///
|
||||
/// ```text
|
||||
/// channels/
|
||||
/// ├── slack.wasm <- Channel WASM component
|
||||
/// ├── slack.capabilities.json <- Capabilities (optional)
|
||||
/// ├── telegram.wasm
|
||||
/// └── telegram.capabilities.json
|
||||
/// ```
|
||||
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> {
|
||||
if !dir.is_dir() {
|
||||
return Err(WasmChannelError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotADirectory,
|
||||
format!("{} is not a directory", dir.display()),
|
||||
)));
|
||||
}
|
||||
|
||||
let mut results = LoadResults::default();
|
||||
let mut entries = fs::read_dir(dir).await?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
|
||||
// Only process .wasm files
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract channel name from filename
|
||||
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
results.errors.push((
|
||||
path.clone(),
|
||||
WasmChannelError::InvalidName("invalid filename".to_string()),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Look for sidecar capabilities file
|
||||
let cap_path = path.with_extension("capabilities.json");
|
||||
let cap_path_option = if cap_path.exists() {
|
||||
Some(cap_path.as_path())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match self.load_from_files(&name, &path, cap_path_option).await {
|
||||
Ok(channel) => {
|
||||
results.loaded.push(channel);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
name = name,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"Failed to load WASM channel"
|
||||
);
|
||||
results.errors.push((path, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !results.loaded.is_empty() {
|
||||
tracing::info!(
|
||||
count = results.loaded.len(),
|
||||
channels = ?results.loaded.iter().map(|c| c.channel_name()).collect::<Vec<_>>(),
|
||||
"Loaded WASM channels from directory"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Results from loading multiple channels.
|
||||
#[derive(Default)]
|
||||
pub struct LoadResults {
|
||||
/// Successfully loaded channels.
|
||||
pub loaded: Vec<WasmChannel>,
|
||||
|
||||
/// Errors encountered (path, error).
|
||||
pub errors: Vec<(PathBuf, WasmChannelError)>,
|
||||
}
|
||||
|
||||
impl LoadResults {
|
||||
/// Check if all channels loaded successfully.
|
||||
pub fn all_succeeded(&self) -> bool {
|
||||
self.errors.is_empty()
|
||||
}
|
||||
|
||||
/// Get the count of successfully loaded channels.
|
||||
pub fn success_count(&self) -> usize {
|
||||
self.loaded.len()
|
||||
}
|
||||
|
||||
/// Get the count of failed channels.
|
||||
pub fn error_count(&self) -> usize {
|
||||
self.errors.len()
|
||||
}
|
||||
|
||||
/// Take ownership of loaded channels.
|
||||
pub fn take_channels(self) -> Vec<WasmChannel> {
|
||||
self.loaded
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover WASM channel files in a directory without loading them.
|
||||
///
|
||||
/// Returns a map of channel name -> (wasm_path, capabilities_path).
|
||||
#[allow(dead_code)]
|
||||
pub async fn discover_channels(
|
||||
dir: &Path,
|
||||
) -> Result<HashMap<String, DiscoveredChannel>, std::io::Error> {
|
||||
let mut channels = HashMap::new();
|
||||
|
||||
if !dir.is_dir() {
|
||||
return Ok(channels);
|
||||
}
|
||||
|
||||
let mut entries = fs::read_dir(dir).await?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let cap_path = path.with_extension("capabilities.json");
|
||||
|
||||
channels.insert(
|
||||
name,
|
||||
DiscoveredChannel {
|
||||
wasm_path: path,
|
||||
capabilities_path: if cap_path.exists() {
|
||||
Some(cap_path)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
/// A discovered WASM channel (not yet loaded).
|
||||
#[derive(Debug)]
|
||||
pub struct DiscoveredChannel {
|
||||
/// Path to the WASM file.
|
||||
pub wasm_path: PathBuf,
|
||||
|
||||
/// Path to the capabilities file (if present).
|
||||
pub capabilities_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Get the default channels directory path.
|
||||
///
|
||||
/// Returns ~/.near-agent/channels/
|
||||
#[allow(dead_code)]
|
||||
pub fn default_channels_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".near-agent")
|
||||
.join("channels")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
|
||||
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_channels_empty_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let channels = discover_channels(dir.path()).await.unwrap();
|
||||
assert!(channels.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_channels_with_wasm() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a fake .wasm file
|
||||
let wasm_path = dir.path().join("slack.wasm");
|
||||
std::fs::File::create(&wasm_path).unwrap();
|
||||
|
||||
let channels = discover_channels(dir.path()).await.unwrap();
|
||||
assert_eq!(channels.len(), 1);
|
||||
assert!(channels.contains_key("slack"));
|
||||
assert!(channels["slack"].capabilities_path.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_channels_with_capabilities() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Create wasm and capabilities files
|
||||
std::fs::File::create(dir.path().join("telegram.wasm")).unwrap();
|
||||
let mut cap_file =
|
||||
std::fs::File::create(dir.path().join("telegram.capabilities.json")).unwrap();
|
||||
cap_file.write_all(b"{}").unwrap();
|
||||
|
||||
let channels = discover_channels(dir.path()).await.unwrap();
|
||||
assert_eq!(channels.len(), 1);
|
||||
assert!(channels["telegram"].capabilities_path.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_channels_ignores_non_wasm() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Create non-wasm files
|
||||
std::fs::File::create(dir.path().join("readme.md")).unwrap();
|
||||
std::fs::File::create(dir.path().join("config.json")).unwrap();
|
||||
std::fs::File::create(dir.path().join("channel.wasm")).unwrap();
|
||||
|
||||
let channels = discover_channels(dir.path()).await.unwrap();
|
||||
assert_eq!(channels.len(), 1);
|
||||
assert!(channels.contains_key("channel"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_loader_invalid_name() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||
let loader = WasmChannelLoader::new(runtime);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let wasm_path = dir.path().join("test.wasm");
|
||||
|
||||
// Invalid name with path separator
|
||||
let result = loader.load_from_files("../escape", &wasm_path, None).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Empty name
|
||||
let result = loader.load_from_files("", &wasm_path, None).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! WASM-extensible channel system.
|
||||
//!
|
||||
//! This module provides a runtime for executing WASM-based channels using a
|
||||
//! Host-Managed Event Loop pattern. The host (Rust) manages infrastructure
|
||||
//! (HTTP server, polling), while WASM modules define channel behavior through
|
||||
//! callbacks.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
//! │ Host-Managed Event Loop │
|
||||
//! │ │
|
||||
//! │ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
//! │ │ HTTP │ │ Polling │ │ Timer │ │
|
||||
//! │ │ Router │ │ Scheduler │ │ Scheduler │ │
|
||||
//! │ └──────┬──────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
//! │ │ │ │ │
|
||||
//! │ └───────────────────┴────────────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ ┌─────────────────┐ │
|
||||
//! │ │ Event Router │ │
|
||||
//! │ └────────┬────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ┌──────────────────┼──────────────────┐ │
|
||||
//! │ ▼ ▼ ▼ │
|
||||
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
//! │ │ on_http_req │ │ on_poll │ │ on_respond │ WASM Exports │
|
||||
//! │ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
//! │ │ │ │ │
|
||||
//! │ └──────────────────┴──────────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ ┌─────────────────┐ │
|
||||
//! │ │ Host Imports │ │
|
||||
//! │ │ emit_message │──────────▶ MessageStream │
|
||||
//! │ │ http_request │ │
|
||||
//! │ │ log, etc. │ │
|
||||
//! │ └─────────────────┘ │
|
||||
//! └─────────────────────────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # Key Design Decisions
|
||||
//!
|
||||
//! 1. **Fresh Instance Per Callback** (NEAR Pattern) - Full isolation, no shared mutable state
|
||||
//! 2. **Host Manages Infrastructure** - HTTP server, polling, timing in Rust
|
||||
//! 3. **WASM Defines Behavior** - Callbacks for events, message parsing, response handling
|
||||
//! 4. **Reuse Tool Runtime** - Share Wasmtime engine, extend capabilities
|
||||
//!
|
||||
//! # Security Model
|
||||
//!
|
||||
//! | Threat | Mitigation |
|
||||
//! |--------|------------|
|
||||
//! | Path hijacking | `allowed_paths` restricts registrable endpoints |
|
||||
//! | Token exposure | Injected at host boundary, WASM never sees |
|
||||
//! | State pollution | Fresh instance per callback |
|
||||
//! | Workspace escape | Paths prefixed with `channels/<name>/` |
|
||||
//! | Message spam | Rate limiting on `emit_message` |
|
||||
//! | Resource exhaustion | Fuel metering, memory limits, callback timeout |
|
||||
//! | Polling abuse | Minimum 30s interval enforced |
|
||||
//!
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use near_agent::channels::wasm::{WasmChannelLoader, WasmChannelRuntime};
|
||||
//!
|
||||
//! // Create runtime (can share engine with tool runtime)
|
||||
//! let runtime = WasmChannelRuntime::new(config)?;
|
||||
//!
|
||||
//! // Load channels from directory
|
||||
//! let loader = WasmChannelLoader::new(runtime);
|
||||
//! let channels = loader.load_from_dir(Path::new("~/.near-agent/channels/")).await?;
|
||||
//!
|
||||
//! // Add to channel manager
|
||||
//! for channel in channels {
|
||||
//! manager.add(Box::new(channel));
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod capabilities;
|
||||
mod error;
|
||||
mod host;
|
||||
mod loader;
|
||||
mod router;
|
||||
mod runtime;
|
||||
mod schema;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
|
||||
pub use error::WasmChannelError;
|
||||
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
||||
pub use loader::{
|
||||
DiscoveredChannel, LoadResults, WasmChannelLoader, default_channels_dir, discover_channels,
|
||||
};
|
||||
pub use router::{
|
||||
RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router,
|
||||
};
|
||||
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
pub use schema::{ChannelCapabilitiesFile, ChannelConfig};
|
||||
pub use wrapper::{HttpResponse, WasmChannel};
|
||||
@@ -0,0 +1,480 @@
|
||||
//! HTTP router for WASM channel webhooks.
|
||||
//!
|
||||
//! Routes incoming HTTP requests to the appropriate WASM channel based on
|
||||
//! registered paths. Handles secret validation at the host level.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Bytes,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
|
||||
/// A registered HTTP endpoint for a WASM channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegisteredEndpoint {
|
||||
/// Channel name that owns this endpoint.
|
||||
pub channel_name: String,
|
||||
/// HTTP path (e.g., "/webhook/slack").
|
||||
pub path: String,
|
||||
/// Allowed HTTP methods.
|
||||
pub methods: Vec<String>,
|
||||
/// Whether secret validation is required.
|
||||
pub require_secret: bool,
|
||||
}
|
||||
|
||||
/// Router for WASM channel HTTP endpoints.
|
||||
pub struct WasmChannelRouter {
|
||||
/// Registered channels by name.
|
||||
channels: RwLock<HashMap<String, Arc<WasmChannel>>>,
|
||||
/// Path to channel mapping for fast lookup.
|
||||
path_to_channel: RwLock<HashMap<String, String>>,
|
||||
/// Expected webhook secrets by channel name.
|
||||
secrets: RwLock<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl WasmChannelRouter {
|
||||
/// Create a new router.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
path_to_channel: RwLock::new(HashMap::new()),
|
||||
secrets: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a channel with its endpoints.
|
||||
pub async fn register(
|
||||
&self,
|
||||
channel: Arc<WasmChannel>,
|
||||
endpoints: Vec<RegisteredEndpoint>,
|
||||
secret: Option<String>,
|
||||
) {
|
||||
let name = channel.channel_name().to_string();
|
||||
|
||||
// Store the channel
|
||||
self.channels.write().await.insert(name.clone(), channel);
|
||||
|
||||
// Register path mappings
|
||||
let mut path_map = self.path_to_channel.write().await;
|
||||
for endpoint in endpoints {
|
||||
path_map.insert(endpoint.path.clone(), name.clone());
|
||||
tracing::info!(
|
||||
channel = %name,
|
||||
path = %endpoint.path,
|
||||
methods = ?endpoint.methods,
|
||||
"Registered WASM channel HTTP endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
// Store secret if provided
|
||||
if let Some(s) = secret {
|
||||
self.secrets.write().await.insert(name, s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unregister a channel and its endpoints.
|
||||
pub async fn unregister(&self, channel_name: &str) {
|
||||
self.channels.write().await.remove(channel_name);
|
||||
self.secrets.write().await.remove(channel_name);
|
||||
|
||||
// Remove all paths for this channel
|
||||
self.path_to_channel
|
||||
.write()
|
||||
.await
|
||||
.retain(|_, name| name != channel_name);
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
"Unregistered WASM channel"
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the channel for a given path.
|
||||
pub async fn get_channel_for_path(&self, path: &str) -> Option<Arc<WasmChannel>> {
|
||||
let path_map = self.path_to_channel.read().await;
|
||||
let channel_name = path_map.get(path)?;
|
||||
|
||||
self.channels.read().await.get(channel_name).cloned()
|
||||
}
|
||||
|
||||
/// Validate a secret for a channel.
|
||||
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
|
||||
let secrets = self.secrets.read().await;
|
||||
match secrets.get(channel_name) {
|
||||
Some(expected) => expected == provided,
|
||||
None => true, // No secret required
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a channel requires a secret.
|
||||
pub async fn requires_secret(&self, channel_name: &str) -> bool {
|
||||
self.secrets.read().await.contains_key(channel_name)
|
||||
}
|
||||
|
||||
/// List all registered channels.
|
||||
pub async fn list_channels(&self) -> Vec<String> {
|
||||
self.channels.read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// List all registered paths.
|
||||
pub async fn list_paths(&self) -> Vec<String> {
|
||||
self.path_to_channel.read().await.keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WasmChannelRouter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state for the HTTP server.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct RouterState {
|
||||
router: Arc<WasmChannelRouter>,
|
||||
}
|
||||
|
||||
impl RouterState {
|
||||
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
|
||||
Self { router }
|
||||
}
|
||||
}
|
||||
|
||||
/// Webhook request body for WASM channels.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WasmWebhookRequest {
|
||||
/// Optional secret for authentication.
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Health response.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
channels: Vec<String>,
|
||||
}
|
||||
|
||||
/// Handler for health check endpoint.
|
||||
#[allow(dead_code)]
|
||||
async fn health_handler(State(state): State<RouterState>) -> impl IntoResponse {
|
||||
let channels = state.router.list_channels().await;
|
||||
Json(HealthResponse {
|
||||
status: "healthy".to_string(),
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generic webhook handler that routes to the appropriate WASM channel.
|
||||
#[allow(dead_code)]
|
||||
async fn webhook_handler(
|
||||
State(state): State<RouterState>,
|
||||
method: Method,
|
||||
Path(path): Path<String>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let full_path = format!("/webhook/{}", path);
|
||||
|
||||
// Find the channel for this path
|
||||
let channel = match state.router.get_channel_for_path(&full_path).await {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "Channel not found for path",
|
||||
"path": full_path
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let channel_name = channel.channel_name();
|
||||
|
||||
// Check if secret is required
|
||||
if state.router.requires_secret(channel_name).await {
|
||||
// Try to get secret from query param or header
|
||||
let provided_secret = query.get("secret").cloned().or_else(|| {
|
||||
headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
match provided_secret {
|
||||
Some(secret) => {
|
||||
if !state.router.validate_secret(channel_name, &secret).await {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "Invalid webhook secret"
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "Webhook secret required"
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert headers to HashMap
|
||||
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();
|
||||
|
||||
// Call the WASM channel
|
||||
let secret_validated = state.router.requires_secret(channel_name).await;
|
||||
match channel
|
||||
.call_on_http_request(
|
||||
method.as_str(),
|
||||
&full_path,
|
||||
&headers_map,
|
||||
&query,
|
||||
&body,
|
||||
secret_validated,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let status =
|
||||
StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
// Build response with headers
|
||||
let body_json: serde_json::Value = serde_json::from_slice(&response.body)
|
||||
.unwrap_or_else(|_| {
|
||||
serde_json::json!({
|
||||
"raw": String::from_utf8_lossy(&response.body).to_string()
|
||||
})
|
||||
});
|
||||
|
||||
(status, Json(body_json))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
channel = %channel_name,
|
||||
error = %e,
|
||||
"WASM channel callback failed"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Channel callback failed",
|
||||
"details": e.to_string()
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an Axum router for WASM channel webhooks.
|
||||
///
|
||||
/// This router can be merged with the existing HTTP channel router.
|
||||
#[allow(dead_code)]
|
||||
pub fn create_wasm_channel_router(router: Arc<WasmChannelRouter>) -> Router {
|
||||
let state = RouterState::new(router);
|
||||
|
||||
Router::new()
|
||||
.route("/wasm-channels/health", get(health_handler))
|
||||
// Catch-all for webhook paths
|
||||
.route("/webhook/*path", get(webhook_handler))
|
||||
.route("/webhook/*path", post(webhook_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// HTTP server for WASM channel webhooks.
|
||||
#[allow(dead_code)]
|
||||
pub struct WasmChannelServer {
|
||||
router: Arc<WasmChannelRouter>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl WasmChannelServer {
|
||||
/// Create a new server.
|
||||
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
|
||||
Self { router }
|
||||
}
|
||||
|
||||
/// Start the HTTP server.
|
||||
///
|
||||
/// Returns a handle that can be used to shut down the server.
|
||||
pub async fn start(
|
||||
&self,
|
||||
addr: SocketAddr,
|
||||
) -> Result<tokio::task::JoinHandle<()>, std::io::Error> {
|
||||
let app = create_wasm_channel_router(self.router.clone());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
tracing::info!(
|
||||
addr = %addr,
|
||||
"WASM channel HTTP server started"
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!("WASM channel HTTP server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::router::{RegisteredEndpoint, WasmChannelRouter};
|
||||
use crate::channels::wasm::runtime::{
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
};
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
use crate::tools::wasm::ResourceLimits;
|
||||
|
||||
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||
|
||||
let prepared = Arc::new(PreparedChannelModule {
|
||||
name: name.to_string(),
|
||||
description: format!("Test channel: {}", name),
|
||||
component_bytes: Vec::new(),
|
||||
limits: ResourceLimits::default(),
|
||||
});
|
||||
|
||||
let capabilities =
|
||||
ChannelCapabilities::for_channel(name).with_path(format!("/webhook/{}", name));
|
||||
|
||||
Arc::new(WasmChannel::new(
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_router_register_and_lookup() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("slack");
|
||||
|
||||
let endpoints = vec![RegisteredEndpoint {
|
||||
channel_name: "slack".to_string(),
|
||||
path: "/webhook/slack".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}];
|
||||
|
||||
router
|
||||
.register(channel, endpoints, Some("secret123".to_string()))
|
||||
.await;
|
||||
|
||||
// Should find channel by path
|
||||
let found = router.get_channel_for_path("/webhook/slack").await;
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().channel_name(), "slack");
|
||||
|
||||
// Should not find non-existent path
|
||||
let not_found = router.get_channel_for_path("/webhook/telegram").await;
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_router_secret_validation() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("slack");
|
||||
|
||||
router
|
||||
.register(channel, vec![], Some("secret123".to_string()))
|
||||
.await;
|
||||
|
||||
// Correct secret
|
||||
assert!(router.validate_secret("slack", "secret123").await);
|
||||
|
||||
// Wrong secret
|
||||
assert!(!router.validate_secret("slack", "wrong").await);
|
||||
|
||||
// Channel without secret always validates
|
||||
let channel2 = create_test_channel("telegram");
|
||||
router.register(channel2, vec![], None).await;
|
||||
assert!(router.validate_secret("telegram", "anything").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_router_unregister() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("slack");
|
||||
|
||||
let endpoints = vec![RegisteredEndpoint {
|
||||
channel_name: "slack".to_string(),
|
||||
path: "/webhook/slack".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: false,
|
||||
}];
|
||||
|
||||
router.register(channel, endpoints, None).await;
|
||||
|
||||
// Should exist
|
||||
assert!(
|
||||
router
|
||||
.get_channel_for_path("/webhook/slack")
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// Unregister
|
||||
router.unregister("slack").await;
|
||||
|
||||
// Should no longer exist
|
||||
assert!(
|
||||
router
|
||||
.get_channel_for_path("/webhook/slack")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_router_list_channels() {
|
||||
let router = WasmChannelRouter::new();
|
||||
|
||||
let channel1 = create_test_channel("slack");
|
||||
let channel2 = create_test_channel("telegram");
|
||||
|
||||
router.register(channel1, vec![], None).await;
|
||||
router.register(channel2, vec![], None).await;
|
||||
|
||||
let channels = router.list_channels().await;
|
||||
assert_eq!(channels.len(), 2);
|
||||
assert!(channels.contains(&"slack".to_string()));
|
||||
assert!(channels.contains(&"telegram".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! WASM channel runtime for managing compiled channel components.
|
||||
//!
|
||||
//! Similar to tool runtime, follows the principle: compile once at registration,
|
||||
//! instantiate fresh per callback execution.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use wasmtime::{Config, Engine, OptLevel};
|
||||
|
||||
use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::tools::wasm::{FuelConfig, ResourceLimits};
|
||||
|
||||
/// Configuration for the WASM channel runtime.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WasmChannelRuntimeConfig {
|
||||
/// Default resource limits for channels.
|
||||
pub default_limits: ResourceLimits,
|
||||
/// Fuel configuration.
|
||||
pub fuel_config: FuelConfig,
|
||||
/// Whether to cache compiled modules.
|
||||
pub cache_compiled: bool,
|
||||
/// Directory for compiled module cache.
|
||||
pub cache_dir: Option<PathBuf>,
|
||||
/// Cranelift optimization level.
|
||||
pub optimization_level: OptLevel,
|
||||
/// Default callback timeout.
|
||||
pub callback_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for WasmChannelRuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_limits: ResourceLimits {
|
||||
// Channels may need more memory for message buffering
|
||||
memory_bytes: 50 * 1024 * 1024, // 50 MB
|
||||
fuel: 10_000_000,
|
||||
timeout: Duration::from_secs(60),
|
||||
},
|
||||
fuel_config: FuelConfig::default(),
|
||||
cache_compiled: true,
|
||||
cache_dir: None,
|
||||
optimization_level: OptLevel::Speed,
|
||||
callback_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmChannelRuntimeConfig {
|
||||
/// Create a minimal config for testing.
|
||||
pub fn for_testing() -> Self {
|
||||
Self {
|
||||
default_limits: ResourceLimits {
|
||||
memory_bytes: 5 * 1024 * 1024, // 5 MB
|
||||
fuel: 1_000_000,
|
||||
timeout: Duration::from_secs(5),
|
||||
},
|
||||
fuel_config: FuelConfig::with_limit(1_000_000),
|
||||
cache_compiled: false,
|
||||
cache_dir: None,
|
||||
optimization_level: OptLevel::None, // Faster compilation for tests
|
||||
callback_timeout: Duration::from_secs(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A compiled WASM channel component ready for instantiation.
|
||||
#[derive(Debug)]
|
||||
pub struct PreparedChannelModule {
|
||||
/// Channel name.
|
||||
pub name: String,
|
||||
/// Channel description.
|
||||
pub description: String,
|
||||
/// Compiled component bytes (public for testing, otherwise use component_bytes()).
|
||||
pub(crate) component_bytes: Vec<u8>,
|
||||
/// Resource limits for this channel.
|
||||
pub limits: ResourceLimits,
|
||||
}
|
||||
|
||||
impl PreparedChannelModule {
|
||||
/// Get the compiled component bytes.
|
||||
pub fn component_bytes(&self) -> &[u8] {
|
||||
&self.component_bytes
|
||||
}
|
||||
|
||||
/// Create a PreparedChannelModule for testing purposes.
|
||||
///
|
||||
/// Creates a module with no actual WASM bytes, suitable for testing
|
||||
/// channel infrastructure without requiring a real WASM component.
|
||||
pub fn for_testing(name: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
component_bytes: Vec::new(),
|
||||
limits: ResourceLimits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM channel runtime.
|
||||
///
|
||||
/// Manages the Wasmtime engine and a cache of prepared channel modules.
|
||||
pub struct WasmChannelRuntime {
|
||||
/// Wasmtime engine with configured settings.
|
||||
engine: Engine,
|
||||
/// Runtime configuration.
|
||||
config: WasmChannelRuntimeConfig,
|
||||
/// Cache of prepared modules by name.
|
||||
modules: RwLock<HashMap<String, Arc<PreparedChannelModule>>>,
|
||||
}
|
||||
|
||||
impl WasmChannelRuntime {
|
||||
/// Create a new runtime with the given configuration.
|
||||
pub fn new(config: WasmChannelRuntimeConfig) -> Result<Self, WasmChannelError> {
|
||||
let mut wasmtime_config = Config::new();
|
||||
|
||||
// Enable fuel consumption for CPU limiting
|
||||
if config.fuel_config.enabled {
|
||||
wasmtime_config.consume_fuel(true);
|
||||
}
|
||||
|
||||
// Enable epoch interruption as a backup timeout mechanism
|
||||
wasmtime_config.epoch_interruption(true);
|
||||
|
||||
// Enable component model (WASI Preview 2)
|
||||
wasmtime_config.wasm_component_model(true);
|
||||
|
||||
// Disable threads (simplifies security model)
|
||||
wasmtime_config.wasm_threads(false);
|
||||
|
||||
// Set optimization level
|
||||
wasmtime_config.cranelift_opt_level(config.optimization_level);
|
||||
|
||||
// Disable debug info in production
|
||||
wasmtime_config.debug_info(false);
|
||||
|
||||
let engine = Engine::new(&wasmtime_config).map_err(|e| {
|
||||
WasmChannelError::Config(format!("Failed to create Wasmtime engine: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
engine,
|
||||
config,
|
||||
modules: RwLock::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the Wasmtime engine.
|
||||
pub fn engine(&self) -> &Engine {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// Get the runtime configuration.
|
||||
pub fn config(&self) -> &WasmChannelRuntimeConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Prepare a WASM channel component for execution.
|
||||
///
|
||||
/// This validates and compiles the component.
|
||||
/// The compiled component is cached for fast instantiation.
|
||||
pub async fn prepare(
|
||||
&self,
|
||||
name: &str,
|
||||
wasm_bytes: &[u8],
|
||||
limits: Option<ResourceLimits>,
|
||||
description: Option<String>,
|
||||
) -> Result<Arc<PreparedChannelModule>, WasmChannelError> {
|
||||
// Check if already prepared
|
||||
if let Some(module) = self.modules.read().await.get(name) {
|
||||
return Ok(Arc::clone(module));
|
||||
}
|
||||
|
||||
let name = name.to_string();
|
||||
let wasm_bytes = wasm_bytes.to_vec();
|
||||
let engine = self.engine.clone();
|
||||
let default_limits = self.config.default_limits.clone();
|
||||
let desc = description.unwrap_or_else(|| format!("WASM channel: {}", name));
|
||||
|
||||
// Compile in blocking task (Wasmtime compilation is synchronous)
|
||||
let prepared = tokio::task::spawn_blocking(move || {
|
||||
// Validate and compile the component
|
||||
let _component = wasmtime::component::Component::new(&engine, &wasm_bytes)
|
||||
.map_err(|e| WasmChannelError::Compilation(e.to_string()))?;
|
||||
|
||||
Ok::<_, WasmChannelError>(PreparedChannelModule {
|
||||
name: name.clone(),
|
||||
description: desc,
|
||||
component_bytes: wasm_bytes,
|
||||
limits: limits.unwrap_or(default_limits),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WasmChannelError::Compilation(format!("Preparation task panicked: {}", e))
|
||||
})??;
|
||||
|
||||
let prepared = Arc::new(prepared);
|
||||
|
||||
// Cache the prepared module
|
||||
if self.config.cache_compiled {
|
||||
self.modules
|
||||
.write()
|
||||
.await
|
||||
.insert(prepared.name.clone(), Arc::clone(&prepared));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
name = %prepared.name,
|
||||
"Prepared WASM channel for execution"
|
||||
);
|
||||
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
/// Get a prepared module by name.
|
||||
pub async fn get(&self, name: &str) -> Option<Arc<PreparedChannelModule>> {
|
||||
self.modules.read().await.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Remove a prepared module from the cache.
|
||||
pub async fn remove(&self, name: &str) -> Option<Arc<PreparedChannelModule>> {
|
||||
self.modules.write().await.remove(name)
|
||||
}
|
||||
|
||||
/// List all prepared module names.
|
||||
pub async fn list(&self) -> Vec<String> {
|
||||
self.modules.read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Clear all cached modules.
|
||||
pub async fn clear(&self) {
|
||||
self.modules.write().await.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WasmChannelRuntime {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WasmChannelRuntime")
|
||||
.field("config", &self.config)
|
||||
.field("modules", &"<RwLock<HashMap>>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_default() {
|
||||
let config = WasmChannelRuntimeConfig::default();
|
||||
assert!(config.cache_compiled);
|
||||
assert!(config.fuel_config.enabled);
|
||||
// Channels get more memory than tools
|
||||
assert_eq!(config.default_limits.memory_bytes, 50 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_for_testing() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
assert!(!config.cache_compiled);
|
||||
assert_eq!(config.default_limits.memory_bytes, 5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_creation() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = WasmChannelRuntime::new(config).unwrap();
|
||||
assert!(runtime.config().fuel_config.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_module_cache_operations() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = WasmChannelRuntime::new(config).unwrap();
|
||||
|
||||
// Initially empty
|
||||
assert!(runtime.list().await.is_empty());
|
||||
assert!(runtime.get("test").await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! JSON schema for WASM channel capabilities files.
|
||||
//!
|
||||
//! External WASM channels declare their required capabilities via a sidecar JSON file
|
||||
//! (e.g., `slack.capabilities.json`). This module defines the schema for those files
|
||||
//! and provides conversion to runtime [`ChannelCapabilities`].
|
||||
//!
|
||||
//! # Example Capabilities File
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "type": "channel",
|
||||
//! "name": "slack",
|
||||
//! "description": "Slack Events API channel",
|
||||
//! "capabilities": {
|
||||
//! "http": {
|
||||
//! "allowlist": [
|
||||
//! { "host": "slack.com", "path_prefix": "/api/" }
|
||||
//! ],
|
||||
//! "credentials": {
|
||||
//! "slack_bot": {
|
||||
//! "secret_name": "slack_bot_token",
|
||||
//! "location": { "type": "bearer" },
|
||||
//! "host_patterns": ["slack.com"]
|
||||
//! }
|
||||
//! }
|
||||
//! },
|
||||
//! "secrets": { "allowed_names": ["slack_*"] },
|
||||
//! "channel": {
|
||||
//! "allowed_paths": ["/webhook/slack"],
|
||||
//! "allow_polling": false,
|
||||
//! "workspace_prefix": "channels/slack/",
|
||||
//! "emit_rate_limit": { "messages_per_minute": 100 }
|
||||
//! }
|
||||
//! },
|
||||
//! "config": {
|
||||
//! "signing_secret_name": "slack_signing_secret"
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::channels::wasm::capabilities::{
|
||||
ChannelCapabilities, EmitRateLimitConfig, MIN_POLL_INTERVAL_MS,
|
||||
};
|
||||
use crate::tools::wasm::{CapabilitiesFile as ToolCapabilitiesFile, RateLimitSchema};
|
||||
|
||||
/// Root schema for a channel capabilities JSON file.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ChannelCapabilitiesFile {
|
||||
/// File type, must be "channel".
|
||||
#[serde(default = "default_type")]
|
||||
pub r#type: String,
|
||||
|
||||
/// Channel name.
|
||||
pub name: String,
|
||||
|
||||
/// Channel description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Capabilities (tool + channel specific).
|
||||
#[serde(default)]
|
||||
pub capabilities: ChannelCapabilitiesSchema,
|
||||
|
||||
/// Channel-specific configuration passed to on_start.
|
||||
#[serde(default)]
|
||||
pub config: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_type() -> String {
|
||||
"channel".to_string()
|
||||
}
|
||||
|
||||
impl ChannelCapabilitiesFile {
|
||||
/// Parse from JSON string.
|
||||
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str(json)
|
||||
}
|
||||
|
||||
/// Parse from JSON bytes.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_slice(bytes)
|
||||
}
|
||||
|
||||
/// Convert to runtime ChannelCapabilities.
|
||||
pub fn to_capabilities(&self) -> ChannelCapabilities {
|
||||
self.capabilities.to_channel_capabilities(&self.name)
|
||||
}
|
||||
|
||||
/// Get the channel config as JSON string.
|
||||
pub fn config_json(&self) -> String {
|
||||
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema for channel capabilities.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ChannelCapabilitiesSchema {
|
||||
/// Tool capabilities (HTTP, secrets, workspace_read).
|
||||
#[serde(flatten)]
|
||||
pub tool: Option<ToolCapabilitiesFile>,
|
||||
|
||||
/// Channel-specific capabilities.
|
||||
#[serde(default)]
|
||||
pub channel: Option<ChannelSpecificCapabilitiesSchema>,
|
||||
}
|
||||
|
||||
impl ChannelCapabilitiesSchema {
|
||||
/// Convert to runtime ChannelCapabilities.
|
||||
pub fn to_channel_capabilities(&self, channel_name: &str) -> ChannelCapabilities {
|
||||
let tool_caps = self
|
||||
.tool
|
||||
.as_ref()
|
||||
.map(|t| t.to_capabilities())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut caps =
|
||||
ChannelCapabilities::for_channel(channel_name).with_tool_capabilities(tool_caps);
|
||||
|
||||
if let Some(channel) = &self.channel {
|
||||
caps.allowed_paths = channel.allowed_paths.clone();
|
||||
caps.allow_polling = channel.allow_polling;
|
||||
caps.min_poll_interval_ms = channel
|
||||
.min_poll_interval_ms
|
||||
.unwrap_or(MIN_POLL_INTERVAL_MS)
|
||||
.max(MIN_POLL_INTERVAL_MS);
|
||||
|
||||
if let Some(prefix) = &channel.workspace_prefix {
|
||||
caps.workspace_prefix = prefix.clone();
|
||||
}
|
||||
|
||||
if let Some(rate) = &channel.emit_rate_limit {
|
||||
caps.emit_rate_limit = rate.to_emit_rate_limit();
|
||||
}
|
||||
|
||||
if let Some(max_size) = channel.max_message_size {
|
||||
caps.max_message_size = max_size;
|
||||
}
|
||||
|
||||
if let Some(timeout_secs) = channel.callback_timeout_secs {
|
||||
caps.callback_timeout = Duration::from_secs(timeout_secs);
|
||||
}
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel-specific capabilities schema.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ChannelSpecificCapabilitiesSchema {
|
||||
/// HTTP paths the channel can register for webhooks.
|
||||
#[serde(default)]
|
||||
pub allowed_paths: Vec<String>,
|
||||
|
||||
/// Whether polling is allowed.
|
||||
#[serde(default)]
|
||||
pub allow_polling: bool,
|
||||
|
||||
/// Minimum poll interval in milliseconds.
|
||||
#[serde(default)]
|
||||
pub min_poll_interval_ms: Option<u32>,
|
||||
|
||||
/// Workspace prefix for storage (overrides default).
|
||||
#[serde(default)]
|
||||
pub workspace_prefix: Option<String>,
|
||||
|
||||
/// Rate limiting for emit_message.
|
||||
#[serde(default)]
|
||||
pub emit_rate_limit: Option<EmitRateLimitSchema>,
|
||||
|
||||
/// Maximum message content size in bytes.
|
||||
#[serde(default)]
|
||||
pub max_message_size: Option<usize>,
|
||||
|
||||
/// Callback timeout in seconds.
|
||||
#[serde(default)]
|
||||
pub callback_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Schema for emit rate limiting.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmitRateLimitSchema {
|
||||
/// Maximum messages per minute.
|
||||
#[serde(default = "default_messages_per_minute")]
|
||||
pub messages_per_minute: u32,
|
||||
|
||||
/// Maximum messages per hour.
|
||||
#[serde(default = "default_messages_per_hour")]
|
||||
pub messages_per_hour: u32,
|
||||
}
|
||||
|
||||
fn default_messages_per_minute() -> u32 {
|
||||
100
|
||||
}
|
||||
|
||||
fn default_messages_per_hour() -> u32 {
|
||||
5000
|
||||
}
|
||||
|
||||
impl EmitRateLimitSchema {
|
||||
fn to_emit_rate_limit(&self) -> EmitRateLimitConfig {
|
||||
EmitRateLimitConfig {
|
||||
messages_per_minute: self.messages_per_minute,
|
||||
messages_per_hour: self.messages_per_hour,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RateLimitSchema> for EmitRateLimitSchema {
|
||||
fn from(schema: RateLimitSchema) -> Self {
|
||||
Self {
|
||||
messages_per_minute: schema.requests_per_minute,
|
||||
messages_per_hour: schema.requests_per_hour,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel configuration returned by on_start.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelConfig {
|
||||
/// Display name for the channel.
|
||||
pub display_name: String,
|
||||
|
||||
/// HTTP endpoints to register.
|
||||
#[serde(default)]
|
||||
pub http_endpoints: Vec<HttpEndpointConfigSchema>,
|
||||
|
||||
/// Polling configuration.
|
||||
#[serde(default)]
|
||||
pub poll: Option<PollConfigSchema>,
|
||||
}
|
||||
|
||||
impl Default for ChannelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
display_name: "WASM Channel".to_string(),
|
||||
http_endpoints: Vec::new(),
|
||||
poll: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP endpoint configuration schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpEndpointConfigSchema {
|
||||
/// Path to register.
|
||||
pub path: String,
|
||||
|
||||
/// HTTP methods to accept.
|
||||
#[serde(default)]
|
||||
pub methods: Vec<String>,
|
||||
|
||||
/// Whether secret validation is required.
|
||||
#[serde(default)]
|
||||
pub require_secret: bool,
|
||||
}
|
||||
|
||||
/// Polling configuration schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PollConfigSchema {
|
||||
/// Polling interval in milliseconds.
|
||||
pub interval_ms: u32,
|
||||
|
||||
/// Whether polling is enabled.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal() {
|
||||
let json = r#"{
|
||||
"name": "test"
|
||||
}"#;
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(file.name, "test");
|
||||
assert_eq!(file.r#type, "channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_slack_example() {
|
||||
let json = r#"{
|
||||
"type": "channel",
|
||||
"name": "slack",
|
||||
"description": "Slack Events API channel",
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "slack.com", "path_prefix": "/api/" }
|
||||
],
|
||||
"credentials": {
|
||||
"slack_bot": {
|
||||
"secret_name": "slack_bot_token",
|
||||
"location": { "type": "bearer" },
|
||||
"host_patterns": ["slack.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
|
||||
},
|
||||
"secrets": { "allowed_names": ["slack_*"] },
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/slack"],
|
||||
"allow_polling": false,
|
||||
"emit_rate_limit": { "messages_per_minute": 100, "messages_per_hour": 5000 }
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"signing_secret_name": "slack_signing_secret"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(file.name, "slack");
|
||||
assert_eq!(
|
||||
file.description,
|
||||
Some("Slack Events API channel".to_string())
|
||||
);
|
||||
|
||||
let caps = file.to_capabilities();
|
||||
assert!(caps.is_path_allowed("/webhook/slack"));
|
||||
assert!(!caps.allow_polling);
|
||||
assert_eq!(caps.workspace_prefix, "channels/slack/");
|
||||
|
||||
// Check tool capabilities were parsed
|
||||
assert!(caps.tool_capabilities.http.is_some());
|
||||
assert!(caps.tool_capabilities.secrets.is_some());
|
||||
|
||||
// Check config
|
||||
let config_json = file.config_json();
|
||||
assert!(config_json.contains("signing_secret_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_polling() {
|
||||
let json = r#"{
|
||||
"name": "telegram",
|
||||
"capabilities": {
|
||||
"channel": {
|
||||
"allowed_paths": [],
|
||||
"allow_polling": true,
|
||||
"min_poll_interval_ms": 60000
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
let caps = file.to_capabilities();
|
||||
|
||||
assert!(caps.allow_polling);
|
||||
assert_eq!(caps.min_poll_interval_ms, 60000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_poll_interval_enforced() {
|
||||
let json = r#"{
|
||||
"name": "test",
|
||||
"capabilities": {
|
||||
"channel": {
|
||||
"allow_polling": true,
|
||||
"min_poll_interval_ms": 1000
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
let caps = file.to_capabilities();
|
||||
|
||||
// Should be clamped to minimum
|
||||
assert_eq!(caps.min_poll_interval_ms, 30000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_prefix_override() {
|
||||
let json = r#"{
|
||||
"name": "custom",
|
||||
"capabilities": {
|
||||
"channel": {
|
||||
"workspace_prefix": "integrations/custom/"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
let caps = file.to_capabilities();
|
||||
|
||||
assert_eq!(caps.workspace_prefix, "integrations/custom/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_rate_limit() {
|
||||
let json = r#"{
|
||||
"name": "test",
|
||||
"capabilities": {
|
||||
"channel": {
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 50,
|
||||
"messages_per_hour": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
let caps = file.to_capabilities();
|
||||
|
||||
assert_eq!(caps.emit_rate_limit.messages_per_minute, 50);
|
||||
assert_eq!(caps.emit_rate_limit.messages_per_hour, 1000);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -180,6 +180,10 @@ fn default_session_path() -> PathBuf {
|
||||
pub struct ChannelsConfig {
|
||||
pub cli: CliConfig,
|
||||
pub http: Option<HttpConfig>,
|
||||
/// Directory containing WASM channel modules (default: ~/.near-agent/channels/).
|
||||
pub wasm_channels_dir: std::path::PathBuf,
|
||||
/// Whether WASM channels are enabled.
|
||||
pub wasm_channels_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -222,10 +226,29 @@ impl ChannelsConfig {
|
||||
enabled: cli_enabled,
|
||||
},
|
||||
http,
|
||||
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
wasm_channels_enabled: optional_env("WASM_CHANNELS_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "WASM_CHANNELS_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default channels directory (~/.near-agent/channels/).
|
||||
fn default_channels_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".near-agent")
|
||||
.join("channels")
|
||||
}
|
||||
|
||||
/// Agent behavior configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentConfig {
|
||||
|
||||
+39
-1
@@ -7,7 +7,10 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
|
||||
|
||||
use near_agent::{
|
||||
agent::{Agent, AgentDeps},
|
||||
channels::{AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel},
|
||||
channels::{
|
||||
AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel,
|
||||
wasm::{WasmChannelLoader, WasmChannelRuntime, WasmChannelRuntimeConfig},
|
||||
},
|
||||
cli::{Cli, Command, run_tool_command},
|
||||
config::Config,
|
||||
history::Store,
|
||||
@@ -286,6 +289,41 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Load WASM channels if enabled
|
||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
|
||||
Ok(runtime) => {
|
||||
let runtime = Arc::new(runtime);
|
||||
let loader = WasmChannelLoader::new(Arc::clone(&runtime));
|
||||
|
||||
match loader
|
||||
.load_from_dir(&config.channels.wasm_channels_dir)
|
||||
.await
|
||||
{
|
||||
Ok(results) => {
|
||||
for channel in results.loaded {
|
||||
tracing::info!("Loaded WASM channel: {}", channel.channel_name());
|
||||
channels.add(Box::new(channel));
|
||||
}
|
||||
for (path, err) in &results.errors {
|
||||
tracing::warn!(
|
||||
"Failed to load WASM channel {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to scan WASM channels directory: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize WASM channel runtime: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create workspace for agent (shared with memory tools)
|
||||
let workspace = store.as_ref().map(|s| {
|
||||
let mut ws = Workspace::new("default", s.pool());
|
||||
|
||||
@@ -118,4 +118,4 @@ pub use storage::{
|
||||
pub use loader::{DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_tools};
|
||||
|
||||
// Capabilities schema (for parsing *.capabilities.json files)
|
||||
pub use capabilities_schema::CapabilitiesFile;
|
||||
pub use capabilities_schema::{CapabilitiesFile, RateLimitSchema};
|
||||
|
||||
Reference in New Issue
Block a user