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    /// Access the hook manager
258    pub fn hooks(&self) -> &HookManager {
259        &self.hooks
260    }
261
262    /// Call a plugin method
263    pub async fn call(
264        &self,
265        plugin: &str,
266        method: &str,
267        params: Value,
268    ) -> Result<Value, PluginError> {
269        let p = self
270            .plugins
271            .get(plugin)
272            .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
273        p.call(method, params).await
274    }
275
276    /// List all plugins
277    pub fn list(&self) -> Vec<String> {
278        self.plugins.keys().cloned().collect()
279    }
280
281    /// Get plugin info
282    pub fn info(&self, name: &str) -> Option<PluginInfo> {
283        self.plugins.get(name).map(|p| PluginInfo {
284            name: p.name().to_string(),
285            version: p.version().to_string(),
286            methods: p.methods(),
287        })
288    }
289
290    /// Emit lifecycle event to all registered hooks
291    pub async fn emit(&self, event: &LifecycleEvent) {
292        self.hooks.emit(event).await;
293    }
294
295    /// Run pre-tool checks
296    pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
297        self.hooks.check_pre_tool(name, arguments).await
298    }
299
300    /// Notify post-tool hooks
301    pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
302        self.hooks.notify_post_tool(name, arguments, result).await;
303    }
304}
305
306impl Default for PluginRegistry {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312// ── Built-in shell plugin (argv spawn, no sh -c) ─────────────────────────
313
314/// JSON-RPC plugin exposing `shell` when `policy.allow_plugin_shell` is enabled.
315pub struct ShellPlugin {
316    policy: crate::policy::ExecutionPolicy,
317}
318
319impl ShellPlugin {
320    pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
321        Self { policy }
322    }
323}
324
325#[async_trait]
326impl Plugin for ShellPlugin {
327    fn name(&self) -> &str {
328        "tools"
329    }
330
331    fn version(&self) -> &str {
332        "0.1.0"
333    }
334
335    fn methods(&self) -> Vec<MethodSpec> {
336        vec![MethodSpec {
337            name: "shell".to_string(),
338            description: "Execute shell command".to_string(),
339            params: {
340                let mut m = HashMap::new();
341                m.insert("cmd".to_string(), "string".to_string());
342                m
343            },
344            returns: "string".to_string(),
345        }]
346    }
347
348    async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
349        if method != "shell" {
350            return Err(PluginError::new(-32601, "Method not found"));
351        }
352        if !self.policy.allow_plugin_shell {
353            return Err(PluginError::new(
354                -32604,
355                "Plugin shell execution is disabled by policy",
356            ));
357        }
358
359        let cmd = params
360            .get("cmd")
361            .and_then(|v| v.as_str())
362            .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
363
364        match crate::process_cmd::run_argv_command(cmd, 120).await {
365            Ok((output, ok)) => Ok(serde_json::json!({
366                "stdout": output,
367                "success": ok,
368            })),
369            Err(e) => Err(PluginError::new(-32603, &e.to_string())),
370        }
371    }
372}
373
374// ── Plugin Info ───────────────────────────────────────────────────────────
375
376#[derive(Debug, Serialize, Deserialize)]
377pub struct PluginInfo {
378    pub name: String,
379    pub version: String,
380    pub methods: Vec<MethodSpec>,
381}
382
383// ── Built-in lifecycle hooks ──────────────────────────────────────────────
384
385/// Append short session notes on agent completion.
386pub struct SessionNoteLifecycleHook {
387    workspace: std::path::PathBuf,
388}
389
390impl SessionNoteLifecycleHook {
391    pub fn new(workspace: std::path::PathBuf) -> Self {
392        Self { workspace }
393    }
394}
395
396#[async_trait]
397impl LifecycleHook for SessionNoteLifecycleHook {
398    fn name(&self) -> &str {
399        "session_note"
400    }
401
402    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
403        if let LifecycleEvent::AgentDone(chat_id, response) = event {
404            let redacted = crate::redaction::redact_text(response);
405            let preview: String = redacted.chars().take(200).collect();
406            if !preview.is_empty() {
407                let _ = crate::memory::session_note::append_session_note(
408                    &self.workspace,
409                    chat_id,
410                    &preview,
411                );
412            }
413        }
414        Ok(())
415    }
416}
417
418/// Logging hook — traces every lifecycle event
419pub struct LoggingLifecycleHook;
420
421#[async_trait]
422impl LifecycleHook for LoggingLifecycleHook {
423    fn name(&self) -> &str {
424        "logging"
425    }
426
427    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
428        match event {
429            LifecycleEvent::BeforeToolCall(name, args) => {
430                tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
431            }
432            LifecycleEvent::AfterToolCall(name, _args, result) => {
433                tracing::debug!(
434                    "[hook] after_tool {} is_error:{} len:{}",
435                    name,
436                    result.is_error,
437                    result.output.len()
438                );
439            }
440            LifecycleEvent::SessionStart(id) => {
441                tracing::info!("[hook] session_start {}", id);
442            }
443            LifecycleEvent::SessionEnd(id) => {
444                tracing::info!("[hook] session_end {}", id);
445            }
446            LifecycleEvent::AgentStart(chat_id, msg) => {
447                tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
448            }
449            LifecycleEvent::AgentDone(chat_id, response) => {
450                tracing::debug!(
451                    "[hook] agent_done {} response_len:{}",
452                    chat_id,
453                    response.len()
454                );
455            }
456        }
457        Ok(())
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use serde_json::json;
465
466    struct TestPlugin;
467
468    #[async_trait]
469    impl Plugin for TestPlugin {
470        fn name(&self) -> &str {
471            "test"
472        }
473
474        fn version(&self) -> &str {
475            "0.1.0"
476        }
477
478        fn methods(&self) -> Vec<MethodSpec> {
479            vec![MethodSpec {
480                name: "echo".to_string(),
481                description: "Echo input".to_string(),
482                params: HashMap::new(),
483                returns: "object".to_string(),
484            }]
485        }
486
487        async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
488            match method {
489                "echo" => Ok(json!({ "result": params })),
490                _ => Err(PluginError::new(-32601, "Method not found")),
491            }
492        }
493    }
494
495    #[tokio::test]
496    async fn shell_plugin_uses_argv_not_shell_when_allowed() {
497        let policy = crate::policy::ExecutionPolicy {
498            allow_plugin_shell: true,
499            ..Default::default()
500        };
501        let plugin = ShellPlugin::new(policy);
502        let result = plugin
503            .call("shell", json!({ "cmd": "echo plugin_ok" }))
504            .await
505            .unwrap();
506        assert_eq!(result["success"], true);
507        assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
508    }
509
510    #[tokio::test]
511    async fn shell_plugin_denied_when_policy_off() {
512        let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
513            allow_plugin_shell: false,
514            ..Default::default()
515        });
516        let err = plugin
517            .call("shell", json!({ "cmd": "echo x" }))
518            .await
519            .unwrap_err();
520        assert_eq!(err.code, -32604);
521    }
522
523    #[tokio::test]
524    async fn test_plugin_call() {
525        let mut registry = PluginRegistry::new();
526        registry.register_sync(Arc::new(TestPlugin));
527
528        let result = registry
529            .call("test", "echo", json!({ "code": "fn main() {}" }))
530            .await
531            .unwrap();
532
533        assert!(result.get("result").is_some());
534    }
535
536    #[tokio::test]
537    async fn test_hook_manager_emit() {
538        let mut manager = HookManager::new();
539        manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
540        let _ = manager.check_pre_tool("test", "{}").await;
541        manager
542            .notify_post_tool("test", "{}", &ToolResult::success("ok"))
543            .await;
544        // No crash = success
545    }
546
547    #[tokio::test]
548    async fn test_plugin_context_register_tool() {
549        let mut ctx = PluginContext::new();
550        // Just verify no crash
551        ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
552        assert!(ctx.hooks.lifecycle_hooks.len() == 1);
553    }
554}