use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use wasmtime::{
Config, Engine, Extern, Func, Instance, Linker, Memory, Module, ResourceLimiter, Store, Val,
};
use crate::error::{Result, SkadooshError};
use crate::llm::Tool;
use crate::tools::ToolExecutor;
pub const DEFAULT_FUEL: u64 = 1_000_000;
pub const DEFAULT_MAX_MEMORY_BYTES: usize = 16 * 1024 * 1024;
pub const DEFAULT_MAX_TABLE_ELEMENTS: usize = 1024;
#[derive(Debug, Clone, Copy)]
struct PluginLimits {
max_memory_bytes: usize,
max_table_elements: usize,
}
struct PluginCtx {
limits: PluginLimits,
}
impl ResourceLimiter for PluginCtx {
fn memory_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> wasmtime::Result<bool> {
Ok(desired <= self.limits.max_memory_bytes)
}
fn table_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> wasmtime::Result<bool> {
Ok(desired <= self.limits.max_table_elements)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub description: String,
pub version: String,
#[serde(default)]
pub parameters: Option<serde_json::Value>,
}
struct PluginRuntime {
store: Store<PluginCtx>,
memory: Memory,
alloc: Func,
dealloc: Func,
execute: Func,
}
pub struct LoadedPlugin {
manifest: PluginManifest,
path: PathBuf,
rt: Mutex<PluginRuntime>,
}
impl LoadedPlugin {
pub fn manifest(&self) -> &PluginManifest {
&self.manifest
}
pub fn path(&self) -> &Path {
&self.path
}
fn load(engine: &Engine, path: &Path, limits: PluginLimits, fuel: u64) -> Result<Self> {
let wasm = fs::read(path).map_err(|e| {
SkadooshError::Other(anyhow::anyhow!("read plugin {}: {e}", path.display()))
})?;
let module = Module::new(engine, &wasm).map_err(|e| {
SkadooshError::Other(anyhow::anyhow!("compile plugin {}: {e}", path.display()))
})?;
let mut store = Store::new(engine, PluginCtx { limits });
store
.set_fuel(fuel)
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("set fuel: {e}")))?;
store.limiter(|ctx: &mut PluginCtx| ctx as &mut dyn ResourceLimiter);
let linker = Linker::new(engine);
let instance = linker.instantiate(&mut store, &module).map_err(|e| {
SkadooshError::Other(anyhow::anyhow!(
"instantiate plugin {}: {e}",
path.display()
))
})?;
let memory = required_memory(&instance, &mut store, "memory", path)?;
let alloc = required_func(&instance, &mut store, "alloc", path)?;
let dealloc = required_func(&instance, &mut store, "dealloc", path)?;
let manifest_func = required_func(&instance, &mut store, "manifest", path)?;
let execute = required_func(&instance, &mut store, "execute", path)?;
let mptr = call_i32(&manifest_func, &mut store, &[])?;
let mjson = read_prefixed(&memory, &mut store, mptr)?;
let manifest: PluginManifest = serde_json::from_str(&mjson).map_err(|e| {
SkadooshError::Other(anyhow::anyhow!(
"plugin {} manifest JSON parse: {e}",
path.display()
))
})?;
if manifest.name.is_empty() {
return Err(SkadooshError::Other(anyhow::anyhow!(
"plugin {} manifest has empty name",
path.display()
)));
}
let _ = dealloc.call(
&mut store,
&[Val::I32(mptr), Val::I32(4 + mjson.len() as i32)],
&mut [],
);
Ok(Self {
manifest,
path: path.to_path_buf(),
rt: Mutex::new(PluginRuntime {
store,
memory,
alloc,
dealloc,
execute,
}),
})
}
}
pub struct PluginManager {
engine: Engine,
plugins: Vec<LoadedPlugin>,
by_name: HashMap<String, usize>,
fuel: u64,
limits: PluginLimits,
}
impl PluginManager {
pub fn new() -> Result<Self> {
Self::with_limits(
DEFAULT_FUEL,
DEFAULT_MAX_MEMORY_BYTES,
DEFAULT_MAX_TABLE_ELEMENTS,
)
}
pub fn with_limits(
fuel: u64,
max_memory_bytes: usize,
max_table_elements: usize,
) -> Result<Self> {
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("wasmtime engine init: {e}")))?;
Ok(Self {
engine,
plugins: Vec::new(),
by_name: HashMap::new(),
fuel,
limits: PluginLimits {
max_memory_bytes,
max_table_elements,
},
})
}
pub fn load_dir(dir: &Path) -> Result<Self> {
let mut mgr = Self::new()?;
mgr.load_directory(dir)?;
Ok(mgr)
}
pub fn load_directory(&mut self, dir: &Path) -> Result<()> {
if !dir.exists() {
return Err(SkadooshError::Other(anyhow::anyhow!(
"plugins dir not found: {}",
dir.display()
)));
}
let entries = fs::read_dir(dir).map_err(|e| {
SkadooshError::Other(anyhow::anyhow!("read plugins dir {}: {e}", dir.display()))
})?;
let mut paths = Vec::new();
for entry in entries {
let entry = entry
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("plugins dir entry: {e}")))?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("wasm") && path.is_file() {
paths.push(path);
}
}
paths.sort();
for path in paths {
match LoadedPlugin::load(&self.engine, &path, self.limits, self.fuel) {
Ok(plugin) => {
let name = plugin.manifest.name.clone();
if self.by_name.contains_key(&name) {
tracing::warn!(
plugin = %name,
path = %path.display(),
"duplicate plugin name; skipping"
);
continue;
}
tracing::info!(
plugin = %plugin.manifest.name,
version = %plugin.manifest.version,
path = %path.display(),
"loaded plugin"
);
self.by_name.insert(name, self.plugins.len());
self.plugins.push(plugin);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to load plugin; skipping"
);
}
}
}
Ok(())
}
pub fn load_path(&mut self, path: &Path) -> Result<&PluginManifest> {
let plugin = LoadedPlugin::load(&self.engine, path, self.limits, self.fuel)?;
let name = plugin.manifest.name.clone();
if self.by_name.contains_key(&name) {
return Err(SkadooshError::Other(anyhow::anyhow!(
"duplicate plugin name '{name}'"
)));
}
self.by_name.insert(name.clone(), self.plugins.len());
self.plugins.push(plugin);
Ok(&self.plugins.last().expect("just pushed").manifest)
}
pub fn len(&self) -> usize {
self.plugins.len()
}
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
pub fn has(&self, name: &str) -> bool {
self.by_name.contains_key(name)
}
pub fn manifests(&self) -> Vec<&PluginManifest> {
self.plugins.iter().map(|p| &p.manifest).collect()
}
pub fn tool_definitions(&self) -> Vec<Tool> {
self.plugins
.iter()
.map(|p| plugin_tool_definition(&p.manifest))
.collect()
}
pub fn run(&self, name: &str, input: &str) -> Result<String> {
let idx = *self
.by_name
.get(name)
.ok_or_else(|| SkadooshError::Other(anyhow::anyhow!("unknown plugin '{name}'")))?;
let plugin = &self.plugins[idx];
let mut rt = plugin
.rt
.lock()
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("plugin mutex poisoned: {e}")))?;
rt.store
.set_fuel(self.fuel)
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("set fuel: {e}")))?;
run_plugin(&mut rt, input)
}
}
impl ToolExecutor for PluginManager {
fn execute(&self, name: &str, arguments: &str) -> Result<String> {
self.run(name, arguments)
}
}
fn plugin_tool_definition(manifest: &PluginManifest) -> Tool {
let parameters = manifest.parameters.clone().unwrap_or_else(|| {
serde_json::json!({"type": "object"})
});
Tool::function(&manifest.name, &manifest.description, parameters)
}
fn call_i32(func: &Func, store: &mut Store<PluginCtx>, params: &[Val]) -> Result<i32> {
let mut out = [Val::I32(0)];
func.call(store, params, &mut out)
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("plugin function call trapped: {e}")))?;
match out[0] {
Val::I32(v) => Ok(v),
_ => Err(SkadooshError::Other(anyhow::anyhow!(
"plugin function returned non-i32 result"
))),
}
}
fn run_plugin(rt: &mut PluginRuntime, input: &str) -> Result<String> {
let in_len = input.len();
let in_ptr = call_i32(&rt.alloc, &mut rt.store, &[Val::I32(in_len as i32)])?;
if in_ptr < 0 {
return Err(SkadooshError::Other(anyhow::anyhow!(
"plugin alloc returned negative pointer"
)));
}
let in_ptr_u = in_ptr as usize;
let mem_size = rt.memory.data_size(&rt.store);
if in_ptr_u.saturating_add(in_len) > mem_size {
return Err(SkadooshError::Other(anyhow::anyhow!(
"plugin alloc returned out-of-range pointer"
)));
}
rt.memory
.write(&mut rt.store, in_ptr_u, input.as_bytes())
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("plugin memory write: {e}")))?;
let rptr = call_i32(
&rt.execute,
&mut rt.store,
&[Val::I32(in_ptr), Val::I32(in_len as i32)],
)?;
let _ = rt.dealloc.call(
&mut rt.store,
&[Val::I32(in_ptr), Val::I32(in_len as i32)],
&mut [],
);
let result = read_prefixed(&rt.memory, &mut rt.store, rptr)?;
let total = 4usize
.checked_add(result.len())
.ok_or_else(|| SkadooshError::Other(anyhow::anyhow!("plugin result length overflow")))?;
let _ = rt.dealloc.call(
&mut rt.store,
&[Val::I32(rptr), Val::I32(total as i32)],
&mut [],
);
Ok(result)
}
fn read_prefixed(memory: &Memory, store: &mut Store<PluginCtx>, ptr: i32) -> Result<String> {
if ptr < 0 {
return Err(SkadooshError::Other(anyhow::anyhow!(
"plugin returned negative pointer"
)));
}
let ptr = ptr as usize;
let size = memory.data_size(&*store);
if ptr.checked_add(4).map(|end| end > size).unwrap_or(true) {
return Err(SkadooshError::Other(anyhow::anyhow!(
"plugin result pointer out of bounds"
)));
}
let mut len_bytes = [0u8; 4];
memory
.read(&*store, ptr, &mut len_bytes)
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("plugin result length read: {e}")))?;
let len = u32::from_le_bytes(len_bytes) as usize;
if ptr
.checked_add(4)
.and_then(|p| p.checked_add(len))
.map(|end| end > size)
.unwrap_or(true)
{
return Err(SkadooshError::Other(anyhow::anyhow!(
"plugin result length {len} exceeds memory"
)));
}
let mut buf = vec![0u8; len];
if len > 0 {
memory
.read(&*store, ptr + 4, &mut buf)
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("plugin result body read: {e}")))?;
}
String::from_utf8(buf)
.map_err(|e| SkadooshError::Other(anyhow::anyhow!("plugin returned non-utf8 result: {e}")))
}
fn required_memory(
instance: &Instance,
store: &mut Store<PluginCtx>,
name: &str,
path: &Path,
) -> Result<Memory> {
match instance.get_export(&mut *store, name) {
Some(Extern::Memory(m)) => Ok(m),
_ => Err(SkadooshError::Other(anyhow::anyhow!(
"plugin {} missing exported memory '{name}'",
path.display()
))),
}
}
fn required_func(
instance: &Instance,
store: &mut Store<PluginCtx>,
name: &str,
path: &Path,
) -> Result<Func> {
match instance.get_export(&mut *store, name) {
Some(Extern::Func(f)) => Ok(f),
_ => Err(SkadooshError::Other(anyhow::anyhow!(
"plugin {} missing required export '{name}'",
path.display()
))),
}
}
pub fn default_plugins_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".skadoosh").join("plugins"))
}