mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
* feat: Implement DM pairing for channels - Introduced a new pairing system to manage direct messages from unknown senders. - Added `PairingStore` to handle pending requests and allowlist management. - Implemented CLI commands for listing and approving pairing requests. - Updated Telegram channel to utilize the new pairing logic, including workspace paths for storing pairing data. - Enhanced WASM channel integration to support pairing functionality. This feature enhances security by requiring approval for unknown senders before they can interact with the agent. * Enhance Telegram channel support with media captioning and DM pairing features - Added support for media captions in Telegram messages, allowing for richer content handling. - Updated message processing to utilize either text or caption, improving message flexibility. - Enhanced DM pairing functionality to include approval and listing capabilities for direct messages. - Updated feature parity documentation to reflect new capabilities and improvements in Telegram integration. * Update README and BUILDING_CHANNELS documentation for Telegram channel integration - Enhanced README with instructions for building and running the Telegram channel, including a note on running `./scripts/build-all.sh` for full releases. - Added detailed steps in BUILDING_CHANNELS.md for building and deploying the Telegram channel, emphasizing the need to run `./channels-src/telegram/build.sh` before building the main crate to ensure updated WASM is included. - Updated CLI module to expose a new command for pairing with store functionality. * Implement build script for Telegram channel WASM and enhance pairing error handling - Added a new `build.rs` script to automate the compilation of the Telegram channel's WASM binary from source, ensuring reproducible builds and emphasizing supply chain security by preventing committed binaries. - Updated `BUILDING_CHANNELS.md` to reflect the new build process and the importance of not committing compiled binaries. - Enhanced error handling in the pairing approval process to include rate limiting for failed attempts, improving security and user feedback. * Remove Telegram channel WASM binary file as part of the build process cleanup, ensuring no committed binaries are present in the repository.
437 lines
14 KiB
Rust
437 lines
14 KiB
Rust
//! WASM channel loader for loading channels from files or directories.
|
|
//!
|
|
//! Loads WASM channel modules from the filesystem (default: ~/.ironclaw/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;
|
|
use crate::pairing::PairingStore;
|
|
|
|
/// Loads WASM channels from the filesystem.
|
|
pub struct WasmChannelLoader {
|
|
runtime: Arc<WasmChannelRuntime>,
|
|
pairing_store: Arc<PairingStore>,
|
|
}
|
|
|
|
impl WasmChannelLoader {
|
|
/// Create a new loader with the given runtime and pairing store.
|
|
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
|
|
Self {
|
|
runtime,
|
|
pairing_store,
|
|
}
|
|
}
|
|
|
|
/// 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<LoadedChannel, 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, cap_file) =
|
|
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()))?;
|
|
|
|
// Debug: log raw capabilities
|
|
tracing::debug!(
|
|
channel = name,
|
|
raw_capabilities = ?cap_file.capabilities,
|
|
"Parsed capabilities file"
|
|
);
|
|
|
|
let caps = cap_file.to_capabilities();
|
|
|
|
// Debug: log resulting capabilities
|
|
tracing::info!(
|
|
channel = name,
|
|
http_allowed = caps.tool_capabilities.http.is_some(),
|
|
http_allowlist_count = caps
|
|
.tool_capabilities
|
|
.http
|
|
.as_ref()
|
|
.map(|h| h.allowlist.len())
|
|
.unwrap_or(0),
|
|
"Channel capabilities loaded"
|
|
);
|
|
|
|
let config = cap_file.config_json();
|
|
let desc = cap_file.description.clone();
|
|
|
|
(caps, config, desc, Some(cap_file))
|
|
} else {
|
|
tracing::warn!(
|
|
path = %cap_path.display(),
|
|
"Capabilities file not found, using defaults"
|
|
);
|
|
(
|
|
ChannelCapabilities::for_channel(name),
|
|
"{}".to_string(),
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
} else {
|
|
(
|
|
ChannelCapabilities::for_channel(name),
|
|
"{}".to_string(),
|
|
None,
|
|
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,
|
|
self.pairing_store.clone(),
|
|
);
|
|
|
|
tracing::info!(
|
|
name = name,
|
|
wasm_path = %wasm_path.display(),
|
|
"Loaded WASM channel from file"
|
|
);
|
|
|
|
Ok(LoadedChannel {
|
|
channel,
|
|
capabilities_file: cap_file,
|
|
})
|
|
}
|
|
|
|
/// 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();
|
|
|
|
// Collect all .wasm entries first, then load in parallel
|
|
let mut channel_entries = Vec::new();
|
|
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 => {
|
|
results.errors.push((
|
|
path.clone(),
|
|
WasmChannelError::InvalidName("invalid filename".to_string()),
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let cap_path = path.with_extension("capabilities.json");
|
|
let has_cap = cap_path.exists();
|
|
channel_entries.push((name, path, if has_cap { Some(cap_path) } else { None }));
|
|
}
|
|
|
|
// Load all channels in parallel (file I/O + WASM compilation)
|
|
let load_futures = channel_entries
|
|
.iter()
|
|
.map(|(name, path, cap_path)| self.load_from_files(name, path, cap_path.as_deref()));
|
|
|
|
let load_results = futures::future::join_all(load_futures).await;
|
|
|
|
for ((name, path, _), result) in channel_entries.into_iter().zip(load_results) {
|
|
match result {
|
|
Ok(loaded) => {
|
|
results.loaded.push(loaded);
|
|
}
|
|
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.name()).collect::<Vec<_>>(),
|
|
"Loaded WASM channels from directory"
|
|
);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
}
|
|
|
|
/// A loaded WASM channel with its capabilities file.
|
|
pub struct LoadedChannel {
|
|
/// The loaded channel.
|
|
pub channel: WasmChannel,
|
|
|
|
/// The parsed capabilities file (if present).
|
|
pub capabilities_file: Option<ChannelCapabilitiesFile>,
|
|
}
|
|
|
|
impl LoadedChannel {
|
|
/// Get the channel name.
|
|
pub fn name(&self) -> &str {
|
|
self.channel.channel_name()
|
|
}
|
|
|
|
/// Get the webhook secret header name from capabilities.
|
|
pub fn webhook_secret_header(&self) -> Option<&str> {
|
|
self.capabilities_file
|
|
.as_ref()
|
|
.and_then(|f| f.webhook_secret_header())
|
|
}
|
|
|
|
/// Get the webhook secret name from capabilities.
|
|
pub fn webhook_secret_name(&self) -> String {
|
|
self.capabilities_file
|
|
.as_ref()
|
|
.map(|f| f.webhook_secret_name())
|
|
.unwrap_or_else(|| format!("{}_webhook_secret", self.channel.channel_name()))
|
|
}
|
|
}
|
|
|
|
/// Results from loading multiple channels.
|
|
#[derive(Default)]
|
|
pub struct LoadResults {
|
|
/// Successfully loaded channels with their capabilities.
|
|
pub loaded: Vec<LoadedChannel>,
|
|
|
|
/// 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 (extracts just the WasmChannel).
|
|
pub fn take_channels(self) -> Vec<WasmChannel> {
|
|
self.loaded.into_iter().map(|l| l.channel).collect()
|
|
}
|
|
}
|
|
|
|
/// 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 ~/.ironclaw/channels/
|
|
#[allow(dead_code)]
|
|
pub fn default_channels_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".ironclaw")
|
|
.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 crate::pairing::PairingStore;
|
|
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, Arc::new(PairingStore::new()));
|
|
|
|
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());
|
|
}
|
|
}
|