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        match crate::process_cmd::run_argv_command(cmd, 120).await {
370            Ok((output, ok)) => Ok(serde_json::json!({
371                "stdout": output,
372                "success": ok,
373            })),
374            Err(e) => Err(PluginError::new(-32603, &e.to_string())),
375        }
376    }
377}
378
379// ── Plugin Info ───────────────────────────────────────────────────────────
380
381#[derive(Debug, Serialize, Deserialize)]
382pub struct PluginInfo {
383    pub name: String,
384    pub version: String,
385    pub methods: Vec<MethodSpec>,
386}
387
388// ── Built-in lifecycle hooks ──────────────────────────────────────────────
389
390/// Append short session notes on agent completion.
391pub struct SessionNoteLifecycleHook {
392    workspace: std::path::PathBuf,
393}
394
395impl SessionNoteLifecycleHook {
396    pub fn new(workspace: std::path::PathBuf) -> Self {
397        Self { workspace }
398    }
399}
400
401#[async_trait]
402impl LifecycleHook for SessionNoteLifecycleHook {
403    fn name(&self) -> &str {
404        "session_note"
405    }
406
407    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
408        if let LifecycleEvent::AgentDone(chat_id, response) = event {
409            let redacted = crate::redaction::redact_text(response);
410            let preview: String = redacted.chars().take(200).collect();
411            if !preview.is_empty() {
412                let _ = crate::memory::session_note::append_session_note(
413                    &self.workspace,
414                    chat_id,
415                    &preview,
416                );
417            }
418        }
419        Ok(())
420    }
421}
422
423/// Logging hook — traces every lifecycle event
424pub struct LoggingLifecycleHook;
425
426#[async_trait]
427impl LifecycleHook for LoggingLifecycleHook {
428    fn name(&self) -> &str {
429        "logging"
430    }
431
432    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
433        match event {
434            LifecycleEvent::BeforeToolCall(name, args) => {
435                tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
436            }
437            LifecycleEvent::AfterToolCall(name, _args, result) => {
438                tracing::debug!(
439                    "[hook] after_tool {} is_error:{} len:{}",
440                    name,
441                    result.is_error,
442                    result.output.len()
443                );
444            }
445            LifecycleEvent::SessionStart(id) => {
446                tracing::info!("[hook] session_start {}", id);
447            }
448            LifecycleEvent::SessionEnd(id) => {
449                tracing::info!("[hook] session_end {}", id);
450            }
451            LifecycleEvent::AgentStart(chat_id, msg) => {
452                tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
453            }
454            LifecycleEvent::AgentDone(chat_id, response) => {
455                tracing::debug!(
456                    "[hook] agent_done {} response_len:{}",
457                    chat_id,
458                    response.len()
459                );
460            }
461        }
462        Ok(())
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use serde_json::json;
470
471    struct TestPlugin;
472
473    #[async_trait]
474    impl Plugin for TestPlugin {
475        fn name(&self) -> &str {
476            "test"
477        }
478
479        fn version(&self) -> &str {
480            "0.1.0"
481        }
482
483        fn methods(&self) -> Vec<MethodSpec> {
484            vec![MethodSpec {
485                name: "echo".to_string(),
486                description: "Echo input".to_string(),
487                params: HashMap::new(),
488                returns: "object".to_string(),
489            }]
490        }
491
492        async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
493            match method {
494                "echo" => Ok(json!({ "result": params })),
495                _ => Err(PluginError::new(-32601, "Method not found")),
496            }
497        }
498    }
499
500    #[tokio::test]
501    async fn shell_plugin_uses_argv_not_shell_when_allowed() {
502        let policy = crate::policy::ExecutionPolicy {
503            allow_plugin_shell: true,
504            ..Default::default()
505        };
506        let plugin = ShellPlugin::new(policy);
507        let result = plugin
508            .call("shell", json!({ "cmd": "echo plugin_ok" }))
509            .await
510            .unwrap();
511        assert_eq!(result["success"], true);
512        assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
513    }
514
515    #[tokio::test]
516    async fn shell_plugin_denied_when_policy_off() {
517        let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
518            allow_plugin_shell: false,
519            ..Default::default()
520        });
521        let err = plugin
522            .call("shell", json!({ "cmd": "echo x" }))
523            .await
524            .unwrap_err();
525        assert_eq!(err.code, -32604);
526    }
527
528    #[tokio::test]
529    async fn test_plugin_call() {
530        let mut registry = PluginRegistry::new();
531        registry.register_sync(Arc::new(TestPlugin));
532
533        let result = registry
534            .call("test", "echo", json!({ "code": "fn main() {}" }))
535            .await
536            .unwrap();
537
538        assert!(result.get("result").is_some());
539    }
540
541    #[tokio::test]
542    async fn test_hook_manager_emit() {
543        let mut manager = HookManager::new();
544        manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
545        let _ = manager.check_pre_tool("test", "{}").await;
546        manager
547            .notify_post_tool("test", "{}", &ToolResult::success("ok"))
548            .await;
549        // No crash = success
550    }
551
552    #[tokio::test]
553    async fn test_plugin_context_register_tool() {
554        let mut ctx = PluginContext::new();
555        // Just verify no crash
556        ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
557        assert!(ctx.hooks.lifecycle_hooks.len() == 1);
558    }
559}