Merge pull request #3827 from zeroclaw-labs/feat/plugin-wasm

feat(plugins): add WASM plugin system with Extism runtime
This commit is contained in:
Argenis 2026-03-17 18:51:41 -04:00 committed by GitHub
commit 1341cfb296
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2126 additions and 28 deletions

10
.cargo/audit.toml Normal file
View File

@ -0,0 +1,10 @@
# cargo-audit configuration
# https://rustsec.org/
[advisories]
ignore = [
# wasmtime vulns via extism 1.13.0 — no upstream fix; plugins feature-gated
"RUSTSEC-2026-0006", # wasmtime f64.copysign segfault on x86-64
"RUSTSEC-2026-0020", # WASI guest-controlled resource exhaustion
"RUSTSEC-2026-0021", # WASI http fields panic
]

1245
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,9 @@ probe-rs = { version = "0.31", optional = true }
# PDF extraction for datasheet RAG (optional, enable with --features rag-pdf)
pdf-extract = { version = "0.10", optional = true }
# WASM plugin runtime (extism)
extism = { version = "1.9", optional = true }
# Terminal QR rendering for WhatsApp Web pairing flow.
qrcode = { version = "0.14", optional = true }
@ -239,6 +242,8 @@ probe = ["dep:probe-rs"]
rag-pdf = ["dep:pdf-extract"]
# whatsapp-web = Native WhatsApp Web client with custom rusqlite storage backend
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "dep:serde-big-array", "dep:prost", "dep:qrcode"]
# WASM plugin system (extism-based)
plugins-wasm = ["dep:extism"]
[profile.release]
opt-level = "z" # Optimize for size

View File

@ -12,6 +12,13 @@ ignore = [
# bincode v2.0.1 via probe-rs — project ceased but 1.3.3 considered complete
"RUSTSEC-2025-0141",
{ id = "RUSTSEC-2024-0384", reason = "Reported to `rust-nostr/nostr` and it's WIP" },
{ id = "RUSTSEC-2024-0388", reason = "derivative via extism → wasmtime transitive dep" },
{ id = "RUSTSEC-2025-0057", reason = "fxhash via extism → wasmtime transitive dep" },
{ id = "RUSTSEC-2025-0119", reason = "number_prefix via indicatif — cosmetic dep" },
# wasmtime vulns via extism 1.13.0 — no upstream fix yet; plugins feature-gated
{ id = "RUSTSEC-2026-0006", reason = "wasmtime segfault via extism; awaiting extism upgrade" },
{ id = "RUSTSEC-2026-0020", reason = "WASI resource exhaustion via extism; awaiting extism upgrade" },
{ id = "RUSTSEC-2026-0021", reason = "WASI http fields panic via extism; awaiting extism upgrade" },
]
[licenses]

12
example-plugin/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "zeroclaw-weather-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
extism-pdk = "1.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@ -0,0 +1,8 @@
name = "weather"
version = "0.1.0"
description = "Example weather tool plugin for ZeroClaw"
author = "ZeroClaw Labs"
wasm_path = "target/wasm32-wasip1/release/zeroclaw_weather_plugin.wasm"
capabilities = ["tool"]
permissions = ["http_client"]

42
example-plugin/src/lib.rs Normal file
View File

@ -0,0 +1,42 @@
//! Example ZeroClaw weather plugin.
//!
//! Demonstrates how to create a WASM tool plugin using extism-pdk.
//! Build with: cargo build --target wasm32-wasip1 --release
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct WeatherInput {
location: String,
}
#[derive(Serialize)]
struct WeatherOutput {
location: String,
temperature: f64,
unit: String,
condition: String,
humidity: u32,
}
/// Get weather for a location (mock implementation for demonstration).
#[plugin_fn]
pub fn get_weather(input: String) -> FnResult<String> {
let params: WeatherInput =
serde_json::from_str(&input).map_err(|e| Error::msg(format!("invalid input: {e}")))?;
// Mock weather data for demonstration
let output = WeatherOutput {
location: params.location,
temperature: 22.5,
unit: "celsius".to_string(),
condition: "Partly cloudy".to_string(),
humidity: 65,
};
let json = serde_json::to_string(&output)
.map_err(|e| Error::msg(format!("serialization error: {e}")))?;
Ok(json)
}

View File

@ -19,13 +19,14 @@ pub use schema::{
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig,
ObservabilityConfig, OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig,
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig,
ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,
StorageProviderConfig, StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy,
TelegramConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig,
TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, PluginsConfig, ProjectIntelConfig,
ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig,
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode,
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
SwarmConfig, SwarmStrategy, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
WorkspaceConfig,
};
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {

View File

@ -335,6 +335,10 @@ pub struct Config {
/// LinkedIn integration configuration (`[linkedin]`).
#[serde(default)]
pub linkedin: LinkedInConfig,
/// Plugin system configuration (`[plugins]`).
#[serde(default)]
pub plugins: PluginsConfig,
}
/// Multi-client workspace isolation configuration.
@ -2357,6 +2361,42 @@ fn default_linkedin_api_version() -> String {
"202602".to_string()
}
/// Plugin system configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginsConfig {
/// Enable the plugin system (default: false)
#[serde(default)]
pub enabled: bool,
/// Directory where plugins are stored
#[serde(default = "default_plugins_dir")]
pub plugins_dir: String,
/// Auto-discover and load plugins on startup
#[serde(default)]
pub auto_discover: bool,
/// Maximum number of plugins that can be loaded
#[serde(default = "default_max_plugins")]
pub max_plugins: usize,
}
fn default_plugins_dir() -> String {
"~/.zeroclaw/plugins".to_string()
}
fn default_max_plugins() -> usize {
50
}
impl Default for PluginsConfig {
fn default() -> Self {
Self {
enabled: false,
plugins_dir: default_plugins_dir(),
auto_discover: false,
max_plugins: default_max_plugins(),
}
}
}
/// Content strategy configuration for LinkedIn auto-posting (`[linkedin.content]`).
///
/// The agent reads this via the `linkedin get_content_strategy` action to know
@ -5950,6 +5990,7 @@ impl Default for Config {
node_transport: NodeTransportConfig::default(),
knowledge: KnowledgeConfig::default(),
linkedin: LinkedInConfig::default(),
plugins: PluginsConfig::default(),
}
}
}
@ -8385,6 +8426,7 @@ default_temperature = 0.7
node_transport: NodeTransportConfig::default(),
knowledge: KnowledgeConfig::default(),
linkedin: LinkedInConfig::default(),
plugins: PluginsConfig::default(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
@ -8717,6 +8759,7 @@ tool_dispatcher = "xml"
node_transport: NodeTransportConfig::default(),
knowledge: KnowledgeConfig::default(),
linkedin: LinkedInConfig::default(),
plugins: PluginsConfig::default(),
};
config.save().await.unwrap();

View File

@ -0,0 +1,77 @@
//! Plugin management API routes (requires `plugins-wasm` feature).
#[cfg(feature = "plugins-wasm")]
pub mod plugin_routes {
use axum::{
extract::State,
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use super::super::AppState;
/// `GET /api/plugins` — list loaded plugins and their status.
pub async fn list_plugins(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
// Auth check
if state.pairing.require_pairing() {
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|auth| auth.strip_prefix("Bearer "))
.unwrap_or("");
if !state.pairing.is_authenticated(token) {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
}
let config = state.config.lock();
let plugins_enabled = config.plugins.enabled;
let plugins_dir = config.plugins.plugins_dir.clone();
drop(config);
let plugins: Vec<serde_json::Value> = if plugins_enabled {
let plugin_path = if plugins_dir.starts_with("~/") {
directories::UserDirs::new()
.map(|u| u.home_dir().join(&plugins_dir[2..]))
.unwrap_or_else(|| std::path::PathBuf::from(&plugins_dir))
} else {
std::path::PathBuf::from(&plugins_dir)
};
if plugin_path.exists() {
match crate::plugins::host::PluginHost::new(
plugin_path.parent().unwrap_or(&plugin_path),
) {
Ok(host) => host
.list_plugins()
.into_iter()
.map(|p| {
serde_json::json!({
"name": p.name,
"version": p.version,
"description": p.description,
"capabilities": p.capabilities,
"loaded": p.loaded,
})
})
.collect(),
Err(_) => vec![],
}
} else {
vec![]
}
} else {
vec![]
};
Json(serde_json::json!({
"plugins_enabled": plugins_enabled,
"plugins_dir": plugins_dir,
"plugins": plugins,
}))
.into_response()
}
}

View File

@ -9,6 +9,8 @@
pub mod api;
pub mod api_pairing;
#[cfg(feature = "plugins-wasm")]
pub mod api_plugins;
pub mod nodes;
pub mod sse;
pub mod static_files;
@ -789,7 +791,16 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.route(
"/api/devices/{id}/token/rotate",
post(api_pairing::rotate_token),
)
);
// ── Plugin management API (requires plugins-wasm feature) ──
#[cfg(feature = "plugins-wasm")]
let app = app.route(
"/api/plugins",
get(api_plugins::plugin_routes::list_plugins),
);
let app = app
// ── SSE event stream ──
.route("/api/events", get(sse::handle_sse_events))
// ── WebSocket agent chat ──

View File

@ -236,7 +236,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, session_id: Option<St
let user_msg = crate::providers::ChatMessage::user(&content);
let _ = backend.append(&session_key, &user_msg);
}
process_chat_message(&state, &mut agent, &mut sender, &content, &session_key).await;
process_chat_message(&state, &mut agent, &mut sender, &content, &session_key)
.await;
}
}
}

View File

@ -73,6 +73,9 @@ pub mod tools;
pub(crate) mod tunnel;
pub(crate) mod util;
#[cfg(feature = "plugins-wasm")]
pub mod plugins;
pub use config::Config;
/// Gateway management subcommands

View File

@ -97,6 +97,8 @@ mod multimodal;
mod observability;
mod onboard;
mod peripherals;
#[cfg(feature = "plugins-wasm")]
mod plugins;
mod providers;
mod runtime;
mod security;
@ -528,6 +530,35 @@ Examples:
#[arg(value_enum)]
shell: CompletionShell,
},
/// Manage WASM plugins
#[cfg(feature = "plugins-wasm")]
Plugin {
#[command(subcommand)]
plugin_command: PluginCommands,
},
}
#[cfg(feature = "plugins-wasm")]
#[derive(Subcommand, Debug)]
enum PluginCommands {
/// List installed plugins
List,
/// Install a plugin from a directory or URL
Install {
/// Path to plugin directory or manifest
source: String,
},
/// Remove an installed plugin
Remove {
/// Plugin name
name: String,
},
/// Show information about a plugin
Info {
/// Plugin name
name: String,
},
}
#[derive(Subcommand, Debug)]
@ -1325,6 +1356,56 @@ async fn main() -> Result<()> {
Ok(())
}
},
#[cfg(feature = "plugins-wasm")]
Commands::Plugin { plugin_command } => match plugin_command {
PluginCommands::List => {
let host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
let plugins = host.list_plugins();
if plugins.is_empty() {
println!("No plugins installed.");
} else {
println!("Installed plugins:");
for p in &plugins {
println!(
" {} v{} — {}",
p.name,
p.version,
p.description.as_deref().unwrap_or("(no description)")
);
}
}
Ok(())
}
PluginCommands::Install { source } => {
let mut host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
host.install(&source)?;
println!("Plugin installed from {source}");
Ok(())
}
PluginCommands::Remove { name } => {
let mut host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
host.remove(&name)?;
println!("Plugin '{name}' removed.");
Ok(())
}
PluginCommands::Info { name } => {
let host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
match host.get_plugin(&name) {
Some(info) => {
println!("Plugin: {} v{}", info.name, info.version);
if let Some(desc) = &info.description {
println!("Description: {desc}");
}
println!("Capabilities: {:?}", info.capabilities);
println!("Permissions: {:?}", info.permissions);
println!("WASM: {}", info.wasm_path.display());
}
None => println!("Plugin '{name}' not found."),
}
Ok(())
}
},
}
}

View File

@ -192,6 +192,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
node_transport: crate::config::NodeTransportConfig::default(),
knowledge: crate::config::KnowledgeConfig::default(),
linkedin: crate::config::LinkedInConfig::default(),
plugins: crate::config::PluginsConfig::default(),
};
println!(
@ -565,6 +566,7 @@ async fn run_quick_setup_with_home(
node_transport: crate::config::NodeTransportConfig::default(),
knowledge: crate::config::KnowledgeConfig::default(),
linkedin: crate::config::LinkedInConfig::default(),
plugins: crate::config::PluginsConfig::default(),
};
config.save().await?;

33
src/plugins/error.rs Normal file
View File

@ -0,0 +1,33 @@
//! Plugin error types.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PluginError {
#[error("plugin not found: {0}")]
NotFound(String),
#[error("invalid manifest: {0}")]
InvalidManifest(String),
#[error("failed to load WASM module: {0}")]
LoadFailed(String),
#[error("plugin execution failed: {0}")]
ExecutionFailed(String),
#[error("permission denied: plugin '{plugin}' requires '{permission}'")]
PermissionDenied { plugin: String, permission: String },
#[error("plugin '{0}' is already loaded")]
AlreadyLoaded(String),
#[error("plugin capability not supported: {0}")]
UnsupportedCapability(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
TomlParse(#[from] toml::de::Error),
}

325
src/plugins/host.rs Normal file
View File

@ -0,0 +1,325 @@
//! Plugin host: discovery, loading, lifecycle management.
use super::error::PluginError;
use super::{PluginCapability, PluginInfo, PluginManifest};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Manages the lifecycle of WASM plugins.
pub struct PluginHost {
plugins_dir: PathBuf,
loaded: HashMap<String, LoadedPlugin>,
}
struct LoadedPlugin {
manifest: PluginManifest,
wasm_path: PathBuf,
}
impl PluginHost {
/// Create a new plugin host with the given plugins directory.
pub fn new(workspace_dir: &Path) -> Result<Self, PluginError> {
let plugins_dir = workspace_dir.join("plugins");
if !plugins_dir.exists() {
std::fs::create_dir_all(&plugins_dir)?;
}
let mut host = Self {
plugins_dir,
loaded: HashMap::new(),
};
host.discover()?;
Ok(host)
}
/// Discover plugins in the plugins directory.
fn discover(&mut self) -> Result<(), PluginError> {
if !self.plugins_dir.exists() {
return Ok(());
}
let entries = std::fs::read_dir(&self.plugins_dir)?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let manifest_path = path.join("manifest.toml");
if manifest_path.exists() {
if let Ok(manifest) = self.load_manifest(&manifest_path) {
let wasm_path = path.join(&manifest.wasm_path);
self.loaded.insert(
manifest.name.clone(),
LoadedPlugin {
manifest,
wasm_path,
},
);
}
}
}
}
Ok(())
}
fn load_manifest(&self, path: &Path) -> Result<PluginManifest, PluginError> {
let content = std::fs::read_to_string(path)?;
let manifest: PluginManifest = toml::from_str(&content)?;
Ok(manifest)
}
/// List all discovered plugins.
pub fn list_plugins(&self) -> Vec<PluginInfo> {
self.loaded
.values()
.map(|p| PluginInfo {
name: p.manifest.name.clone(),
version: p.manifest.version.clone(),
description: p.manifest.description.clone(),
capabilities: p.manifest.capabilities.clone(),
permissions: p.manifest.permissions.clone(),
wasm_path: p.wasm_path.clone(),
loaded: p.wasm_path.exists(),
})
.collect()
}
/// Get info about a specific plugin.
pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
self.loaded.get(name).map(|p| PluginInfo {
name: p.manifest.name.clone(),
version: p.manifest.version.clone(),
description: p.manifest.description.clone(),
capabilities: p.manifest.capabilities.clone(),
permissions: p.manifest.permissions.clone(),
wasm_path: p.wasm_path.clone(),
loaded: p.wasm_path.exists(),
})
}
/// Install a plugin from a directory path.
pub fn install(&mut self, source: &str) -> Result<(), PluginError> {
let source_path = PathBuf::from(source);
let manifest_path = if source_path.is_dir() {
source_path.join("manifest.toml")
} else {
source_path.clone()
};
if !manifest_path.exists() {
return Err(PluginError::NotFound(format!(
"manifest.toml not found at {}",
manifest_path.display()
)));
}
let manifest = self.load_manifest(&manifest_path)?;
let source_dir = manifest_path
.parent()
.ok_or_else(|| PluginError::InvalidManifest("no parent directory".into()))?;
let wasm_source = source_dir.join(&manifest.wasm_path);
if !wasm_source.exists() {
return Err(PluginError::NotFound(format!(
"WASM file not found: {}",
wasm_source.display()
)));
}
if self.loaded.contains_key(&manifest.name) {
return Err(PluginError::AlreadyLoaded(manifest.name));
}
// Copy plugin to plugins directory
let dest_dir = self.plugins_dir.join(&manifest.name);
std::fs::create_dir_all(&dest_dir)?;
// Copy manifest
std::fs::copy(&manifest_path, dest_dir.join("manifest.toml"))?;
// Copy WASM file
let wasm_dest = dest_dir.join(&manifest.wasm_path);
if let Some(parent) = wasm_dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&wasm_source, &wasm_dest)?;
self.loaded.insert(
manifest.name.clone(),
LoadedPlugin {
manifest,
wasm_path: wasm_dest,
},
);
Ok(())
}
/// Remove a plugin by name.
pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
if self.loaded.remove(name).is_none() {
return Err(PluginError::NotFound(name.to_string()));
}
let plugin_dir = self.plugins_dir.join(name);
if plugin_dir.exists() {
std::fs::remove_dir_all(plugin_dir)?;
}
Ok(())
}
/// Get tool-capable plugins.
pub fn tool_plugins(&self) -> Vec<&PluginManifest> {
self.loaded
.values()
.filter(|p| p.manifest.capabilities.contains(&PluginCapability::Tool))
.map(|p| &p.manifest)
.collect()
}
/// Get channel-capable plugins.
pub fn channel_plugins(&self) -> Vec<&PluginManifest> {
self.loaded
.values()
.filter(|p| p.manifest.capabilities.contains(&PluginCapability::Channel))
.map(|p| &p.manifest)
.collect()
}
/// Returns the plugins directory path.
pub fn plugins_dir(&self) -> &Path {
&self.plugins_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_empty_plugin_dir() {
let dir = tempdir().unwrap();
let host = PluginHost::new(dir.path()).unwrap();
assert!(host.list_plugins().is_empty());
}
#[test]
fn test_discover_with_manifest() {
let dir = tempdir().unwrap();
let plugin_dir = dir.path().join("plugins").join("test-plugin");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(
plugin_dir.join("manifest.toml"),
r#"
name = "test-plugin"
version = "0.1.0"
description = "A test plugin"
wasm_path = "plugin.wasm"
capabilities = ["tool"]
permissions = []
"#,
)
.unwrap();
let host = PluginHost::new(dir.path()).unwrap();
let plugins = host.list_plugins();
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "test-plugin");
}
#[test]
fn test_tool_plugins_filter() {
let dir = tempdir().unwrap();
let plugins_base = dir.path().join("plugins");
// Tool plugin
let tool_dir = plugins_base.join("my-tool");
std::fs::create_dir_all(&tool_dir).unwrap();
std::fs::write(
tool_dir.join("manifest.toml"),
r#"
name = "my-tool"
version = "0.1.0"
wasm_path = "tool.wasm"
capabilities = ["tool"]
"#,
)
.unwrap();
// Channel plugin
let chan_dir = plugins_base.join("my-channel");
std::fs::create_dir_all(&chan_dir).unwrap();
std::fs::write(
chan_dir.join("manifest.toml"),
r#"
name = "my-channel"
version = "0.1.0"
wasm_path = "channel.wasm"
capabilities = ["channel"]
"#,
)
.unwrap();
let host = PluginHost::new(dir.path()).unwrap();
assert_eq!(host.list_plugins().len(), 2);
assert_eq!(host.tool_plugins().len(), 1);
assert_eq!(host.channel_plugins().len(), 1);
assert_eq!(host.tool_plugins()[0].name, "my-tool");
}
#[test]
fn test_get_plugin() {
let dir = tempdir().unwrap();
let plugin_dir = dir.path().join("plugins").join("lookup-test");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(
plugin_dir.join("manifest.toml"),
r#"
name = "lookup-test"
version = "1.0.0"
description = "Lookup test"
wasm_path = "plugin.wasm"
capabilities = ["tool"]
"#,
)
.unwrap();
let host = PluginHost::new(dir.path()).unwrap();
assert!(host.get_plugin("lookup-test").is_some());
assert!(host.get_plugin("nonexistent").is_none());
}
#[test]
fn test_remove_plugin() {
let dir = tempdir().unwrap();
let plugin_dir = dir.path().join("plugins").join("removable");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(
plugin_dir.join("manifest.toml"),
r#"
name = "removable"
version = "0.1.0"
wasm_path = "plugin.wasm"
capabilities = ["tool"]
"#,
)
.unwrap();
let mut host = PluginHost::new(dir.path()).unwrap();
assert_eq!(host.list_plugins().len(), 1);
host.remove("removable").unwrap();
assert!(host.list_plugins().is_empty());
assert!(!plugin_dir.exists());
}
#[test]
fn test_remove_nonexistent_returns_error() {
let dir = tempdir().unwrap();
let mut host = PluginHost::new(dir.path()).unwrap();
assert!(host.remove("ghost").is_err());
}
}

76
src/plugins/mod.rs Normal file
View File

@ -0,0 +1,76 @@
//! WASM plugin system for ZeroClaw.
//!
//! Plugins are WebAssembly modules loaded via Extism that can extend
//! ZeroClaw with custom tools and channels. Enable with `--features plugins-wasm`.
pub mod error;
pub mod host;
pub mod wasm_channel;
pub mod wasm_tool;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// A plugin's declared manifest (loaded from manifest.toml alongside the .wasm).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
/// Plugin name (unique identifier)
pub name: String,
/// Plugin version
pub version: String,
/// Human-readable description
pub description: Option<String>,
/// Author name or organization
pub author: Option<String>,
/// Path to the .wasm file (relative to manifest)
pub wasm_path: String,
/// Capabilities this plugin provides
pub capabilities: Vec<PluginCapability>,
/// Permissions this plugin requests
#[serde(default)]
pub permissions: Vec<PluginPermission>,
}
/// What a plugin can do.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PluginCapability {
/// Provides one or more tools
Tool,
/// Provides a channel implementation
Channel,
/// Provides a memory backend
Memory,
/// Provides an observer/metrics backend
Observer,
}
/// Permissions a plugin may request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PluginPermission {
/// Can make HTTP requests
HttpClient,
/// Can read from the filesystem (within sandbox)
FileRead,
/// Can write to the filesystem (within sandbox)
FileWrite,
/// Can access environment variables
EnvRead,
/// Can read agent memory
MemoryRead,
/// Can write agent memory
MemoryWrite,
}
/// Information about a loaded plugin.
#[derive(Debug, Clone, Serialize)]
pub struct PluginInfo {
pub name: String,
pub version: String,
pub description: Option<String>,
pub capabilities: Vec<PluginCapability>,
pub permissions: Vec<PluginPermission>,
pub wasm_path: PathBuf,
pub loaded: bool,
}

View File

@ -0,0 +1,44 @@
//! Bridge between WASM plugins and the Channel trait.
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
/// A channel backed by a WASM plugin.
pub struct WasmChannel {
name: String,
plugin_name: String,
}
impl WasmChannel {
pub fn new(name: String, plugin_name: String) -> Self {
Self { name, plugin_name }
}
}
#[async_trait]
impl Channel for WasmChannel {
fn name(&self) -> &str {
&self.name
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
// TODO: Wire to WASM plugin send function
tracing::warn!(
"WasmChannel '{}' (plugin: {}) send not yet connected: {}",
self.name,
self.plugin_name,
message.content
);
Ok(())
}
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
// TODO: Wire to WASM plugin receive/listen function
tracing::warn!(
"WasmChannel '{}' (plugin: {}) listen not yet connected",
self.name,
self.plugin_name,
);
Ok(())
}
}

63
src/plugins/wasm_tool.rs Normal file
View File

@ -0,0 +1,63 @@
//! Bridge between WASM plugins and the Tool trait.
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::Value;
/// A tool backed by a WASM plugin function.
pub struct WasmTool {
name: String,
description: String,
plugin_name: String,
function_name: String,
parameters_schema: Value,
}
impl WasmTool {
pub fn new(
name: String,
description: String,
plugin_name: String,
function_name: String,
parameters_schema: Value,
) -> Self {
Self {
name,
description,
plugin_name,
function_name,
parameters_schema,
}
}
}
#[async_trait]
impl Tool for WasmTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> Value {
self.parameters_schema.clone()
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
// TODO: Call into Extism plugin runtime
// For now, return a placeholder indicating the plugin system is available
// but not yet wired to actual WASM execution.
Ok(ToolResult {
success: false,
output: format!(
"[plugin:{}/{}] WASM execution not yet connected. Args: {}",
self.plugin_name,
self.function_name,
serde_json::to_string(&args).unwrap_or_default()
),
error: Some("WASM execution bridge not yet implemented".into()),
})
}
}

View File

@ -634,6 +634,53 @@ pub fn all_tools_with_runtime(
)));
}
// ── WASM plugin tools (requires plugins-wasm feature) ──
#[cfg(feature = "plugins-wasm")]
{
let plugin_dir = config.plugins.plugins_dir.clone();
let plugin_path = if plugin_dir.starts_with("~/") {
let home = directories::UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(&plugin_dir[2..])
} else {
std::path::PathBuf::from(&plugin_dir)
};
if plugin_path.exists() && config.plugins.enabled {
match crate::plugins::host::PluginHost::new(
plugin_path.parent().unwrap_or(&plugin_path),
) {
Ok(host) => {
let tool_manifests = host.tool_plugins();
let count = tool_manifests.len();
for manifest in tool_manifests {
tool_arcs.push(Arc::new(crate::plugins::wasm_tool::WasmTool::new(
manifest.name.clone(),
manifest.description.clone().unwrap_or_default(),
manifest.name.clone(),
"call".to_string(),
serde_json::json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Input for the plugin"
}
},
"required": ["input"]
}),
)));
}
tracing::info!("Loaded {count} WASM plugin tools");
}
Err(e) => {
tracing::warn!("Failed to load WASM plugins: {e}");
}
}
}
}
(boxed_registry_from_arcs(tool_arcs), delegate_handle)
}