Merge pull request #3827 from zeroclaw-labs/feat/plugin-wasm
feat(plugins): add WASM plugin system with Extism runtime
This commit is contained in:
commit
1341cfb296
10
.cargo/audit.toml
Normal file
10
.cargo/audit.toml
Normal 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
1245
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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
12
example-plugin/Cargo.toml
Normal 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"
|
||||
8
example-plugin/manifest.toml
Normal file
8
example-plugin/manifest.toml
Normal 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
42
example-plugin/src/lib.rs
Normal 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)
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
77
src/gateway/api_plugins.rs
Normal file
77
src/gateway/api_plugins.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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 ──
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
81
src/main.rs
81
src/main.rs
@ -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(())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
33
src/plugins/error.rs
Normal 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
325
src/plugins/host.rs
Normal 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
76
src/plugins/mod.rs
Normal 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,
|
||||
}
|
||||
44
src/plugins/wasm_channel.rs
Normal file
44
src/plugins/wasm_channel.rs
Normal 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
63
src/plugins/wasm_tool.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user