Skip to main content

openclaw_plugins/
native.rs

1//! Native plugin support via dynamic library loading.
2//!
3//! Provides FFI-based plugin loading for high-performance native plugins.
4//!
5//! # Safety
6//!
7//! This module uses unsafe code for FFI with native plugins.
8//! Only load plugins from trusted sources.
9
10#![allow(unsafe_code)]
11// FFI code requires specific pointer handling patterns
12#![allow(clippy::ptr_as_ptr, clippy::borrow_as_ptr, clippy::ptr_cast_constness)]
13
14use std::ffi::{CStr, c_char, c_int};
15use std::path::{Path, PathBuf};
16
17use async_trait::async_trait;
18use libloading::{Library, Symbol};
19
20use crate::api::{Plugin, PluginError, PluginHook};
21
22/// Plugin API version for ABI compatibility.
23pub const PLUGIN_API_VERSION: u32 = 1;
24
25/// Plugin info returned by native plugins.
26#[repr(C)]
27pub struct CPluginInfo {
28    /// API version (must match `PLUGIN_API_VERSION`).
29    pub api_version: u32,
30    /// Plugin name (null-terminated UTF-8).
31    pub name: *const c_char,
32    /// Plugin version string (null-terminated UTF-8).
33    pub version: *const c_char,
34}
35
36/// Result from hook execution.
37#[repr(C)]
38pub struct CHookResult {
39    /// Success flag (0 = success, non-zero = error).
40    pub status: c_int,
41    /// Result data pointer (owned by plugin).
42    pub data: *const u8,
43    /// Result data length.
44    pub data_len: usize,
45    /// Error message if status != 0 (null-terminated UTF-8).
46    pub error: *const c_char,
47}
48
49/// Type alias for `plugin_get_info` function.
50type GetInfoFn = unsafe extern "C" fn() -> *const CPluginInfo;
51/// Type alias for `plugin_init` function.
52type InitFn = unsafe extern "C" fn() -> c_int;
53/// Type alias for `plugin_deinit` function.
54type DeinitFn = unsafe extern "C" fn() -> c_int;
55/// Type alias for `plugin_execute_hook` function.
56type ExecuteHookFn =
57    unsafe extern "C" fn(hook_id: c_int, data: *const u8, data_len: usize) -> CHookResult;
58/// Type alias for `plugin_free_result` function.
59type FreeResultFn = unsafe extern "C" fn(result: *mut CHookResult);
60
61/// Native plugin loaded from a dynamic library.
62pub struct NativePlugin {
63    #[allow(dead_code)]
64    library: Library,
65    info: NativePluginInfo,
66    init_fn: Option<InitFn>,
67    deinit_fn: Option<DeinitFn>,
68    execute_hook_fn: Option<ExecuteHookFn>,
69    free_result_fn: Option<FreeResultFn>,
70    initialized: bool,
71}
72
73/// Native plugin metadata.
74#[derive(Debug, Clone)]
75pub struct NativePluginInfo {
76    /// Plugin name.
77    pub name: String,
78    /// Plugin version.
79    pub version: String,
80    /// Path to the library.
81    pub path: PathBuf,
82}
83
84impl NativePlugin {
85    /// Load a native plugin from a dynamic library.
86    ///
87    /// # Safety
88    ///
89    /// This function loads and executes code from the specified library.
90    /// Only load libraries from trusted sources.
91    ///
92    /// # Errors
93    ///
94    /// Returns error if loading fails or ABI version mismatch.
95    pub fn load(path: &Path) -> Result<Self, PluginError> {
96        // Load the library
97        let library = unsafe {
98            Library::new(path).map_err(|e| {
99                PluginError::LoadFailed(format!("Failed to load library {}: {e}", path.display()))
100            })?
101        };
102
103        // Get plugin_get_info (required)
104        let get_info: Symbol<GetInfoFn> = unsafe {
105            library.get(b"plugin_get_info").map_err(|e| {
106                PluginError::LoadFailed(format!("Missing plugin_get_info export: {e}"))
107            })?
108        };
109
110        // Call get_info to verify API version
111        let c_info = unsafe { get_info() };
112        if c_info.is_null() {
113            return Err(PluginError::LoadFailed(
114                "plugin_get_info returned null".to_string(),
115            ));
116        }
117
118        let c_info = unsafe { &*c_info };
119
120        // Check API version
121        if c_info.api_version != PLUGIN_API_VERSION {
122            return Err(PluginError::LoadFailed(format!(
123                "API version mismatch: expected {}, got {}",
124                PLUGIN_API_VERSION, c_info.api_version
125            )));
126        }
127
128        // Extract plugin info
129        let name = if c_info.name.is_null() {
130            "unknown".to_string()
131        } else {
132            unsafe {
133                CStr::from_ptr(c_info.name)
134                    .to_str()
135                    .unwrap_or("unknown")
136                    .to_string()
137            }
138        };
139
140        let version = if c_info.version.is_null() {
141            "0.0.0".to_string()
142        } else {
143            unsafe {
144                CStr::from_ptr(c_info.version)
145                    .to_str()
146                    .unwrap_or("0.0.0")
147                    .to_string()
148            }
149        };
150
151        let info = NativePluginInfo {
152            name,
153            version,
154            path: path.to_path_buf(),
155        };
156
157        // Get optional functions
158        let init_fn: Option<InitFn> = unsafe { library.get(b"plugin_init").ok().map(|s| *s) };
159
160        let deinit_fn: Option<DeinitFn> = unsafe { library.get(b"plugin_deinit").ok().map(|s| *s) };
161
162        let execute_hook_fn: Option<ExecuteHookFn> =
163            unsafe { library.get(b"plugin_execute_hook").ok().map(|s| *s) };
164
165        let free_result_fn: Option<FreeResultFn> =
166            unsafe { library.get(b"plugin_free_result").ok().map(|s| *s) };
167
168        let mut plugin = Self {
169            library,
170            info,
171            init_fn,
172            deinit_fn,
173            execute_hook_fn,
174            free_result_fn,
175            initialized: false,
176        };
177
178        // Initialize the plugin
179        plugin.init()?;
180
181        Ok(plugin)
182    }
183
184    /// Initialize the plugin.
185    fn init(&mut self) -> Result<(), PluginError> {
186        if self.initialized {
187            return Ok(());
188        }
189
190        if let Some(init) = self.init_fn {
191            let result = unsafe { init() };
192            if result != 0 {
193                return Err(PluginError::ExecutionError(format!(
194                    "Plugin init failed with code: {result}"
195                )));
196            }
197        }
198
199        self.initialized = true;
200        tracing::info!(
201            name = %self.info.name,
202            version = %self.info.version,
203            "Native plugin loaded"
204        );
205
206        Ok(())
207    }
208
209    /// Execute a hook.
210    fn execute_hook_internal(&self, hook_id: i32, data: &[u8]) -> Result<Vec<u8>, PluginError> {
211        let execute = self
212            .execute_hook_fn
213            .ok_or_else(|| PluginError::ExecutionError("No execute_hook export".to_string()))?;
214
215        let result = unsafe { execute(hook_id, data.as_ptr(), data.len()) };
216
217        if result.status != 0 {
218            let status = result.status;
219            let error_msg = if result.error.is_null() {
220                format!("Hook execution failed with code: {status}")
221            } else {
222                unsafe {
223                    CStr::from_ptr(result.error)
224                        .to_str()
225                        .unwrap_or("Unknown error")
226                        .to_string()
227                }
228            };
229
230            // Free the result if needed
231            if let Some(free_fn) = self.free_result_fn {
232                unsafe { free_fn(std::ptr::from_ref(&result) as *mut _) };
233            }
234
235            return Err(PluginError::ExecutionError(error_msg));
236        }
237
238        // Copy result data
239        let result_data = if result.data.is_null() || result.data_len == 0 {
240            Vec::new()
241        } else {
242            unsafe { std::slice::from_raw_parts(result.data, result.data_len).to_vec() }
243        };
244
245        // Free the result
246        if let Some(free_fn) = self.free_result_fn {
247            unsafe { free_fn(std::ptr::from_ref(&result) as *mut _) };
248        }
249
250        Ok(result_data)
251    }
252
253    /// Get plugin info.
254    #[must_use]
255    pub const fn info(&self) -> &NativePluginInfo {
256        &self.info
257    }
258}
259
260impl Drop for NativePlugin {
261    fn drop(&mut self) {
262        if self.initialized {
263            if let Some(deinit) = self.deinit_fn {
264                let result = unsafe { deinit() };
265                if result != 0 {
266                    tracing::warn!(
267                        plugin = %self.info.name,
268                        code = result,
269                        "Plugin deinit returned error"
270                    );
271                }
272            }
273        }
274    }
275}
276
277#[async_trait]
278impl Plugin for NativePlugin {
279    fn id(&self) -> &str {
280        &self.info.name
281    }
282
283    fn name(&self) -> &str {
284        &self.info.name
285    }
286
287    fn version(&self) -> &str {
288        &self.info.version
289    }
290
291    fn hooks(&self) -> &[PluginHook] {
292        // Native plugins can implement any hook
293        &[
294            PluginHook::BeforeMessage,
295            PluginHook::AfterMessage,
296            PluginHook::BeforeToolCall,
297            PluginHook::AfterToolCall,
298            PluginHook::SessionStart,
299            PluginHook::SessionEnd,
300            PluginHook::AgentResponse,
301            PluginHook::Error,
302        ]
303    }
304
305    async fn execute_hook(
306        &self,
307        hook: PluginHook,
308        data: serde_json::Value,
309    ) -> Result<serde_json::Value, PluginError> {
310        let hook_id = match hook {
311            PluginHook::BeforeMessage => 0,
312            PluginHook::AfterMessage => 1,
313            PluginHook::BeforeToolCall => 2,
314            PluginHook::AfterToolCall => 3,
315            PluginHook::SessionStart => 4,
316            PluginHook::SessionEnd => 5,
317            PluginHook::AgentResponse => 6,
318            PluginHook::Error => 7,
319        };
320
321        let input = serde_json::to_vec(&data)
322            .map_err(|e| PluginError::ExecutionError(format!("Serialize: {e}")))?;
323
324        let output = self.execute_hook_internal(hook_id, &input)?;
325
326        if output.is_empty() {
327            return Ok(data);
328        }
329
330        serde_json::from_slice(&output)
331            .map_err(|e| PluginError::ExecutionError(format!("Deserialize: {e}")))
332    }
333
334    async fn activate(&self) -> Result<(), PluginError> {
335        Ok(())
336    }
337
338    async fn deactivate(&self) -> Result<(), PluginError> {
339        Ok(())
340    }
341}
342
343/// Discover native plugins in a directory.
344///
345/// Looks for platform-appropriate shared libraries (.so, .dylib, .dll).
346#[must_use]
347pub fn discover_native_plugins(dir: &Path) -> Vec<PathBuf> {
348    let extension = if cfg!(windows) {
349        "dll"
350    } else if cfg!(target_os = "macos") {
351        "dylib"
352    } else {
353        "so"
354    };
355
356    let mut plugins = Vec::new();
357
358    if let Ok(entries) = std::fs::read_dir(dir) {
359        for entry in entries.flatten() {
360            let path = entry.path();
361            if path.extension().is_some_and(|ext| ext == extension) {
362                plugins.push(path);
363            }
364        }
365    }
366
367    plugins
368}
369
370/// Native plugin manager for loading and managing native plugins.
371pub struct NativePluginManager {
372    plugins: Vec<NativePlugin>,
373}
374
375impl NativePluginManager {
376    /// Create a new plugin manager.
377    #[must_use]
378    pub const fn new() -> Self {
379        Self {
380            plugins: Vec::new(),
381        }
382    }
383
384    /// Load a plugin from a library file.
385    ///
386    /// # Errors
387    ///
388    /// Returns error if loading fails.
389    pub fn load(&mut self, path: &Path) -> Result<(), PluginError> {
390        let plugin = NativePlugin::load(path)?;
391        self.plugins.push(plugin);
392        Ok(())
393    }
394
395    /// Load all native plugins from a directory.
396    ///
397    /// # Errors
398    ///
399    /// Returns error if directory read fails (individual failures are logged).
400    pub fn load_dir(&mut self, dir: &Path) -> Result<usize, PluginError> {
401        let paths = discover_native_plugins(dir);
402        let mut loaded = 0;
403
404        for path in paths {
405            match NativePlugin::load(&path) {
406                Ok(plugin) => {
407                    tracing::info!(path = %path.display(), "Loaded native plugin");
408                    self.plugins.push(plugin);
409                    loaded += 1;
410                }
411                Err(e) => {
412                    tracing::warn!(path = %path.display(), error = %e, "Failed to load native plugin");
413                }
414            }
415        }
416
417        Ok(loaded)
418    }
419
420    /// Get all loaded plugins.
421    #[must_use]
422    pub fn plugins(&self) -> &[NativePlugin] {
423        &self.plugins
424    }
425}
426
427impl Default for NativePluginManager {
428    fn default() -> Self {
429        Self::new()
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_api_version() {
439        assert_eq!(PLUGIN_API_VERSION, 1);
440    }
441
442    #[test]
443    fn test_manager_creation() {
444        let manager = NativePluginManager::new();
445        assert!(manager.plugins().is_empty());
446    }
447
448    #[test]
449    fn test_discover_empty_dir() {
450        let dir = std::env::temp_dir().join("openclaw-test-empty");
451        let _ = std::fs::create_dir_all(&dir);
452        let plugins = discover_native_plugins(&dir);
453        assert!(plugins.is_empty());
454    }
455}