diff --git a/src/gateway/api_plugins.rs b/src/gateway/api_plugins.rs new file mode 100644 index 000000000..36891c49c --- /dev/null +++ b/src/gateway/api_plugins.rs @@ -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, + 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 = 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() + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 112e6394a..63c37333b 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -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 ── diff --git a/src/main.rs b/src/main.rs index c8698512d..e574ca3d0 100644 --- a/src/main.rs +++ b/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; diff --git a/src/plugins/host.rs b/src/plugins/host.rs index 5b31c4e89..87de3f58a 100644 --- a/src/plugins/host.rs +++ b/src/plugins/host.rs @@ -192,3 +192,134 @@ impl PluginHost { &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()); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c4e44143c..7c5ae54d0 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -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) }