1use crate::error::RuntimeError;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::time::{Duration, Instant};
5use wasmtime::*;
6
7struct StoreData {
9 limits: StoreLimits,
10 callbacks: HostCallbacks,
11}
12
13#[derive(Clone)]
18pub struct HostCallbacks {
19 pub fs_read: Arc<dyn Fn(&str) -> String + Send + Sync>,
21 pub fs_list: Arc<dyn Fn(&str) -> String + Send + Sync>,
23 pub http_request: Arc<dyn Fn(&str) -> String + Send + Sync>,
25 pub git_status: Arc<dyn Fn() -> String + Send + Sync>,
27 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#[derive(Debug, Clone)]
46pub struct ToolRuntimeConfig {
47 pub timeout: Duration,
49 pub memory_limit_bytes: u64,
51 pub fuel: u64,
53 pub max_output_bytes: usize,
55 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, fuel: 10_000_000,
65 max_output_bytes: 32 * 1024, stack_size_bytes: 1024 * 1024, }
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct RunResult {
74 pub output: String,
76 pub fuel_consumed: u64,
78 pub duration: Duration,
80}
81
82pub struct PluginRuntime {
84 config: ToolRuntimeConfig,
85}
86
87impl PluginRuntime {
88 pub fn new(config: ToolRuntimeConfig) -> Self {
90 Self { config }
91 }
92
93 pub fn with_defaults() -> Self {
95 Self::new(ToolRuntimeConfig::default())
96 }
97
98 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 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 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 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
250fn 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
260fn 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
270fn add_host_imports(linker: &mut Linker<StoreData>) -> Result<(), RuntimeError> {
272 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 let offset = mem.data_size(&caller).saturating_sub(4096);
293 write_wasm_string(mem.data_mut(&mut caller), &result, offset)
294 },
295 )?;
296
297 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 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 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 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 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}