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:
parent
c051f0323e
commit
c857b64bb4
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
79
src/main.rs
79
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(())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
}
|
||||
48
src/plugins/host.rs
Normal file
48
src/plugins/host.rs
Normal 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
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,
|
||||
}
|
||||
3
src/plugins/wasm_channel.rs
Normal file
3
src/plugins/wasm_channel.rs
Normal 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
3
src/plugins/wasm_tool.rs
Normal file
@ -0,0 +1,3 @@
|
||||
//! Bridge between WASM plugins and the Tool trait.
|
||||
//!
|
||||
//! Placeholder — full implementation in a follow-up commit.
|
||||
Loading…
Reference in New Issue
Block a user