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    /// Channels registered by plugins, keyed by the name `--channel` selects.
175    pub channels: Vec<(String, crate::channels::ChannelBuilder)>,
176}
177
178impl PluginContext {
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// Register a tool that the agent can call
184    pub fn register_tool(&mut self, tool: Arc<dyn crate::tools::Tool>) {
185        self.tools.push(tool);
186    }
187
188    /// Register a channel the user can select with `--channel <name>`.
189    ///
190    /// This is the whole integration: no feature flag, no `mod.rs` entry, no
191    /// arm in the `serve` match. The builder receives the same
192    /// `[channel].settings` a built-in would.
193    pub fn register_channel<F>(&mut self, name: impl Into<String>, builder: F)
194    where
195        F: Fn(
196                &crate::channels::ChannelSettings,
197            ) -> anyhow::Result<Box<dyn crate::channels::Channel>>
198            + Send
199            + Sync
200            + 'static,
201    {
202        self.channels.push((name.into(), Arc::new(builder)));
203    }
204
205    /// Register a lifecycle hook
206    pub fn register_lifecycle_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
207        self.hooks.register_lifecycle(hook);
208    }
209
210    /// Register a pre-tool hook
211    pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
212        self.hooks.register_pre_tool(hook);
213    }
214
215    /// Register a post-tool hook
216    pub fn register_post_tool_hook(&mut self, hook: Arc<dyn PostToolHook>) {
217        self.hooks.register_post_tool(hook);
218    }
219}
220
221// ── Plugin Registry ───────────────────────────────────────────────────────
222
223/// Plugin registry — manage installed plugins
224pub struct PluginRegistry {
225    plugins: HashMap<String, Arc<dyn Plugin>>,
226    hooks: HookManager,
227    channels: crate::channels::ChannelRegistry,
228    tools: Vec<Arc<dyn crate::tools::Tool>>,
229    host_plugins: Vec<crate::plugin_hosts::HostPluginEntry>,
230}
231
232impl PluginRegistry {
233    pub fn new() -> Self {
234        Self {
235            plugins: HashMap::new(),
236            hooks: HookManager::new(),
237            channels: crate::channels::ChannelRegistry::with_builtins(),
238            tools: Vec::new(),
239            host_plugins: Vec::new(),
240        }
241    }
242
243    /// Tools contributed by plugins during `on_register`.
244    ///
245    /// `LoopRunner::with_plugin_registry` appends these to the agent's tool
246    /// list. They were previously logged and dropped, so a plugin could
247    /// "register" a tool the agent could never call.
248    pub fn tools(&self) -> &[Arc<dyn crate::tools::Tool>] {
249        &self.tools
250    }
251
252    /// Host plugin directories found by `ingest_host_plugins`.
253    pub fn host_plugins(&self) -> &[crate::plugin_hosts::HostPluginEntry] {
254        &self.host_plugins
255    }
256
257    /// Every channel that can be selected by name — built-ins plus whatever
258    /// plugins registered during `on_register`.
259    pub fn channels(&self) -> &crate::channels::ChannelRegistry {
260        &self.channels
261    }
262
263    /// Discover OpenClaw/Hermes plugin directories and keep them.
264    ///
265    /// Kept, not just logged: `host_plugins()` is what feeds SKILL.md-bearing
266    /// plugin directories into skill discovery. A `HostPluginEntry` carries no
267    /// entrypoint, so a plugin whose manifest declares neither a skill nor an
268    /// in-process registration still does nothing — see `docs/` before adding
269    /// an execution model for those.
270    pub fn ingest_host_plugins(&mut self, workspace: &std::path::Path, extra: &[PathBuf]) {
271        let found = crate::plugin_hosts::discover_host_plugins(workspace, extra);
272        for p in &found {
273            tracing::info!(
274                "[plugin-host] {:?} {} {:?}",
275                p.kind,
276                p.name.as_deref().unwrap_or("?"),
277                p.path
278            );
279        }
280        self.host_plugins.extend(found);
281    }
282
283    /// Register a plugin and call its on_register with a PluginContext
284    pub async fn register(&mut self, plugin: Arc<dyn Plugin>) {
285        let name = plugin.name().to_string();
286        let mut ctx = PluginContext::default();
287        plugin.on_register(&mut ctx).await;
288        // Keep the tools the plugin exposed — `tools()` hands them to the agent.
289        for tool in ctx.tools {
290            tracing::info!("[plugin] '{}' registered tool: {}", name, tool.name());
291            self.tools.push(tool);
292        }
293        // Merge channels so `--channel <name>` can reach them.
294        for (channel_name, builder) in ctx.channels {
295            tracing::info!("[plugin] '{}' registered channel: {}", name, channel_name);
296            self.channels.register_builder(channel_name, builder);
297        }
298        // Merge hooks
299        self.hooks.lifecycle_hooks.extend(ctx.hooks.lifecycle_hooks);
300        self.hooks.pre_hooks.extend(ctx.hooks.pre_hooks);
301        self.hooks.post_hooks.extend(ctx.hooks.post_hooks);
302        self.plugins.insert(name, plugin);
303    }
304
305    /// Register a plugin without async (for tests, legacy)
306    pub fn register_sync(&mut self, plugin: Arc<dyn Plugin>) {
307        self.plugins.insert(plugin.name().to_string(), plugin);
308    }
309
310    /// Register a lifecycle hook directly (not from a plugin)
311    pub fn register_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
312        self.hooks.register_lifecycle(hook);
313    }
314
315    /// Register a pre-tool hook directly (not from a plugin)
316    pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
317        self.hooks.register_pre_tool(hook);
318    }
319
320    /// Access the hook manager
321    pub fn hooks(&self) -> &HookManager {
322        &self.hooks
323    }
324
325    /// Call a plugin method
326    pub async fn call(
327        &self,
328        plugin: &str,
329        method: &str,
330        params: Value,
331    ) -> Result<Value, PluginError> {
332        let p = self
333            .plugins
334            .get(plugin)
335            .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
336        p.call(method, params).await
337    }
338
339    /// List all plugins
340    pub fn list(&self) -> Vec<String> {
341        self.plugins.keys().cloned().collect()
342    }
343
344    /// Get plugin info
345    pub fn info(&self, name: &str) -> Option<PluginInfo> {
346        self.plugins.get(name).map(|p| PluginInfo {
347            name: p.name().to_string(),
348            version: p.version().to_string(),
349            methods: p.methods(),
350        })
351    }
352
353    /// Emit lifecycle event to all registered hooks
354    pub async fn emit(&self, event: &LifecycleEvent) {
355        self.hooks.emit(event).await;
356    }
357
358    /// Run pre-tool checks
359    pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
360        self.hooks.check_pre_tool(name, arguments).await
361    }
362
363    /// Notify post-tool hooks
364    pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
365        self.hooks.notify_post_tool(name, arguments, result).await;
366    }
367}
368
369impl Default for PluginRegistry {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375// ── Built-in shell plugin (argv spawn, no sh -c) ─────────────────────────
376
377/// JSON-RPC plugin exposing `shell` when `policy.allow_plugin_shell` is enabled.
378pub struct ShellPlugin {
379    policy: crate::policy::ExecutionPolicy,
380}
381
382impl ShellPlugin {
383    pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
384        Self { policy }
385    }
386}
387
388#[async_trait]
389impl Plugin for ShellPlugin {
390    fn name(&self) -> &str {
391        "tools"
392    }
393
394    fn version(&self) -> &str {
395        "0.1.0"
396    }
397
398    fn methods(&self) -> Vec<MethodSpec> {
399        vec![MethodSpec {
400            name: "shell".to_string(),
401            description: "Execute shell command".to_string(),
402            params: {
403                let mut m = HashMap::new();
404                m.insert("cmd".to_string(), "string".to_string());
405                m
406            },
407            returns: "string".to_string(),
408        }]
409    }
410
411    async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
412        if method != "shell" {
413            return Err(PluginError::new(-32601, "Method not found"));
414        }
415        if !self.policy.allow_plugin_shell {
416            return Err(PluginError::new(
417                -32604,
418                "Plugin shell execution is disabled by policy",
419            ));
420        }
421
422        let cmd = params
423            .get("cmd")
424            .and_then(|v| v.as_str())
425            .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
426
427        if let Err(reason) = self.policy.check_shell_command(cmd) {
428            return Err(PluginError::new(-32604, &reason));
429        }
430
431        match crate::process_cmd::run_argv_command(cmd, 120).await {
432            Ok((output, ok)) => Ok(serde_json::json!({
433                "stdout": output,
434                "success": ok,
435            })),
436            Err(e) => Err(PluginError::new(-32603, &e.to_string())),
437        }
438    }
439}
440
441// ── Plugin Info ───────────────────────────────────────────────────────────
442
443#[derive(Debug, Serialize, Deserialize)]
444pub struct PluginInfo {
445    pub name: String,
446    pub version: String,
447    pub methods: Vec<MethodSpec>,
448}
449
450// ── Built-in lifecycle hooks ──────────────────────────────────────────────
451
452/// Append short session notes on agent completion.
453pub struct SessionNoteLifecycleHook {
454    workspace: std::path::PathBuf,
455}
456
457impl SessionNoteLifecycleHook {
458    pub fn new(workspace: std::path::PathBuf) -> Self {
459        Self { workspace }
460    }
461}
462
463#[async_trait]
464impl LifecycleHook for SessionNoteLifecycleHook {
465    fn name(&self) -> &str {
466        "session_note"
467    }
468
469    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
470        if let LifecycleEvent::AgentDone(chat_id, response) = event {
471            let redacted = crate::redaction::redact_text(response);
472            let preview: String = redacted.chars().take(200).collect();
473            if !preview.is_empty() {
474                let _ = crate::memory::session_note::append_session_note(
475                    &self.workspace,
476                    chat_id,
477                    &preview,
478                );
479            }
480        }
481        Ok(())
482    }
483}
484
485/// Logging hook — traces every lifecycle event
486pub struct LoggingLifecycleHook;
487
488#[async_trait]
489impl LifecycleHook for LoggingLifecycleHook {
490    fn name(&self) -> &str {
491        "logging"
492    }
493
494    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
495        match event {
496            LifecycleEvent::BeforeToolCall(name, args) => {
497                tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
498            }
499            LifecycleEvent::AfterToolCall(name, _args, result) => {
500                tracing::debug!(
501                    "[hook] after_tool {} is_error:{} len:{}",
502                    name,
503                    result.is_error,
504                    result.output.len()
505                );
506            }
507            LifecycleEvent::SessionStart(id) => {
508                tracing::info!("[hook] session_start {}", id);
509            }
510            LifecycleEvent::SessionEnd(id) => {
511                tracing::info!("[hook] session_end {}", id);
512            }
513            LifecycleEvent::AgentStart(chat_id, msg) => {
514                tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
515            }
516            LifecycleEvent::AgentDone(chat_id, response) => {
517                tracing::debug!(
518                    "[hook] agent_done {} response_len:{}",
519                    chat_id,
520                    response.len()
521                );
522            }
523        }
524        Ok(())
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use serde_json::json;
532
533    struct TestPlugin;
534
535    #[async_trait]
536    impl Plugin for TestPlugin {
537        fn name(&self) -> &str {
538            "test"
539        }
540
541        fn version(&self) -> &str {
542            "0.1.0"
543        }
544
545        fn methods(&self) -> Vec<MethodSpec> {
546            vec![MethodSpec {
547                name: "echo".to_string(),
548                description: "Echo input".to_string(),
549                params: HashMap::new(),
550                returns: "object".to_string(),
551            }]
552        }
553
554        async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
555            match method {
556                "echo" => Ok(json!({ "result": params })),
557                _ => Err(PluginError::new(-32601, "Method not found")),
558            }
559        }
560    }
561
562    #[tokio::test]
563    async fn shell_plugin_uses_argv_not_shell_when_allowed() {
564        let policy = crate::policy::ExecutionPolicy {
565            allow_plugin_shell: true,
566            ..Default::default()
567        };
568        let plugin = ShellPlugin::new(policy);
569        let result = plugin
570            .call("shell", json!({ "cmd": "echo plugin_ok" }))
571            .await
572            .unwrap();
573        assert_eq!(result["success"], true);
574        assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
575    }
576
577    #[tokio::test]
578    async fn shell_plugin_denied_when_policy_off() {
579        let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
580            allow_plugin_shell: false,
581            ..Default::default()
582        });
583        let err = plugin
584            .call("shell", json!({ "cmd": "echo x" }))
585            .await
586            .unwrap_err();
587        assert_eq!(err.code, -32604);
588    }
589
590    #[tokio::test]
591    async fn test_plugin_call() {
592        let mut registry = PluginRegistry::new();
593        registry.register_sync(Arc::new(TestPlugin));
594
595        let result = registry
596            .call("test", "echo", json!({ "code": "fn main() {}" }))
597            .await
598            .unwrap();
599
600        assert!(result.get("result").is_some());
601    }
602
603    #[tokio::test]
604    async fn test_hook_manager_emit() {
605        let mut manager = HookManager::new();
606        manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
607        let _ = manager.check_pre_tool("test", "{}").await;
608        manager
609            .notify_post_tool("test", "{}", &ToolResult::success("ok"))
610            .await;
611        // No crash = success
612    }
613
614    #[tokio::test]
615    async fn test_plugin_context_register_tool() {
616        let mut ctx = PluginContext::new();
617        // Just verify no crash
618        ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
619        assert!(ctx.hooks.lifecycle_hooks.len() == 1);
620    }
621}