Skip to main content

navi_plugin_runtime/
runtime.rs

1use crate::error::RuntimeError;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::time::{Duration, Instant};
5use wasmtime::*;
6
7/// Data stored in the WASM store, holding resource limits and host callbacks.
8struct StoreData {
9    limits: StoreLimits,
10    callbacks: HostCallbacks,
11}
12
13/// Host callback functions that the WASM module can call.
14///
15/// Each callback receives a string input and returns a string output.
16/// Errors are returned as JSON: `{"error": "message"}`.
17#[derive(Clone)]
18pub struct HostCallbacks {
19    /// Read a project file. Input: path. Output: file content or error JSON.
20    pub fs_read: Arc<dyn Fn(&str) -> String + Send + Sync>,
21    /// List a project directory. Input: path. Output: JSON array of names or error JSON.
22    pub fs_list: Arc<dyn Fn(&str) -> String + Send + Sync>,
23    /// Make an HTTP request. Input: JSON `{method, url, body}`. Output: JSON response or error JSON.
24    pub http_request: Arc<dyn Fn(&str) -> String + Send + Sync>,
25    /// Get git status. Input: empty. Output: git status text or error JSON.
26    pub git_status: Arc<dyn Fn() -> String + Send + Sync>,
27    /// Get git diff. Input: empty. Output: git diff text or error JSON.
28    pub git_diff: Arc<dyn Fn() -> String + Send + Sync>,
29}
30
31impl Default for HostCallbacks {
32    fn default() -> Self {
33        Self {
34            fs_read: Arc::new(|_| r#"{"error":"not implemented"}"#.into()),
35            fs_list: Arc::new(|_| r#"{"error":"not implemented"}"#.into()),
36            http_request: Arc::new(|_| r#"{"error":"not implemented"}"#.into()),
37            git_status: Arc::new(|| r#"{"error":"not implemented"}"#.into()),
38            git_diff: Arc::new(|| r#"{"error":"not implemented"}"#.into()),
39        }
40    }
41}
42
43/// Configuration for the WASM plugin runtime.
44/// All limits are mandatory and cannot be disabled.
45#[derive(Debug, Clone)]
46pub struct ToolRuntimeConfig {
47    /// Wall-clock timeout per invocation.
48    pub timeout: Duration,
49    /// Linear memory limit in bytes.
50    pub memory_limit_bytes: u64,
51    /// Fuel (instruction budget) per invocation.
52    pub fuel: u64,
53    /// Max tool output size in bytes.
54    pub max_output_bytes: usize,
55    /// Stack size in bytes.
56    pub stack_size_bytes: usize,
57}
58
59impl Default for ToolRuntimeConfig {
60    fn default() -> Self {
61        Self {
62            timeout: Duration::from_secs(30),
63            memory_limit_bytes: 64 * 1024 * 1024, // 64 MB
64            fuel: 10_000_000,
65            max_output_bytes: 32 * 1024,   // 32 KB
66            stack_size_bytes: 1024 * 1024, // 1 MB
67        }
68    }
69}
70
71/// Result of a tool invocation.
72#[derive(Debug, Clone)]
73pub struct RunResult {
74    /// The JSON output string from the plugin.
75    pub output: String,
76    /// Fuel consumed during execution.
77    pub fuel_consumed: u64,
78    /// Wall-clock duration of execution.
79    pub duration: Duration,
80}
81
82/// WASM plugin runtime with mandatory resource limits.
83pub struct PluginRuntime {
84    config: ToolRuntimeConfig,
85}
86
87impl PluginRuntime {
88    /// Create a new runtime with the given configuration.
89    pub fn new(config: ToolRuntimeConfig) -> Self {
90        Self { config }
91    }
92
93    /// Create a runtime with default security limits.
94    pub fn with_defaults() -> Self {
95        Self::new(ToolRuntimeConfig::default())
96    }
97
98    /// Execute a tool in a WASM module with host callbacks.
99    pub fn execute(
100        &self,
101        wasm_bytes: &[u8],
102        tool_name: &str,
103        input_json: &str,
104        callbacks: HostCallbacks,
105    ) -> Result<RunResult, RuntimeError> {
106        let start = Instant::now();
107
108        let mut engine_config = Config::new();
109        engine_config.consume_fuel(true);
110
111        let engine = Engine::new(&engine_config)?;
112
113        let limits = StoreLimitsBuilder::new()
114            .memory_size(self.config.memory_limit_bytes as usize)
115            .build();
116        let mut store = Store::new(&engine, StoreData { limits, callbacks });
117
118        store.set_fuel(self.config.fuel)?;
119        store.limiter(|data: &mut StoreData| &mut data.limits);
120
121        let module = Module::new(&engine, wasm_bytes)?;
122        let mut linker = Linker::new(&engine);
123
124        // Register host imports
125        add_host_imports(&mut linker)?;
126
127        let instance = linker.instantiate(&mut store, &module)?;
128
129        let run_tool = instance
130            .get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "run_tool")
131            .map_err(|_| RuntimeError::ToolNotFound {
132                tool_name: tool_name.into(),
133            })?;
134
135        let memory = instance.get_memory(&mut store, "memory").ok_or_else(|| {
136            RuntimeError::ToolNotFound {
137                tool_name: "memory".into(),
138            }
139        })?;
140
141        // Write tool_name and input_json into WASM memory
142        let tool_name_bytes = tool_name.as_bytes();
143        let input_bytes = input_json.as_bytes();
144
145        let tool_name_ptr = 0i32;
146        let input_ptr = tool_name_bytes.len() as i32;
147
148        let total_needed = tool_name_bytes.len() + input_bytes.len();
149        let pages_needed = total_needed.div_ceil(65536);
150        let current_pages = memory.size(&store) as usize;
151        if total_needed > current_pages * 65536 {
152            memory.grow(&mut store, pages_needed as u64)?;
153        }
154
155        let mem_data = memory.data_mut(&mut store);
156        mem_data[..tool_name_bytes.len()].copy_from_slice(tool_name_bytes);
157        mem_data[tool_name_bytes.len()..tool_name_bytes.len() + input_bytes.len()]
158            .copy_from_slice(input_bytes);
159
160        // Timeout thread
161        let timed_out = Arc::new(AtomicBool::new(false));
162        let timed_out_clone = timed_out.clone();
163        let timeout = self.config.timeout;
164        let timeout_thread = std::thread::spawn(move || {
165            std::thread::sleep(timeout);
166            timed_out_clone.store(true, Ordering::Relaxed);
167        });
168
169        let result = run_tool.call(
170            &mut store,
171            (
172                tool_name_ptr,
173                tool_name_bytes.len() as i32,
174                input_ptr,
175                input_bytes.len() as i32,
176            ),
177        );
178
179        let timed_out_flag = timed_out.load(Ordering::Relaxed);
180        drop(timeout_thread);
181
182        let fuel_after = self
183            .config
184            .fuel
185            .saturating_sub(store.get_fuel().unwrap_or(0));
186        let duration = start.elapsed();
187
188        match result {
189            Ok(output_ptr) => {
190                let mem_data = memory.data(&store);
191                let ptr = output_ptr as usize;
192
193                if ptr + 4 > mem_data.len() {
194                    return Err(RuntimeError::PluginError("invalid output pointer".into()));
195                }
196
197                let len = u32::from_le_bytes([
198                    mem_data[ptr],
199                    mem_data[ptr + 1],
200                    mem_data[ptr + 2],
201                    mem_data[ptr + 3],
202                ]) as usize;
203
204                if ptr + 4 + len > mem_data.len() {
205                    return Err(RuntimeError::PluginError(
206                        "output extends beyond memory".into(),
207                    ));
208                }
209
210                let output_bytes = &mem_data[ptr + 4..ptr + 4 + len];
211                let output = String::from_utf8_lossy(output_bytes).to_string();
212
213                if output.len() > self.config.max_output_bytes {
214                    return Err(RuntimeError::OutputTooLarge {
215                        size_bytes: output.len(),
216                        limit_bytes: self.config.max_output_bytes,
217                    });
218                }
219
220                Ok(RunResult {
221                    output,
222                    fuel_consumed: fuel_after,
223                    duration,
224                })
225            }
226            Err(e) => {
227                if timed_out_flag {
228                    return Err(RuntimeError::Timeout {
229                        timeout_secs: self.config.timeout.as_secs(),
230                    });
231                }
232
233                let e_str = format!("{:?}", e);
234                if e_str.contains("fuel") || e_str.contains("all fuel consumed") {
235                    return Err(RuntimeError::FuelExhausted);
236                }
237
238                if e_str.contains("memory") || e_str.contains("out of bounds") {
239                    return Err(RuntimeError::MemoryLimitExceeded {
240                        limit_mb: self.config.memory_limit_bytes / (1024 * 1024),
241                    });
242                }
243
244                Err(RuntimeError::Engine(e.to_string()))
245            }
246        }
247    }
248}
249
250/// Helper: read a string from WASM memory.
251fn read_wasm_string(mem: &[u8], ptr: i32, len: i32) -> String {
252    let start = ptr as usize;
253    let end = start + len as usize;
254    if end > mem.len() {
255        return String::new();
256    }
257    String::from_utf8_lossy(&mem[start..end]).to_string()
258}
259
260/// Helper: write a string to WASM memory and return the pointer.
261/// Format: [4 bytes length LE][content bytes]
262fn write_wasm_string(mem: &mut [u8], content: &str, offset: usize) -> i32 {
263    let bytes = content.as_bytes();
264    let len = bytes.len() as u32;
265    mem[offset..offset + 4].copy_from_slice(&len.to_le_bytes());
266    mem[offset + 4..offset + 4 + bytes.len()].copy_from_slice(bytes);
267    offset as i32
268}
269
270/// Register host imports with real implementations backed by HostCallbacks.
271fn add_host_imports(linker: &mut Linker<StoreData>) -> Result<(), RuntimeError> {
272    // fs.read-project-file(path_ptr, path_len) -> result_ptr
273    linker.func_wrap(
274        "fs",
275        "read-project-file",
276        |mut caller: Caller<'_, StoreData>, ptr: i32, len: i32| -> i32 {
277            let path = {
278                let mem = caller.get_export("memory").and_then(|e| e.into_memory());
279                match mem {
280                    Some(m) => read_wasm_string(m.data(&caller), ptr, len),
281                    None => return -1,
282                }
283            };
284
285            let result = (caller.data().callbacks.fs_read)(&path);
286
287            let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
288                Some(m) => m,
289                None => return -1,
290            };
291            // Write result at the end of current memory (after input area)
292            let offset = mem.data_size(&caller).saturating_sub(4096);
293            write_wasm_string(mem.data_mut(&mut caller), &result, offset)
294        },
295    )?;
296
297    // fs.list-project-dir(path_ptr, path_len) -> result_ptr
298    linker.func_wrap(
299        "fs",
300        "list-project-dir",
301        |mut caller: Caller<'_, StoreData>, ptr: i32, len: i32| -> i32 {
302            let path = {
303                let mem = caller.get_export("memory").and_then(|e| e.into_memory());
304                match mem {
305                    Some(m) => read_wasm_string(m.data(&caller), ptr, len),
306                    None => return -1,
307                }
308            };
309
310            let result = (caller.data().callbacks.fs_list)(&path);
311
312            let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
313                Some(m) => m,
314                None => return -1,
315            };
316            let offset = mem.data_size(&caller).saturating_sub(4096);
317            write_wasm_string(mem.data_mut(&mut caller), &result, offset)
318        },
319    )?;
320
321    // http.request(method_ptr, method_len, url_ptr, url_len, body_ptr, body_len) -> result_ptr
322    linker.func_wrap(
323        "http",
324        "request",
325        |mut caller: Caller<'_, StoreData>,
326         m_ptr: i32,
327         m_len: i32,
328         u_ptr: i32,
329         u_len: i32,
330         b_ptr: i32,
331         b_len: i32|
332         -> i32 {
333            let (method, url, body) = {
334                let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
335                    Some(m) => m,
336                    None => return -1,
337                };
338                let data = mem.data(&caller);
339                let method = read_wasm_string(data, m_ptr, m_len);
340                let url = read_wasm_string(data, u_ptr, u_len);
341                let body = if b_ptr >= 0 && b_len > 0 {
342                    read_wasm_string(data, b_ptr, b_len)
343                } else {
344                    String::new()
345                };
346                (method, url, body)
347            };
348
349            let input = if body.is_empty() {
350                format!(r#"{{"method":"{}","url":"{}"}}"#, method, url)
351            } else {
352                format!(
353                    r#"{{"method":"{}","url":"{}","body":"{}"}}"#,
354                    method, url, body
355                )
356            };
357
358            let result = (caller.data().callbacks.http_request)(&input);
359
360            let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
361                Some(m) => m,
362                None => return -1,
363            };
364            let offset = mem.data_size(&caller).saturating_sub(4096);
365            write_wasm_string(mem.data_mut(&mut caller), &result, offset)
366        },
367    )?;
368
369    // git.status() -> result_ptr
370    linker.func_wrap(
371        "git",
372        "status",
373        |mut caller: Caller<'_, StoreData>| -> i32 {
374            let result = (caller.data().callbacks.git_status)();
375
376            let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
377                Some(m) => m,
378                None => return -1,
379            };
380            let offset = mem.data_size(&caller).saturating_sub(4096);
381            write_wasm_string(mem.data_mut(&mut caller), &result, offset)
382        },
383    )?;
384
385    // git.diff() -> result_ptr
386    linker.func_wrap("git", "diff", |mut caller: Caller<'_, StoreData>| -> i32 {
387        let result = (caller.data().callbacks.git_diff)();
388
389        let mem = match caller.get_export("memory").and_then(|e| e.into_memory()) {
390            Some(m) => m,
391            None => return -1,
392        };
393        let offset = mem.data_size(&caller).saturating_sub(4096);
394        write_wasm_string(mem.data_mut(&mut caller), &result, offset)
395    })?;
396
397    Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn default_config_matches_spec() {
406        let config = ToolRuntimeConfig::default();
407        assert_eq!(config.timeout, Duration::from_secs(30));
408        assert_eq!(config.memory_limit_bytes, 64 * 1024 * 1024);
409        assert_eq!(config.fuel, 10_000_000);
410        assert_eq!(config.max_output_bytes, 32 * 1024);
411        assert_eq!(config.stack_size_bytes, 1024 * 1024);
412    }
413
414    #[test]
415    fn runtime_creation() {
416        let runtime = PluginRuntime::with_defaults();
417        assert_eq!(runtime.config.timeout, Duration::from_secs(30));
418    }
419
420    #[test]
421    fn runtime_with_custom_config() {
422        let config = ToolRuntimeConfig {
423            timeout: Duration::from_secs(5),
424            memory_limit_bytes: 16 * 1024 * 1024,
425            fuel: 1_000_000,
426            max_output_bytes: 8192,
427            stack_size_bytes: 512 * 1024,
428        };
429        let runtime = PluginRuntime::new(config.clone());
430        assert_eq!(runtime.config.timeout, Duration::from_secs(5));
431        assert_eq!(runtime.config.memory_limit_bytes, 16 * 1024 * 1024);
432    }
433
434    #[test]
435    fn execute_echo_plugin() {
436        let wasm = wat::parse_str(
437            r#"
438            (module
439                (memory (export "memory") 1)
440
441                (func $write_u32 (param $ptr i32) (param $val i32)
442                    (i32.store (local.get $ptr) (local.get $val))
443                )
444
445                (func (export "run_tool")
446                    (param $name_ptr i32) (param $name_len i32)
447                    (param $input_ptr i32) (param $input_len i32)
448                    (result i32)
449
450                    (local $output_ptr i32)
451                    (local $i i32)
452
453                    (local.set $output_ptr
454                        (i32.add (local.get $input_ptr) (local.get $input_len))
455                    )
456
457                    (call $write_u32 (local.get $output_ptr) (local.get $input_len))
458
459                    (local.set $i (i32.const 0))
460                    (block $break
461                        (loop $copy
462                            (br_if $break (i32.ge_u (local.get $i) (local.get $input_len)))
463                            (i32.store8
464                                (i32.add (i32.add (local.get $output_ptr) (i32.const 4)) (local.get $i))
465                                (i32.load8_u (i32.add (local.get $input_ptr) (local.get $i)))
466                            )
467                            (local.set $i (i32.add (local.get $i) (i32.const 1)))
468                            (br $copy)
469                        )
470                    )
471
472                    (local.get $output_ptr)
473                )
474            )
475            "#,
476        )
477        .expect("valid WAT");
478
479        let runtime = PluginRuntime::with_defaults();
480        let result = runtime.execute(
481            &wasm,
482            "echo",
483            r#"{"text":"hello"}"#,
484            HostCallbacks::default(),
485        );
486        assert!(
487            result.is_ok(),
488            "execution should succeed: {:?}",
489            result.err()
490        );
491        let result = result.unwrap();
492        assert_eq!(result.output, r#"{"text":"hello"}"#);
493    }
494
495    #[test]
496    fn execute_fuel_exhaustion() {
497        let wasm = wat::parse_str(
498            r#"
499            (module
500                (memory (export "memory") 1)
501                (func (export "run_tool")
502                    (param $name_ptr i32) (param $name_len i32)
503                    (param $input_ptr i32) (param $input_len i32)
504                    (result i32)
505                    (block $break
506                        (loop $loop
507                            (br $loop)
508                        )
509                    )
510                    (i32.const 0)
511                )
512            )
513            "#,
514        )
515        .expect("valid WAT");
516
517        let config = ToolRuntimeConfig {
518            fuel: 100,
519            timeout: Duration::from_secs(3600),
520            ..Default::default()
521        };
522        let runtime = PluginRuntime::new(config);
523        let result = runtime.execute(&wasm, "loop", "{}", HostCallbacks::default());
524        assert!(result.is_err());
525        let err = result.unwrap_err();
526        assert!(
527            matches!(err, RuntimeError::FuelExhausted),
528            "should be fuel exhaustion, got: {:?}",
529            err
530        );
531    }
532
533    #[test]
534    fn execute_timeout() {
535        let wasm = wat::parse_str(
536            r#"
537            (module
538                (memory (export "memory") 1)
539                (func (export "run_tool")
540                    (param $name_ptr i32) (param $name_len i32)
541                    (param $input_ptr i32) (param $input_len i32)
542                    (result i32)
543                    (local $i i32)
544                    (block $break
545                        (loop $loop
546                            (local.set $i (i32.add (local.get $i) (i32.const 1)))
547                            (br_if $break (i32.ge_u (local.get $i) (i32.const 1000000000)))
548                            (br $loop)
549                        )
550                    )
551                    (i32.const 0)
552                )
553            )
554            "#,
555        )
556        .expect("valid WAT");
557
558        let config = ToolRuntimeConfig {
559            timeout: Duration::from_millis(1),
560            fuel: 100_000_000,
561            ..Default::default()
562        };
563        let runtime = PluginRuntime::new(config);
564        let result = runtime.execute(&wasm, "slow", "{}", HostCallbacks::default());
565        if let Err(e) = result {
566            assert!(
567                matches!(e, RuntimeError::Timeout { .. }),
568                "should be timeout"
569            );
570        }
571    }
572
573    #[test]
574    fn tool_not_found_error() {
575        let wasm = wat::parse_str(
576            r#"
577            (module
578                (memory (export "memory") 1)
579                (func (export "other_func") (result i32)
580                    (i32.const 0)
581                )
582            )
583            "#,
584        )
585        .expect("valid WAT");
586
587        let runtime = PluginRuntime::with_defaults();
588        let result = runtime.execute(&wasm, "test", "{}", HostCallbacks::default());
589        assert!(result.is_err());
590        assert!(
591            matches!(result.unwrap_err(), RuntimeError::ToolNotFound { .. }),
592            "should be tool not found"
593        );
594    }
595
596    #[test]
597    fn host_callback_fs_read() {
598        // WASM module that calls fs.read-project-file and returns the result
599        let wasm = wat::parse_str(
600            r#"
601            (module
602                ;; Import fs.read-project-file BEFORE memory
603                (import "fs" "read-project-file" (func $fs_read (param i32 i32) (result i32)))
604
605                (memory (export "memory") 1)
606
607                (func (export "run_tool")
608                    (param $name_ptr i32) (param $name_len i32)
609                    (param $input_ptr i32) (param $input_len i32)
610                    (result i32)
611
612                    ;; Write "test.txt" at address 0
613                    (i32.store8 (i32.const 0) (i32.const 116))  ;; t
614                    (i32.store8 (i32.const 1) (i32.const 101))  ;; e
615                    (i32.store8 (i32.const 2) (i32.const 115))  ;; s
616                    (i32.store8 (i32.const 3) (i32.const 116))  ;; t
617                    (i32.store8 (i32.const 4) (i32.const 46))   ;; .
618                    (i32.store8 (i32.const 5) (i32.const 116))  ;; t
619                    (i32.store8 (i32.const 6) (i32.const 120))  ;; x
620                    (i32.store8 (i32.const 7) (i32.const 116))  ;; t
621
622                    ;; Call fs.read-project-file(0, 8)
623                    (call $fs_read (i32.const 0) (i32.const 8))
624                    ;; Return the result pointer
625                )
626            )
627            "#,
628        )
629        .expect("valid WAT");
630
631        let callbacks = HostCallbacks {
632            fs_read: Arc::new(|path| {
633                if path == "test.txt" {
634                    r#"{"content":"hello from broker"}"#.into()
635                } else {
636                    r#"{"error":"not found"}"#.into()
637                }
638            }),
639            ..Default::default()
640        };
641
642        let runtime = PluginRuntime::with_defaults();
643        let result = runtime.execute(&wasm, "read", "{}", callbacks);
644        assert!(result.is_ok(), "should succeed: {:?}", result.err());
645    }
646}