Skip to main content

apollo/
plugin.rs

1//! Plugin system — JSON-RPC 2.0 interface + lifecycle hooks.
2//!
3//! Two extension surfaces:
4//! 1. JSON-RPC methods (existing) — regular plugin methods
5//! 2. Lifecycle hooks — intercept tool calls, session events, etc.
6
7use crate::tools::ToolResult;
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15// ── Core Plugin trait ─────────────────────────────────────────────────────
16
17/// Plugin trait — implement this to extend aclaw with JSON-RPC methods
18#[async_trait]
19pub trait Plugin: Send + Sync {
20    /// Plugin name (e.g., "ai", "remote", "tools", "vibemania", "git")
21    fn name(&self) -> &str;
22
23    /// Plugin version
24    fn version(&self) -> &str;
25
26    /// List available methods this plugin provides
27    fn methods(&self) -> Vec<MethodSpec>;
28
29    /// Execute a method (JSON-RPC style)
30    async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError>;
31
32    /// Called after registration — gives the plugin a chance to register tools and hooks.
33    /// Default implementation does nothing.
34    async fn on_register(&self, _ctx: &mut PluginContext) {}
35}
36
37/// Method specification
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct MethodSpec {
40    pub name: String,
41    pub description: String,
42    pub params: HashMap<String, String>,
43    pub returns: String,
44}
45
46/// Plugin error
47#[derive(Debug, Serialize, Deserialize)]
48pub struct PluginError {
49    pub code: i32,
50    pub message: String,
51    pub data: Option<Value>,
52}
53
54impl PluginError {
55    pub fn new(code: i32, message: &str) -> Self {
56        Self {
57            code,
58            message: message.to_string(),
59            data: None,
60        }
61    }
62}
63
64// ── Lifecycle Hook System ─────────────────────────────────────────────────
65
66/// Events that lifecycle hooks can intercept
67#[derive(Debug, Clone)]
68pub enum LifecycleEvent {
69    /// Before a tool executes (tool_name, arguments_json)
70    BeforeToolCall(String, String),
71    /// After a tool completes (tool_name, arguments_json, result)
72    AfterToolCall(String, String, ToolResult),
73    /// A conversation session started (session_id)
74    SessionStart(String),
75    /// A conversation session ended (session_id)
76    SessionEnd(String),
77    /// Agent loop started (chat_id, message)
78    AgentStart(String, String),
79    /// Agent loop completed (chat_id, response)
80    AgentDone(String, String),
81}
82
83// Re-export hook decision types from agent hooks
84pub use crate::agent::hooks::HookDecision;
85
86/// A lifecycle hook — registered by plugins or core
87#[async_trait]
88pub trait LifecycleHook: Send + Sync {
89    fn name(&self) -> &str;
90
91    /// Called on any lifecycle event. Return Ok(()) to continue, Err to abort (tool-call only).
92    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()>;
93}
94
95/// Hook that can block tool execution based on custom logic
96#[async_trait]
97pub trait PreToolHook: Send + Sync {
98    fn name(&self) -> &str;
99    async fn before_tool_call(&self, name: &str, arguments: &str) -> HookDecision;
100}
101
102/// Hook that observes tool results (logging, metrics, auditing)
103#[async_trait]
104pub trait PostToolHook: Send + Sync {
105    fn name(&self) -> &str;
106    async fn after_tool_call(&self, name: &str, arguments: &str, result: &ToolResult);
107}
108
109/// Central hook manager — dispatches events to all registered hooks
110#[derive(Default)]
111pub struct HookManager {
112    lifecycle_hooks: Vec<Arc<dyn LifecycleHook>>,
113    pre_hooks: Vec<Arc<dyn PreToolHook>>,
114    post_hooks: Vec<Arc<dyn PostToolHook>>,
115}
116
117impl HookManager {
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    pub fn register_lifecycle(&mut self, hook: Arc<dyn LifecycleHook>) {
123        self.lifecycle_hooks.push(hook);
124    }
125
126    pub fn register_pre_tool(&mut self, hook: Arc<dyn PreToolHook>) {
127        self.pre_hooks.push(hook);
128    }
129
130    pub fn register_post_tool(&mut self, hook: Arc<dyn PostToolHook>) {
131        self.post_hooks.push(hook);
132    }
133
134    /// Fire event to all lifecycle hooks
135    pub async fn emit(&self, event: &LifecycleEvent) {
136        for hook in &self.lifecycle_hooks {
137            if let Err(e) = hook.on_event(event).await {
138                tracing::warn!(
139                    "LifecycleHook '{}' error on {:?}: {}",
140                    hook.name(),
141                    event,
142                    e
143                );
144            }
145        }
146    }
147
148    /// Run pre-tool hooks — first Block wins
149    pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
150        for hook in &self.pre_hooks {
151            match hook.before_tool_call(name, arguments).await {
152                HookDecision::Block(reason) => return HookDecision::Block(reason),
153                HookDecision::Allow => {}
154            }
155        }
156        HookDecision::Allow
157    }
158
159    /// Run post-tool hooks
160    pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
161        for hook in &self.post_hooks {
162            hook.after_tool_call(name, arguments, result).await;
163        }
164    }
165}
166
167/// Plugin context — passed to plugins during registration, allows tool registration
168#[derive(Default)]
169pub struct PluginContext {
170    /// Tools registered by plugins
171    pub tools: Vec<Arc<dyn crate::tools::Tool>>,
172    /// Hook manager for lifecycle hooks
173    pub hooks: HookManager,
174}
175
176impl PluginContext {
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Register a tool that the agent can call
182    pub fn register_tool(&mut self, tool: Arc<dyn crate::tools::Tool>) {
183        self.tools.push(tool);
184    }
185
186    /// Register a lifecycle hook
187    pub fn register_lifecycle_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
188        self.hooks.register_lifecycle(hook);
189    }
190
191    /// Register a pre-tool hook
192    pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
193        self.hooks.register_pre_tool(hook);
194    }
195
196    /// Register a post-tool hook
197    pub fn register_post_tool_hook(&mut self, hook: Arc<dyn PostToolHook>) {
198        self.hooks.register_post_tool(hook);
199    }
200}
201
202// ── Plugin Registry ───────────────────────────────────────────────────────
203
204/// Plugin registry — manage installed plugins
205pub struct PluginRegistry {
206    plugins: HashMap<String, Arc<dyn Plugin>>,
207    hooks: HookManager,
208}
209
210impl PluginRegistry {
211    pub fn new() -> Self {
212        Self {
213            plugins: HashMap::new(),
214            hooks: HookManager::new(),
215        }
216    }
217
218    /// Log discovered OpenClaw/Hermes plugins from workspace (host-agnostic).
219    pub fn ingest_host_plugins(&mut self, workspace: &std::path::Path, extra: &[PathBuf]) {
220        let found = crate::plugin_hosts::discover_host_plugins(workspace, extra);
221        for p in found {
222            tracing::info!(
223                "[plugin-host] {:?} {} {:?}",
224                p.kind,
225                p.name.as_deref().unwrap_or("?"),
226                p.path
227            );
228        }
229    }
230
231    /// Register a plugin and call its on_register with a PluginContext
232    pub async fn register(&mut self, plugin: Arc<dyn Plugin>) {
233        let name = plugin.name().to_string();
234        let mut ctx = PluginContext::default();
235        plugin.on_register(&mut ctx).await;
236        // Register any tools the plugin exposed during on_register
237        for tool in ctx.tools {
238            tracing::info!("[plugin] '{}' registered tool: {}", name, tool.name());
239        }
240        // Merge hooks
241        self.hooks.lifecycle_hooks.extend(ctx.hooks.lifecycle_hooks);
242        self.hooks.pre_hooks.extend(ctx.hooks.pre_hooks);
243        self.hooks.post_hooks.extend(ctx.hooks.post_hooks);
244        self.plugins.insert(name, plugin);
245    }
246
247    /// Register a plugin without async (for tests, legacy)
248    pub fn register_sync(&mut self, plugin: Arc<dyn Plugin>) {
249        self.plugins.insert(plugin.name().to_string(), plugin);
250    }
251
252    /// Register a lifecycle hook directly (not from a plugin)
253    pub fn register_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
254        self.hooks.register_lifecycle(hook);
255    }
256
257    /// Register a pre-tool hook directly (not from a plugin)
258    pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
259        self.hooks.register_pre_tool(hook);
260    }
261
262    /// Access the hook manager
263    pub fn hooks(&self) -> &HookManager {
264        &self.hooks
265    }
266
267    /// Call a plugin method
268    pub async fn call(
269        &self,
270        plugin: &str,
271        method: &str,
272        params: Value,
273    ) -> Result<Value, PluginError> {
274        let p = self
275            .plugins
276            .get(plugin)
277            .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
278        p.call(method, params).await
279    }
280
281    /// List all plugins
282    pub fn list(&self) -> Vec<String> {
283        self.plugins.keys().cloned().collect()
284    }
285
286    /// Get plugin info
287    pub fn info(&self, name: &str) -> Option<PluginInfo> {
288        self.plugins.get(name).map(|p| PluginInfo {
289            name: p.name().to_string(),
290            version: p.version().to_string(),
291            methods: p.methods(),
292        })
293    }
294
295    /// Emit lifecycle event to all registered hooks
296    pub async fn emit(&self, event: &LifecycleEvent) {
297        self.hooks.emit(event).await;
298    }
299
300    /// Run pre-tool checks
301    pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
302        self.hooks.check_pre_tool(name, arguments).await
303    }
304
305    /// Notify post-tool hooks
306    pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
307        self.hooks.notify_post_tool(name, arguments, result).await;
308    }
309}
310
311impl Default for PluginRegistry {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317// ── Built-in shell plugin (argv spawn, no sh -c) ─────────────────────────
318
319/// JSON-RPC plugin exposing `shell` when `policy.allow_plugin_shell` is enabled.
320pub struct ShellPlugin {
321    policy: crate::policy::ExecutionPolicy,
322}
323
324impl ShellPlugin {
325    pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
326        Self { policy }
327    }
328}
329
330#[async_trait]
331impl Plugin for ShellPlugin {
332    fn name(&self) -> &str {
333        "tools"
334    }
335
336    fn version(&self) -> &str {
337        "0.1.0"
338    }
339
340    fn methods(&self) -> Vec<MethodSpec> {
341        vec![MethodSpec {
342            name: "shell".to_string(),
343            description: "Execute shell command".to_string(),
344            params: {
345                let mut m = HashMap::new();
346                m.insert("cmd".to_string(), "string".to_string());
347                m
348            },
349            returns: "string".to_string(),
350        }]
351    }
352
353    async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
354        if method != "shell" {
355            return Err(PluginError::new(-32601, "Method not found"));
356        }
357        if !self.policy.allow_plugin_shell {
358            return Err(PluginError::new(
359                -32604,
360                "Plugin shell execution is disabled by policy",
361            ));
362        }
363
364        let cmd = params
365            .get("cmd")
366            .and_then(|v| v.as_str())
367            .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
368
369        if let Err(reason) = self.policy.check_shell_command(cmd) {
370            return Err(PluginError::new(-32604, &reason));
371        }
372
373        match crate::process_cmd::run_argv_command(cmd, 120).await {
374            Ok((output, ok)) => Ok(serde_json::json!({
375                "stdout": output,
376                "success": ok,
377            })),
378            Err(e) => Err(PluginError::new(-32603, &e.to_string())),
379        }
380    }
381}
382
383// ── Plugin Info ───────────────────────────────────────────────────────────
384
385#[derive(Debug, Serialize, Deserialize)]
386pub struct PluginInfo {
387    pub name: String,
388    pub version: String,
389    pub methods: Vec<MethodSpec>,
390}
391
392// ── Built-in lifecycle hooks ──────────────────────────────────────────────
393
394/// Append short session notes on agent completion.
395pub struct SessionNoteLifecycleHook {
396    workspace: std::path::PathBuf,
397}
398
399impl SessionNoteLifecycleHook {
400    pub fn new(workspace: std::path::PathBuf) -> Self {
401        Self { workspace }
402    }
403}
404
405#[async_trait]
406impl LifecycleHook for SessionNoteLifecycleHook {
407    fn name(&self) -> &str {
408        "session_note"
409    }
410
411    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
412        if let LifecycleEvent::AgentDone(chat_id, response) = event {
413            let redacted = crate::redaction::redact_text(response);
414            let preview: String = redacted.chars().take(200).collect();
415            if !preview.is_empty() {
416                let _ = crate::memory::session_note::append_session_note(
417                    &self.workspace,
418                    chat_id,
419                    &preview,
420                );
421            }
422        }
423        Ok(())
424    }
425}
426
427/// Logging hook — traces every lifecycle event
428pub struct LoggingLifecycleHook;
429
430#[async_trait]
431impl LifecycleHook for LoggingLifecycleHook {
432    fn name(&self) -> &str {
433        "logging"
434    }
435
436    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
437        match event {
438            LifecycleEvent::BeforeToolCall(name, args) => {
439                tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
440            }
441            LifecycleEvent::AfterToolCall(name, _args, result) => {
442                tracing::debug!(
443                    "[hook] after_tool {} is_error:{} len:{}",
444                    name,
445                    result.is_error,
446                    result.output.len()
447                );
448            }
449            LifecycleEvent::SessionStart(id) => {
450                tracing::info!("[hook] session_start {}", id);
451            }
452            LifecycleEvent::SessionEnd(id) => {
453                tracing::info!("[hook] session_end {}", id);
454            }
455            LifecycleEvent::AgentStart(chat_id, msg) => {
456                tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
457            }
458            LifecycleEvent::AgentDone(chat_id, response) => {
459                tracing::debug!(
460                    "[hook] agent_done {} response_len:{}",
461                    chat_id,
462                    response.len()
463                );
464            }
465        }
466        Ok(())
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use serde_json::json;
474
475    struct TestPlugin;
476
477    #[async_trait]
478    impl Plugin for TestPlugin {
479        fn name(&self) -> &str {
480            "test"
481        }
482
483        fn version(&self) -> &str {
484            "0.1.0"
485        }
486
487        fn methods(&self) -> Vec<MethodSpec> {
488            vec![MethodSpec {
489                name: "echo".to_string(),
490                description: "Echo input".to_string(),
491                params: HashMap::new(),
492                returns: "object".to_string(),
493            }]
494        }
495
496        async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
497            match method {
498                "echo" => Ok(json!({ "result": params })),
499                _ => Err(PluginError::new(-32601, "Method not found")),
500            }
501        }
502    }
503
504    #[tokio::test]
505    async fn shell_plugin_uses_argv_not_shell_when_allowed() {
506        let policy = crate::policy::ExecutionPolicy {
507            allow_plugin_shell: true,
508            ..Default::default()
509        };
510        let plugin = ShellPlugin::new(policy);
511        let result = plugin
512            .call("shell", json!({ "cmd": "echo plugin_ok" }))
513            .await
514            .unwrap();
515        assert_eq!(result["success"], true);
516        assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
517    }
518
519    #[tokio::test]
520    async fn shell_plugin_denied_when_policy_off() {
521        let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
522            allow_plugin_shell: false,
523            ..Default::default()
524        });
525        let err = plugin
526            .call("shell", json!({ "cmd": "echo x" }))
527            .await
528            .unwrap_err();
529        assert_eq!(err.code, -32604);
530    }
531
532    #[tokio::test]
533    async fn test_plugin_call() {
534        let mut registry = PluginRegistry::new();
535        registry.register_sync(Arc::new(TestPlugin));
536
537        let result = registry
538            .call("test", "echo", json!({ "code": "fn main() {}" }))
539            .await
540            .unwrap();
541
542        assert!(result.get("result").is_some());
543    }
544
545    #[tokio::test]
546    async fn test_hook_manager_emit() {
547        let mut manager = HookManager::new();
548        manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
549        let _ = manager.check_pre_tool("test", "{}").await;
550        manager
551            .notify_post_tool("test", "{}", &ToolResult::success("ok"))
552            .await;
553        // No crash = success
554    }
555
556    #[tokio::test]
557    async fn test_plugin_context_register_tool() {
558        let mut ctx = PluginContext::new();
559        // Just verify no crash
560        ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
561        assert!(ctx.hooks.lifecycle_hooks.len() == 1);
562    }
563}