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        self.ingest_host_plugins_trusting(workspace, extra, &[]);
272    }
273
274    /// As `ingest_host_plugins`, additionally building the manifest-declared
275    /// tools of plugins named in `trusted`.
276    ///
277    /// Trust is per plugin id and defaults to empty, because discovery finds a
278    /// directory — it does not vouch for it. See `plugin_exec` for what a
279    /// trusted plugin is still not allowed to do.
280    pub fn ingest_host_plugins_trusting(
281        &mut self,
282        workspace: &std::path::Path,
283        extra: &[PathBuf],
284        trusted: &[String],
285    ) {
286        let found = crate::plugin_hosts::discover_host_plugins(workspace, extra);
287        for p in &found {
288            tracing::info!(
289                "[plugin-host] {:?} {} {:?}",
290                p.kind,
291                p.name.as_deref().unwrap_or("?"),
292                p.path
293            );
294            for tool in crate::plugin_exec::HostPluginToolAdapter::build(p, trusted) {
295                let tool: Arc<dyn crate::tools::Tool> = Arc::new(tool);
296                tracing::info!(
297                    "[plugin-host] {} contributed trusted tool: {}",
298                    p.id,
299                    tool.name()
300                );
301                self.tools.push(tool);
302            }
303        }
304        self.host_plugins.extend(found);
305    }
306
307    /// Register a plugin and call its on_register with a PluginContext
308    pub async fn register(&mut self, plugin: Arc<dyn Plugin>) {
309        let name = plugin.name().to_string();
310        let mut ctx = PluginContext::default();
311        plugin.on_register(&mut ctx).await;
312        // Keep the tools the plugin exposed — `tools()` hands them to the agent.
313        for tool in ctx.tools {
314            tracing::info!("[plugin] '{}' registered tool: {}", name, tool.name());
315            self.tools.push(tool);
316        }
317        // Merge channels so `--channel <name>` can reach them.
318        for (channel_name, builder) in ctx.channels {
319            tracing::info!("[plugin] '{}' registered channel: {}", name, channel_name);
320            self.channels.register_builder(channel_name, builder);
321        }
322        // Merge hooks
323        self.hooks.lifecycle_hooks.extend(ctx.hooks.lifecycle_hooks);
324        self.hooks.pre_hooks.extend(ctx.hooks.pre_hooks);
325        self.hooks.post_hooks.extend(ctx.hooks.post_hooks);
326        self.plugins.insert(name, plugin);
327    }
328
329    /// Register a plugin without async (for tests, legacy)
330    pub fn register_sync(&mut self, plugin: Arc<dyn Plugin>) {
331        self.plugins.insert(plugin.name().to_string(), plugin);
332    }
333
334    /// Register a lifecycle hook directly (not from a plugin)
335    pub fn register_hook(&mut self, hook: Arc<dyn LifecycleHook>) {
336        self.hooks.register_lifecycle(hook);
337    }
338
339    /// Register a pre-tool hook directly (not from a plugin)
340    pub fn register_pre_tool_hook(&mut self, hook: Arc<dyn PreToolHook>) {
341        self.hooks.register_pre_tool(hook);
342    }
343
344    /// Access the hook manager
345    pub fn hooks(&self) -> &HookManager {
346        &self.hooks
347    }
348
349    /// Call a plugin method
350    pub async fn call(
351        &self,
352        plugin: &str,
353        method: &str,
354        params: Value,
355    ) -> Result<Value, PluginError> {
356        let p = self
357            .plugins
358            .get(plugin)
359            .ok_or_else(|| PluginError::new(-32601, "Plugin not found"))?;
360        p.call(method, params).await
361    }
362
363    /// List all plugins
364    pub fn list(&self) -> Vec<String> {
365        self.plugins.keys().cloned().collect()
366    }
367
368    /// Get plugin info
369    pub fn info(&self, name: &str) -> Option<PluginInfo> {
370        self.plugins.get(name).map(|p| PluginInfo {
371            name: p.name().to_string(),
372            version: p.version().to_string(),
373            methods: p.methods(),
374        })
375    }
376
377    /// Emit lifecycle event to all registered hooks
378    pub async fn emit(&self, event: &LifecycleEvent) {
379        self.hooks.emit(event).await;
380    }
381
382    /// Run pre-tool checks
383    pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
384        self.hooks.check_pre_tool(name, arguments).await
385    }
386
387    /// Notify post-tool hooks
388    pub async fn notify_post_tool(&self, name: &str, arguments: &str, result: &ToolResult) {
389        self.hooks.notify_post_tool(name, arguments, result).await;
390    }
391}
392
393impl Default for PluginRegistry {
394    fn default() -> Self {
395        Self::new()
396    }
397}
398
399// ── Built-in shell plugin (argv spawn, no sh -c) ─────────────────────────
400
401/// JSON-RPC plugin exposing `shell` when `policy.allow_plugin_shell` is enabled.
402pub struct ShellPlugin {
403    policy: crate::policy::ExecutionPolicy,
404}
405
406impl ShellPlugin {
407    pub fn new(policy: crate::policy::ExecutionPolicy) -> Self {
408        Self { policy }
409    }
410}
411
412#[async_trait]
413impl Plugin for ShellPlugin {
414    fn name(&self) -> &str {
415        "tools"
416    }
417
418    fn version(&self) -> &str {
419        "0.1.0"
420    }
421
422    fn methods(&self) -> Vec<MethodSpec> {
423        vec![MethodSpec {
424            name: "shell".to_string(),
425            description: "Execute shell command".to_string(),
426            params: {
427                let mut m = HashMap::new();
428                m.insert("cmd".to_string(), "string".to_string());
429                m
430            },
431            returns: "string".to_string(),
432        }]
433    }
434
435    async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
436        if method != "shell" {
437            return Err(PluginError::new(-32601, "Method not found"));
438        }
439        if !self.policy.allow_plugin_shell {
440            return Err(PluginError::new(
441                -32604,
442                "Plugin shell execution is disabled by policy",
443            ));
444        }
445
446        let cmd = params
447            .get("cmd")
448            .and_then(|v| v.as_str())
449            .ok_or_else(|| PluginError::new(-32602, "Missing cmd parameter"))?;
450
451        if let Err(reason) = self.policy.check_shell_command(cmd) {
452            return Err(PluginError::new(-32604, &reason));
453        }
454
455        match crate::process_cmd::run_argv_command(cmd, 120).await {
456            Ok((output, ok)) => Ok(serde_json::json!({
457                "stdout": output,
458                "success": ok,
459            })),
460            Err(e) => Err(PluginError::new(-32603, &e.to_string())),
461        }
462    }
463}
464
465// ── Plugin Info ───────────────────────────────────────────────────────────
466
467#[derive(Debug, Serialize, Deserialize)]
468pub struct PluginInfo {
469    pub name: String,
470    pub version: String,
471    pub methods: Vec<MethodSpec>,
472}
473
474// ── Built-in lifecycle hooks ──────────────────────────────────────────────
475
476/// Append short session notes on agent completion.
477pub struct SessionNoteLifecycleHook {
478    workspace: std::path::PathBuf,
479}
480
481impl SessionNoteLifecycleHook {
482    pub fn new(workspace: std::path::PathBuf) -> Self {
483        Self { workspace }
484    }
485}
486
487#[async_trait]
488impl LifecycleHook for SessionNoteLifecycleHook {
489    fn name(&self) -> &str {
490        "session_note"
491    }
492
493    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
494        if let LifecycleEvent::AgentDone(chat_id, response) = event {
495            let redacted = crate::redaction::redact_text(response);
496            let preview: String = redacted.chars().take(200).collect();
497            if !preview.is_empty() {
498                let _ = crate::memory::session_note::append_session_note(
499                    &self.workspace,
500                    chat_id,
501                    &preview,
502                );
503            }
504        }
505        Ok(())
506    }
507}
508
509/// Logging hook — traces every lifecycle event
510pub struct LoggingLifecycleHook;
511
512#[async_trait]
513impl LifecycleHook for LoggingLifecycleHook {
514    fn name(&self) -> &str {
515        "logging"
516    }
517
518    async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
519        match event {
520            LifecycleEvent::BeforeToolCall(name, args) => {
521                tracing::debug!("[hook] before_tool {} args_len:{}", name, args.len());
522            }
523            LifecycleEvent::AfterToolCall(name, _args, result) => {
524                tracing::debug!(
525                    "[hook] after_tool {} is_error:{} len:{}",
526                    name,
527                    result.is_error,
528                    result.output.len()
529                );
530            }
531            LifecycleEvent::SessionStart(id) => {
532                tracing::info!("[hook] session_start {}", id);
533            }
534            LifecycleEvent::SessionEnd(id) => {
535                tracing::info!("[hook] session_end {}", id);
536            }
537            LifecycleEvent::AgentStart(chat_id, msg) => {
538                tracing::debug!("[hook] agent_start {} msg_len:{}", chat_id, msg.len());
539            }
540            LifecycleEvent::AgentDone(chat_id, response) => {
541                tracing::debug!(
542                    "[hook] agent_done {} response_len:{}",
543                    chat_id,
544                    response.len()
545                );
546            }
547        }
548        Ok(())
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use serde_json::json;
556
557    struct TestPlugin;
558
559    #[async_trait]
560    impl Plugin for TestPlugin {
561        fn name(&self) -> &str {
562            "test"
563        }
564
565        fn version(&self) -> &str {
566            "0.1.0"
567        }
568
569        fn methods(&self) -> Vec<MethodSpec> {
570            vec![MethodSpec {
571                name: "echo".to_string(),
572                description: "Echo input".to_string(),
573                params: HashMap::new(),
574                returns: "object".to_string(),
575            }]
576        }
577
578        async fn call(&self, method: &str, params: Value) -> Result<Value, PluginError> {
579            match method {
580                "echo" => Ok(json!({ "result": params })),
581                _ => Err(PluginError::new(-32601, "Method not found")),
582            }
583        }
584    }
585
586    #[tokio::test]
587    async fn shell_plugin_uses_argv_not_shell_when_allowed() {
588        let policy = crate::policy::ExecutionPolicy {
589            allow_plugin_shell: true,
590            ..Default::default()
591        };
592        let plugin = ShellPlugin::new(policy);
593        let result = plugin
594            .call("shell", json!({ "cmd": "echo plugin_ok" }))
595            .await
596            .unwrap();
597        assert_eq!(result["success"], true);
598        assert!(result["stdout"].as_str().unwrap().contains("plugin_ok"));
599    }
600
601    #[tokio::test]
602    async fn shell_plugin_denied_when_policy_off() {
603        let plugin = ShellPlugin::new(crate::policy::ExecutionPolicy {
604            allow_plugin_shell: false,
605            ..Default::default()
606        });
607        let err = plugin
608            .call("shell", json!({ "cmd": "echo x" }))
609            .await
610            .unwrap_err();
611        assert_eq!(err.code, -32604);
612    }
613
614    #[tokio::test]
615    async fn test_plugin_call() {
616        let mut registry = PluginRegistry::new();
617        registry.register_sync(Arc::new(TestPlugin));
618
619        let result = registry
620            .call("test", "echo", json!({ "code": "fn main() {}" }))
621            .await
622            .unwrap();
623
624        assert!(result.get("result").is_some());
625    }
626
627    #[tokio::test]
628    async fn test_hook_manager_emit() {
629        let mut manager = HookManager::new();
630        manager.register_lifecycle(Arc::new(LoggingLifecycleHook));
631        let _ = manager.check_pre_tool("test", "{}").await;
632        manager
633            .notify_post_tool("test", "{}", &ToolResult::success("ok"))
634            .await;
635        // No crash = success
636    }
637
638    #[tokio::test]
639    async fn test_plugin_context_register_tool() {
640        let mut ctx = PluginContext::new();
641        // Just verify no crash
642        ctx.register_lifecycle_hook(Arc::new(LoggingLifecycleHook));
643        assert!(ctx.hooks.lifecycle_hooks.len() == 1);
644    }
645}