1#![allow(unsafe_code)]
11#![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
22pub const PLUGIN_API_VERSION: u32 = 1;
24
25#[repr(C)]
27pub struct CPluginInfo {
28 pub api_version: u32,
30 pub name: *const c_char,
32 pub version: *const c_char,
34}
35
36#[repr(C)]
38pub struct CHookResult {
39 pub status: c_int,
41 pub data: *const u8,
43 pub data_len: usize,
45 pub error: *const c_char,
47}
48
49type GetInfoFn = unsafe extern "C" fn() -> *const CPluginInfo;
51type InitFn = unsafe extern "C" fn() -> c_int;
53type DeinitFn = unsafe extern "C" fn() -> c_int;
55type ExecuteHookFn =
57 unsafe extern "C" fn(hook_id: c_int, data: *const u8, data_len: usize) -> CHookResult;
58type FreeResultFn = unsafe extern "C" fn(result: *mut CHookResult);
60
61pub 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#[derive(Debug, Clone)]
75pub struct NativePluginInfo {
76 pub name: String,
78 pub version: String,
80 pub path: PathBuf,
82}
83
84impl NativePlugin {
85 pub fn load(path: &Path) -> Result<Self, PluginError> {
96 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 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 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 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 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 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 plugin.init()?;
180
181 Ok(plugin)
182 }
183
184 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 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 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 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 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 #[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 &[
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#[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
370pub struct NativePluginManager {
372 plugins: Vec<NativePlugin>,
373}
374
375impl NativePluginManager {
376 #[must_use]
378 pub const fn new() -> Self {
379 Self {
380 plugins: Vec::new(),
381 }
382 }
383
384 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 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 #[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}