diff --git a/Cargo.toml b/Cargo.toml index 2c479d638..232b866ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/config/mod.rs b/src/config/mod.rs index 7130c6dce..c999783b5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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(channel: Option<&T>) -> (&'static str, bool) { diff --git a/src/config/schema.rs b/src/config/schema.rs index 3cc17142d..dc9b2ae0e 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -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(); diff --git a/src/lib.rs b/src/lib.rs index 7f11fd009..73d21e44f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/src/main.rs b/src/main.rs index 5df2d3fdd..c8698512d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -528,6 +528,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 +1354,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(()) + } + }, } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 26606ba0a..10e931412 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -192,6 +192,7 @@ pub async fn run_wizard(force: bool) -> Result { 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?; diff --git a/src/plugins/error.rs b/src/plugins/error.rs new file mode 100644 index 000000000..2c7981127 --- /dev/null +++ b/src/plugins/error.rs @@ -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), +} diff --git a/src/plugins/host.rs b/src/plugins/host.rs new file mode 100644 index 000000000..297adab51 --- /dev/null +++ b/src/plugins/host.rs @@ -0,0 +1,48 @@ +//! Plugin host: discovery, loading, lifecycle management. + +use super::error::PluginError; +use super::PluginInfo; +use std::path::{Path, PathBuf}; + +/// Manages the lifecycle of WASM plugins. +pub struct PluginHost { + plugins_dir: PathBuf, +} + +impl PluginHost { + /// Create a new plugin host with the given workspace directory. + pub fn new(workspace_dir: &Path) -> Result { + let plugins_dir = workspace_dir.join("plugins"); + if !plugins_dir.exists() { + std::fs::create_dir_all(&plugins_dir)?; + } + Ok(Self { plugins_dir }) + } + + /// List all discovered plugins. + pub fn list_plugins(&self) -> Vec { + Vec::new() + } + + /// Get info about a specific plugin. + pub fn get_plugin(&self, _name: &str) -> Option { + None + } + + /// Install a plugin from a directory path. + pub fn install(&mut self, _source: &str) -> Result<(), PluginError> { + Err(PluginError::LoadFailed( + "plugin host not yet fully implemented".into(), + )) + } + + /// Remove a plugin by name. + pub fn remove(&mut self, name: &str) -> Result<(), PluginError> { + Err(PluginError::NotFound(name.to_string())) + } + + /// Returns the plugins directory path. + pub fn plugins_dir(&self) -> &Path { + &self.plugins_dir + } +} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs new file mode 100644 index 000000000..56c2fc500 --- /dev/null +++ b/src/plugins/mod.rs @@ -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, + /// Author name or organization + pub author: Option, + /// Path to the .wasm file (relative to manifest) + pub wasm_path: String, + /// Capabilities this plugin provides + pub capabilities: Vec, + /// Permissions this plugin requests + #[serde(default)] + pub permissions: Vec, +} + +/// 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, + pub capabilities: Vec, + pub permissions: Vec, + pub wasm_path: PathBuf, + pub loaded: bool, +} diff --git a/src/plugins/wasm_channel.rs b/src/plugins/wasm_channel.rs new file mode 100644 index 000000000..7762c3539 --- /dev/null +++ b/src/plugins/wasm_channel.rs @@ -0,0 +1,3 @@ +//! Bridge between WASM plugins and the Channel trait. +//! +//! Placeholder — full implementation in a follow-up commit. diff --git a/src/plugins/wasm_tool.rs b/src/plugins/wasm_tool.rs new file mode 100644 index 000000000..c958436b1 --- /dev/null +++ b/src/plugins/wasm_tool.rs @@ -0,0 +1,3 @@ +//! Bridge between WASM plugins and the Tool trait. +//! +//! Placeholder — full implementation in a follow-up commit.