feat(plugins): add Extism dependency, feature flag, and plugin module skeleton

Introduce the WASM plugin system foundation:
- Add extism 1.9 as an optional dependency behind `plugins-wasm` feature
- Create `src/plugins/` module with manifest types, error types, and stub host
- Add `Plugin` CLI subcommands (list, install, remove, info) behind cfg gate
- Add `PluginsConfig` to the config schema with sensible defaults

All plugin code is behind `#[cfg(feature = "plugins-wasm")]` so the default
build is unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
argenis de la rosa 2026-03-17 11:54:20 -04:00
parent c051f0323e
commit c857b64bb4
11 changed files with 303 additions and 7 deletions

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

@ -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

@ -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

@ -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(())
}
},
}
}

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),
}

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

@ -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<Self, PluginError> {
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<PluginInfo> {
Vec::new()
}
/// Get info about a specific plugin.
pub fn get_plugin(&self, _name: &str) -> Option<PluginInfo> {
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
}
}

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,3 @@
//! Bridge between WASM plugins and the Channel trait.
//!
//! Placeholder — full implementation in a follow-up commit.

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

@ -0,0 +1,3 @@
//! Bridge between WASM plugins and the Tool trait.
//!
//! Placeholder — full implementation in a follow-up commit.