tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat: llimphi standalone — framework UI soberano extraído del monorepo
1//! llimphi-plugin-host — runtime de plugins WASM Tier 2 para apps Llimphi.
2//!
3//! Vea `docs/MODULES.md` (§Tier 2 — Plugins WASM) para el contrato
4//! completo. En síntesis:
5//!
6//! - Un plugin es un `.wasm` + un `manifest.toml` hermano que declara
7//! `name`, `version`, `capabilities`, y los `Permissions` que pide.
8//! - El host expone imports bajo el namespace `"plugin"`. Cada uno se
9//! gatea por un campo de `card_core::Permissions`: si el permiso falta,
10//! el import **no se enlaza** y el plugin trap-ea al intentar usarlo.
11//! - El `.wasm` exporta `_invoke(cap_ptr, cap_len, arg_ptr, arg_len) -> i32`
12//! y una `memory` lineal.
13//! - Invocar un plugin devuelve `PluginAction` — intención, no ejecución.
14//! El host decide cómo materializar `OpenAt`/`SetStatus` en su contexto.
15
16use std::cell::RefCell;
17use std::path::{Path, PathBuf};
18
19use card_core::{FsPolicy, Permissions};
20use serde::Deserialize;
21use thiserror::Error;
22use tracing::{info, warn};
23use wasmi::{Caller, CompilationMode, Config, Engine, Linker, Memory, Module, Store};
24
25// =====================================================================
26// Manifest
27// =====================================================================
28
29/// Manifest sidecar (`manifest.toml`) que acompaña a cada `.wasm`.
30///
31/// El formato es estable: campos extra se ignoran con `#[serde(default)]`
32/// donde aplica, para que plugins viejos sigan cargando si el host suma
33/// metadatos opcionales.
34#[derive(Debug, Clone, Deserialize)]
35pub struct PluginManifest {
36 pub name: String,
37 pub version: String,
38 /// Capabilities que el plugin atiende. El host enruta invocaciones
39 /// por el nombre exacto pasado a `PluginHost::invoke(_, cap, _)`.
40 #[serde(default)]
41 pub capabilities: Vec<String>,
42 /// Permisos que el plugin necesita para no trap-ear. Si el manifest
43 /// pide más de lo que el host está dispuesto a conceder, la carga
44 /// puede aceptarse "downgraded" — pero el plugin entonces trap-eará
45 /// al intentar los imports que no se enlazaron. La política la fija
46 /// quien llama a `PluginHost::load_*`.
47 #[serde(default)]
48 pub permissions: Permissions,
49}
50
51impl PluginManifest {
52 pub fn from_toml(s: &str) -> Result<Self, PluginError> {
53 toml::from_str(s).map_err(|e| PluginError::Manifest(e.to_string()))
54 }
55}
56
57// =====================================================================
58// Acciones y errores
59// =====================================================================
60
61/// Intención que el plugin emite. Igual que en los módulos Tier 1, el
62/// plugin no sabe cómo el host materializa cada variante — sólo declara
63/// qué quiere que pase.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum PluginAction {
66 None,
67 SetStatus(String),
68 OpenAt { path: PathBuf, line: u32, col: u32 },
69}
70
71#[derive(Debug, Error)]
72pub enum PluginError {
73 #[error("manifest inválido: {0}")]
74 Manifest(String),
75 #[error("no se pudo leer {0}: {1}")]
76 Io(PathBuf, String),
77 #[error("compilando wasm: {0}")]
78 Compile(String),
79 #[error("instanciando wasm: {0}")]
80 Instantiate(String),
81 #[error("plugin no exporta `_invoke` con la signatura esperada: {0}")]
82 MissingEntry(String),
83 #[error("trap durante la ejecución del plugin: {0}")]
84 Trap(String),
85 #[error("no existe plugin con id {0:?}")]
86 UnknownPlugin(PluginId),
87}
88
89// =====================================================================
90// Host
91// =====================================================================
92
93/// Identificador opaco de un plugin cargado. Sólo se construye desde
94/// `PluginHost::load_*`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub struct PluginId(u32);
97
98struct LoadedPlugin {
99 manifest: PluginManifest,
100 module: Module,
101}
102
103/// Estado por invocación. Vive sólo durante un `invoke` — se descarta al
104/// volver. Lo usamos como `Store::data()` para que los host imports
105/// puedan emitir su `PluginAction` sin globals. Los permisos no viajan
106/// aquí porque su efecto es link-time: los imports prohibidos
107/// simplemente no se enlazan.
108struct InvokeCtx {
109 /// Acción a devolver al host. `RefCell` porque los closures de
110 /// `func_wrap` toman `Caller` por referencia compartida.
111 pending: RefCell<PluginAction>,
112}
113
114pub struct PluginHost {
115 engine: Engine,
116 plugins: Vec<LoadedPlugin>,
117}
118
119impl Default for PluginHost {
120 fn default() -> Self {
121 Self::new()
122 }
123}
124
125impl PluginHost {
126 pub fn new() -> Self {
127 // Eager: mismo modo que arje-wasm, comportamiento predecible y
128 // los traps de compilación salen en `load_*`, no en `invoke`.
129 let mut config = Config::default();
130 config.compilation_mode(CompilationMode::Eager);
131 Self { engine: Engine::new(&config), plugins: Vec::new() }
132 }
133
134 /// Carga `dir/plugin.wasm` + `dir/manifest.toml`. Por convención el
135 /// `.wasm` se llama igual que el directorio o `plugin.wasm`. Probamos
136 /// ambos para ser indulgentes con el packaging.
137 pub fn load_from_dir(&mut self, dir: impl AsRef<Path>) -> Result<PluginId, PluginError> {
138 let dir = dir.as_ref();
139 let manifest_path = dir.join("manifest.toml");
140 let manifest_str = std::fs::read_to_string(&manifest_path)
141 .map_err(|e| PluginError::Io(manifest_path.clone(), e.to_string()))?;
142 let manifest = PluginManifest::from_toml(&manifest_str)?;
143
144 let candidates = [dir.join("plugin.wasm"), dir.join(format!("{}.wasm", manifest.name))];
145 let (wasm_path, wasm_bytes) = candidates
146 .iter()
147 .find_map(|p| std::fs::read(p).ok().map(|b| (p.clone(), b)))
148 .ok_or_else(|| {
149 PluginError::Io(dir.join("plugin.wasm"), "no encontré ningún .wasm".into())
150 })?;
151
152 let _ = wasm_path;
153 self.load_bytes(manifest, &wasm_bytes)
154 }
155
156 /// Carga un plugin desde bytes ya en memoria (útil en tests y para
157 /// plugins embebidos en el binario del host).
158 pub fn load_bytes(
159 &mut self,
160 manifest: PluginManifest,
161 wasm_bytes: &[u8],
162 ) -> Result<PluginId, PluginError> {
163 let module = Module::new(&self.engine, wasm_bytes)
164 .map_err(|e| PluginError::Compile(e.to_string()))?;
165 let id = PluginId(self.plugins.len() as u32);
166 info!(
167 plugin = %manifest.name,
168 version = %manifest.version,
169 capabilities = ?manifest.capabilities,
170 "plugin Tier 2 cargado"
171 );
172 self.plugins.push(LoadedPlugin { manifest, module });
173 Ok(id)
174 }
175
176 pub fn manifest(&self, id: PluginId) -> Result<&PluginManifest, PluginError> {
177 self.plugins
178 .get(id.0 as usize)
179 .map(|p| &p.manifest)
180 .ok_or(PluginError::UnknownPlugin(id))
181 }
182
183 /// Devuelve la unión de capabilities de todos los plugins cargados —
184 /// la lista que el host enrola en su Card antes de `spawn_sidecar()`.
185 pub fn all_capabilities(&self) -> Vec<String> {
186 let mut caps: Vec<String> =
187 self.plugins.iter().flat_map(|p| p.manifest.capabilities.iter().cloned()).collect();
188 caps.sort();
189 caps.dedup();
190 caps
191 }
192
193 /// Invoca una capability sobre el plugin indicado. `args` se entrega
194 /// tal cual al plugin (bytes opacos — la app y el plugin acuerdan el
195 /// schema). El retorno colapsa el `_invoke` exit code y la
196 /// `PluginAction` que el plugin haya emitido.
197 pub fn invoke(
198 &self,
199 id: PluginId,
200 capability: &str,
201 args: &[u8],
202 ) -> Result<PluginAction, PluginError> {
203 let plugin = self.plugins.get(id.0 as usize).ok_or(PluginError::UnknownPlugin(id))?;
204 let ctx = InvokeCtx { pending: RefCell::new(PluginAction::None) };
205 let mut store = Store::new(&self.engine, ctx);
206 let linker = build_linker(&self.engine, &plugin.manifest.permissions)?;
207
208 // wasmi 1.0: `instantiate_and_start` corre la `(start)` section
209 // si la hay; nuestros plugins no la usan — su entrada es
210 // `_invoke`, llamada explícitamente más abajo.
211 let instance = linker
212 .instantiate_and_start(&mut store, &plugin.module)
213 .map_err(|e| PluginError::Instantiate(e.to_string()))?;
214
215 let memory = instance
216 .get_memory(&store, "memory")
217 .ok_or_else(|| PluginError::MissingEntry("plugin sin export `memory`".into()))?;
218
219 // Escribimos cap + args al inicio de la memoria del plugin. v0
220 // del ABI: layout fijo, no negociado. Si el plugin necesita más
221 // espacio se va a cualquier offset por encima — su asunto.
222 let cap_bytes = capability.as_bytes();
223 write_memory(&mut store, memory, 0, cap_bytes)?;
224 let args_off = cap_bytes.len();
225 write_memory(&mut store, memory, args_off, args)?;
226
227 let func = instance
228 .get_typed_func::<(i32, i32, i32, i32), i32>(&store, "_invoke")
229 .map_err(|e| PluginError::MissingEntry(e.to_string()))?;
230
231 let _exit = func
232 .call(
233 &mut store,
234 (0, cap_bytes.len() as i32, args_off as i32, args.len() as i32),
235 )
236 .map_err(|e| PluginError::Trap(e.to_string()))?;
237
238 let action = store.data().pending.borrow().clone();
239 Ok(action)
240 }
241}
242
243// =====================================================================
244// Host imports — gateados por Permissions
245// =====================================================================
246
247fn build_linker(
248 engine: &Engine,
249 perms: &Permissions,
250) -> Result<Linker<InvokeCtx>, PluginError> {
251 let mut linker = Linker::<InvokeCtx>::new(engine);
252
253 // log — siempre disponible. Aún plugins sin permisos pueden trazar.
254 linker
255 .func_wrap("plugin", "log", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| {
256 if let Some(s) = read_utf8(&caller, ptr, len) {
257 info!("[plugin] {s}");
258 }
259 })
260 .map_err(|e| PluginError::Instantiate(e.to_string()))?;
261
262 // set_status — siempre disponible. No toca recursos del sistema.
263 linker
264 .func_wrap("plugin", "set_status", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| {
265 if let Some(s) = read_utf8(&caller, ptr, len) {
266 *caller.data().pending.borrow_mut() = PluginAction::SetStatus(s);
267 }
268 })
269 .map_err(|e| PluginError::Instantiate(e.to_string()))?;
270
271 // open_at — requiere filesystem >= read-only. Si el permiso falta NO
272 // enlazamos el import: el plugin trap-eará al invocarlo, que es la
273 // semántica correcta para un sandbox.
274 if matches!(perms.filesystem, FsPolicy::ReadOnly | FsPolicy::ReadWrite) {
275 linker
276 .func_wrap(
277 "plugin",
278 "open_at",
279 |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32, line: i32, col: i32| {
280 if let Some(s) = read_utf8(&caller, ptr, len) {
281 *caller.data().pending.borrow_mut() = PluginAction::OpenAt {
282 path: PathBuf::from(s),
283 line: line.max(0) as u32,
284 col: col.max(0) as u32,
285 };
286 }
287 },
288 )
289 .map_err(|e| PluginError::Instantiate(e.to_string()))?;
290 } else {
291 warn!(
292 "plugin sin permiso filesystem — `plugin.open_at` no enlazado; \
293 llamarlo trap-eará"
294 );
295 }
296
297 Ok(linker)
298}
299
300// =====================================================================
301// Helpers de memoria
302// =====================================================================
303
304fn read_utf8(caller: &Caller<'_, InvokeCtx>, ptr: i32, len: i32) -> Option<String> {
305 let memory = caller.get_export("memory")?.into_memory()?;
306 let bytes = read_memory(caller, memory, ptr, len)?;
307 String::from_utf8(bytes).ok()
308}
309
310fn read_memory(
311 caller: &Caller<'_, InvokeCtx>,
312 memory: Memory,
313 ptr: i32,
314 len: i32,
315) -> Option<Vec<u8>> {
316 let ptr = ptr.max(0) as usize;
317 let len = len.max(0) as usize;
318 let data = memory.data(caller);
319 if ptr.saturating_add(len) > data.len() {
320 return None;
321 }
322 Some(data[ptr..ptr + len].to_vec())
323}
324
325fn write_memory(
326 store: &mut Store<InvokeCtx>,
327 memory: Memory,
328 off: usize,
329 bytes: &[u8],
330) -> Result<(), PluginError> {
331 memory
332 .write(store, off, bytes)
333 .map_err(|e| PluginError::Trap(format!("write_memory off={off} len={}: {e}", bytes.len())))
334}