1use async_trait::async_trait;
7use rain_engine_core::{
8 SkillCapability, SkillExecutionError, SkillExecutor, SkillFailureKind, SkillInvocation,
9 SkillManifest,
10};
11use reqwest::blocking::Client;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17use thiserror::Error;
18use tokio::task;
19use url::Url;
20use wasmtime::{
21 AsContextMut, Caller, Config, Engine, Extern, Instance, Linker, Memory, Module, Store,
22 StoreLimits, StoreLimitsBuilder,
23};
24
25#[derive(Clone)]
26pub struct WasmSkillConfig {
27 pub manifest: SkillManifest,
28 pub wasm_bytes: Arc<Vec<u8>>,
29 pub capabilities: Arc<dyn WasmCapabilityHost>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct WasmSkillRequest {
34 pub invocation: SkillInvocation,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct WasmSkillResponse {
39 pub ok: bool,
40 pub value: Value,
41 pub error: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct HttpCapabilityRequest {
46 pub url: String,
47 #[serde(default = "default_method")]
48 pub method: String,
49 #[serde(default)]
50 pub headers: HashMap<String, String>,
51 #[serde(default)]
52 pub body: Option<String>,
53}
54
55fn default_method() -> String {
56 "GET".to_string()
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct HttpCapabilityResponse {
61 pub status: u16,
62 pub body: String,
63 pub headers: HashMap<String, String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub struct KvCapabilityRequest {
68 pub namespace: String,
69 pub key: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct KvCapabilityResponse {
74 pub found: bool,
75 pub value: Option<Value>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct StructuredLogEntry {
80 pub level: String,
81 pub message: String,
82 #[serde(default)]
83 pub fields: HashMap<String, Value>,
84}
85
86#[derive(Debug, Error)]
87pub enum WasmError {
88 #[error("module error: {0}")]
89 Module(String),
90}
91
92#[derive(Debug, Error, Clone, PartialEq)]
93#[error("{message}")]
94pub struct CapabilityError {
95 pub kind: SkillFailureKind,
96 pub message: String,
97}
98
99impl CapabilityError {
100 fn denied(message: impl Into<String>) -> Self {
101 Self {
102 kind: SkillFailureKind::CapabilityDenied,
103 message: message.into(),
104 }
105 }
106}
107
108pub trait WasmCapabilityHost: Send + Sync {
109 fn kv_get(
110 &self,
111 _request: KvCapabilityRequest,
112 ) -> Result<KvCapabilityResponse, CapabilityError> {
113 Err(CapabilityError::denied(
114 "key/value capability host is not configured",
115 ))
116 }
117
118 fn http_fetch(
119 &self,
120 _request: HttpCapabilityRequest,
121 ) -> Result<HttpCapabilityResponse, CapabilityError> {
122 Err(CapabilityError::denied(
123 "http capability host is not configured",
124 ))
125 }
126
127 fn log(&self, _entry: StructuredLogEntry) -> Result<(), CapabilityError> {
128 Err(CapabilityError::denied(
129 "structured log capability host is not configured",
130 ))
131 }
132}
133
134#[derive(Default)]
135pub struct NoopCapabilityHost;
136
137impl WasmCapabilityHost for NoopCapabilityHost {}
138
139#[derive(Default)]
140pub struct InMemoryCapabilityHost {
141 values: HashMap<(String, String), Value>,
142 logs: Mutex<Vec<StructuredLogEntry>>,
143 http_enabled: bool,
144}
145
146impl InMemoryCapabilityHost {
147 pub fn new() -> Self {
148 Self {
149 values: HashMap::new(),
150 logs: Mutex::new(Vec::new()),
151 http_enabled: false,
152 }
153 }
154
155 pub fn with_value(mut self, namespace: &str, key: &str, value: Value) -> Self {
156 self.values
157 .insert((namespace.to_string(), key.to_string()), value);
158 self
159 }
160
161 pub fn with_http_client(mut self) -> Self {
162 self.http_enabled = true;
163 self
164 }
165
166 pub fn logs(&self) -> Vec<StructuredLogEntry> {
167 self.logs.lock().expect("logs lock").clone()
168 }
169}
170
171impl WasmCapabilityHost for InMemoryCapabilityHost {
172 fn kv_get(
173 &self,
174 request: KvCapabilityRequest,
175 ) -> Result<KvCapabilityResponse, CapabilityError> {
176 Ok(KvCapabilityResponse {
177 found: self
178 .values
179 .contains_key(&(request.namespace.clone(), request.key.clone())),
180 value: self.values.get(&(request.namespace, request.key)).cloned(),
181 })
182 }
183
184 fn http_fetch(
185 &self,
186 request: HttpCapabilityRequest,
187 ) -> Result<HttpCapabilityResponse, CapabilityError> {
188 if !self.http_enabled {
189 return Err(CapabilityError::denied("http client is disabled"));
190 }
191 let client = Client::new();
192 let method = request
193 .method
194 .parse::<reqwest::Method>()
195 .map_err(|err| CapabilityError {
196 kind: SkillFailureKind::InvalidResponse,
197 message: err.to_string(),
198 })?;
199 let mut builder = client.request(method, &request.url);
200 for (name, value) in &request.headers {
201 builder = builder.header(name, value);
202 }
203 if let Some(body) = request.body {
204 builder = builder.body(body);
205 }
206 let response = builder.send().map_err(|err| CapabilityError {
207 kind: SkillFailureKind::Internal,
208 message: err.to_string(),
209 })?;
210 let status = response.status().as_u16();
211 let headers = response
212 .headers()
213 .iter()
214 .map(|(name, value)| {
215 (
216 name.to_string(),
217 value.to_str().unwrap_or_default().to_string(),
218 )
219 })
220 .collect::<HashMap<_, _>>();
221 let body = response.text().map_err(|err| CapabilityError {
222 kind: SkillFailureKind::Internal,
223 message: err.to_string(),
224 })?;
225 Ok(HttpCapabilityResponse {
226 status,
227 body,
228 headers,
229 })
230 }
231
232 fn log(&self, entry: StructuredLogEntry) -> Result<(), CapabilityError> {
233 self.logs.lock().expect("logs lock").push(entry);
234 Ok(())
235 }
236}
237
238struct StoreState {
239 limits: StoreLimits,
240 manifest: SkillManifest,
241 capabilities: Arc<dyn WasmCapabilityHost>,
242}
243
244pub struct WasmSkillExecutor {
245 engine: Engine,
246 module: Module,
247 manifest: SkillManifest,
248 capabilities: Arc<dyn WasmCapabilityHost>,
249}
250
251impl WasmSkillExecutor {
252 pub fn new(config: WasmSkillConfig) -> Result<Self, WasmError> {
253 let mut wasmtime_config = Config::new();
254 wasmtime_config.consume_fuel(true);
255 wasmtime_config.epoch_interruption(true);
256
257 let engine =
258 Engine::new(&wasmtime_config).map_err(|err| WasmError::Module(err.to_string()))?;
259 let module = Module::from_binary(&engine, &config.wasm_bytes)
260 .map_err(|err| WasmError::Module(err.to_string()))?;
261
262 Ok(Self {
263 engine,
264 module,
265 manifest: config.manifest,
266 capabilities: config.capabilities,
267 })
268 }
269
270 pub fn manifest(&self) -> &SkillManifest {
271 &self.manifest
272 }
273
274 fn build_store(&self) -> Result<Store<StoreState>, SkillExecutionError> {
275 let mut store = Store::new(
276 &self.engine,
277 StoreState {
278 limits: StoreLimitsBuilder::new()
279 .memory_size(self.manifest.resource_policy.max_memory_bytes)
280 .build(),
281 manifest: self.manifest.clone(),
282 capabilities: self.capabilities.clone(),
283 },
284 );
285 store.limiter(|state| &mut state.limits);
286 if let Some(fuel) = self.manifest.resource_policy.max_fuel {
287 store.set_fuel(fuel).map_err(|err| {
288 SkillExecutionError::new(SkillFailureKind::Internal, err.to_string())
289 })?;
290 }
291 Ok(store)
292 }
293}
294
295#[async_trait]
296impl SkillExecutor for WasmSkillExecutor {
297 async fn execute(&self, invocation: SkillInvocation) -> Result<Value, SkillExecutionError> {
298 let timeout = Duration::from_millis(self.manifest.resource_policy.timeout_ms);
299 let engine = self.engine.clone();
300 let module = self.module.clone();
301 let manifest = self.manifest.clone();
302 let capabilities = self.capabilities.clone();
303 let encoded = serde_json::to_vec(&WasmSkillRequest { invocation }).map_err(|err| {
304 SkillExecutionError::new(SkillFailureKind::InvalidResponse, err.to_string())
305 })?;
306
307 let join = task::spawn_blocking(move || {
308 let executor = WasmSkillExecutor {
309 engine,
310 module,
311 manifest,
312 capabilities,
313 };
314 executor.execute_blocking(encoded)
315 });
316
317 match tokio::time::timeout(timeout + Duration::from_millis(50), join).await {
318 Ok(join_result) => join_result.map_err(|err| {
319 SkillExecutionError::new(SkillFailureKind::Internal, err.to_string())
320 })?,
321 Err(_) => {
322 self.engine.increment_epoch();
323 Err(SkillExecutionError::new(
324 SkillFailureKind::Timeout,
325 "skill execution exceeded timeout",
326 ))
327 }
328 }
329 }
330
331 fn executor_kind(&self) -> &'static str {
332 "wasm"
333 }
334}
335
336impl WasmSkillExecutor {
337 fn execute_blocking(&self, encoded: Vec<u8>) -> Result<Value, SkillExecutionError> {
338 let mut store = self.build_store()?;
339 store.set_epoch_deadline(1);
340
341 let mut linker = Linker::new(&self.engine);
342 register_capabilities(&mut linker)?;
343
344 let instance = linker
345 .instantiate(&mut store, &self.module)
346 .map_err(|err| classify_trap(err.to_string()))?;
347
348 let memory = extract_memory(&mut store, &instance)?;
349 let alloc = instance
350 .get_typed_func::<i32, i32>(&mut store, "alloc")
351 .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
352 let run = instance
353 .get_typed_func::<(i32, i32), i64>(&mut store, "run")
354 .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
355 let dealloc = instance
356 .get_typed_func::<(i32, i32), ()>(&mut store, "dealloc")
357 .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
358
359 let input_ptr = alloc
360 .call(&mut store, i32::try_from(encoded.len()).unwrap_or(i32::MAX))
361 .map_err(|err| classify_trap(err.to_string()))?;
362 memory
363 .write(&mut store, input_ptr as usize, &encoded)
364 .map_err(|err| classify_memory(err.to_string()))?;
365
366 let packed = run
367 .call(
368 &mut store,
369 (input_ptr, i32::try_from(encoded.len()).unwrap_or(i32::MAX)),
370 )
371 .map_err(|err| classify_trap(err.to_string()))?;
372 let output_ptr = packed as u32;
373 let output_len = (packed >> 32) as u32;
374
375 let mut output = vec![0u8; output_len as usize];
376 memory
377 .read(&store, output_ptr as usize, &mut output)
378 .map_err(|err| classify_memory(err.to_string()))?;
379 let _ = dealloc.call(&mut store, (input_ptr, encoded.len() as i32));
380 let _ = dealloc.call(&mut store, (output_ptr as i32, output_len as i32));
381
382 if let Ok(decoded) = serde_json::from_slice::<WasmSkillResponse>(&output) {
383 if decoded.ok {
384 return Ok(decoded.value);
385 }
386 let message = decoded
387 .error
388 .unwrap_or_else(|| "wasm module returned failure".to_string());
389 let kind = if message.contains("capability") {
390 SkillFailureKind::CapabilityDenied
391 } else {
392 SkillFailureKind::Internal
393 };
394 return Err(SkillExecutionError::new(kind, message));
395 }
396
397 serde_json::from_slice::<Value>(&output).map_err(|err| {
398 SkillExecutionError::new(SkillFailureKind::InvalidResponse, err.to_string())
399 })
400 }
401}
402
403fn register_capabilities(linker: &mut Linker<StoreState>) -> Result<(), SkillExecutionError> {
404 linker
405 .func_wrap(
406 "env",
407 "host_log",
408 |mut caller: Caller<'_, StoreState>,
409 ptr: i32,
410 len: i32|
411 -> Result<i32, anyhow::Error> {
412 ensure_capability(&caller.data().manifest, CapabilityKind::Log)
413 .map_err(anyhow::Error::msg)?;
414 let bytes = read_guest_bytes(&mut caller, ptr, len).map_err(anyhow::Error::msg)?;
415 let entry: StructuredLogEntry =
416 serde_json::from_slice(&bytes).map_err(anyhow::Error::msg)?;
417 caller
418 .data()
419 .capabilities
420 .log(entry)
421 .map_err(|err| anyhow::Error::msg(err.message))?;
422 Ok(0)
423 },
424 )
425 .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
426
427 linker
428 .func_wrap(
429 "env",
430 "host_kv_get",
431 |mut caller: Caller<'_, StoreState>,
432 ptr: i32,
433 len: i32|
434 -> Result<i64, anyhow::Error> {
435 let bytes = read_guest_bytes(&mut caller, ptr, len).map_err(anyhow::Error::msg)?;
436 let request: KvCapabilityRequest =
437 serde_json::from_slice(&bytes).map_err(anyhow::Error::msg)?;
438 let response_bytes = match ensure_capability(
439 &caller.data().manifest,
440 CapabilityKind::Kv(&request.namespace),
441 ) {
442 Ok(()) => {
443 let response = caller
444 .data()
445 .capabilities
446 .kv_get(request)
447 .map_err(|err| anyhow::Error::msg(err.message))?;
448 serde_json::to_vec(&response).map_err(anyhow::Error::msg)?
449 }
450 Err(err) => {
451 serialize_error_response(err.message).map_err(anyhow::Error::msg)?
452 }
453 };
454 let (ptr, len) =
455 write_guest_bytes(&mut caller, &response_bytes).map_err(anyhow::Error::msg)?;
456 Ok(pack_ptr_len(ptr, len))
457 },
458 )
459 .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
460
461 linker
462 .func_wrap(
463 "env",
464 "host_http_fetch",
465 |mut caller: Caller<'_, StoreState>,
466 ptr: i32,
467 len: i32|
468 -> Result<i64, anyhow::Error> {
469 let bytes = read_guest_bytes(&mut caller, ptr, len).map_err(anyhow::Error::msg)?;
470 let request: HttpCapabilityRequest =
471 serde_json::from_slice(&bytes).map_err(anyhow::Error::msg)?;
472 let response_bytes = match ensure_capability(
473 &caller.data().manifest,
474 CapabilityKind::Http(&request.url),
475 ) {
476 Ok(()) => {
477 let response = caller
478 .data()
479 .capabilities
480 .http_fetch(request)
481 .map_err(|err| anyhow::Error::msg(err.message))?;
482 serde_json::to_vec(&response).map_err(anyhow::Error::msg)?
483 }
484 Err(err) => {
485 serialize_error_response(err.message).map_err(anyhow::Error::msg)?
486 }
487 };
488 let (ptr, len) =
489 write_guest_bytes(&mut caller, &response_bytes).map_err(anyhow::Error::msg)?;
490 Ok(pack_ptr_len(ptr, len))
491 },
492 )
493 .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
494
495 Ok(())
496}
497
498enum CapabilityKind<'a> {
499 Log,
500 Kv(&'a str),
501 Http(&'a str),
502}
503
504fn ensure_capability(
505 manifest: &SkillManifest,
506 requested: CapabilityKind<'_>,
507) -> Result<(), CapabilityError> {
508 match requested {
509 CapabilityKind::Log => manifest
510 .capability_grants
511 .iter()
512 .any(|capability| matches!(capability, SkillCapability::StructuredLog))
513 .then_some(())
514 .ok_or_else(|| CapabilityError::denied("structured log capability not granted")),
515 CapabilityKind::Kv(namespace) => manifest
516 .capability_grants
517 .iter()
518 .find_map(|capability| match capability {
519 SkillCapability::KeyValueRead { namespaces }
520 if namespaces.iter().any(|allowed| allowed == namespace) =>
521 {
522 Some(())
523 }
524 _ => None,
525 })
526 .ok_or_else(|| {
527 CapabilityError::denied(format!(
528 "key/value capability not granted for namespace `{namespace}`"
529 ))
530 }),
531 CapabilityKind::Http(url) => {
532 let parsed = Url::parse(url).map_err(|err| CapabilityError {
533 kind: SkillFailureKind::InvalidResponse,
534 message: err.to_string(),
535 })?;
536 let host = parsed.host_str().unwrap_or_default();
537 manifest
538 .capability_grants
539 .iter()
540 .find_map(|capability| match capability {
541 SkillCapability::HttpOutbound { allow_hosts }
542 if allow_hosts.iter().any(|allowed| allowed == host) =>
543 {
544 Some(())
545 }
546 _ => None,
547 })
548 .ok_or_else(|| {
549 CapabilityError::denied(format!(
550 "http capability not granted for host `{host}`"
551 ))
552 })
553 }
554 }
555}
556
557fn pack_ptr_len(ptr: i32, len: i32) -> i64 {
558 ((len as i64) << 32) | (ptr as u32 as i64)
559}
560
561fn serialize_error_response(message: String) -> Result<Vec<u8>, serde_json::Error> {
562 serde_json::to_vec(&WasmSkillResponse {
563 ok: false,
564 value: Value::Null,
565 error: Some(message),
566 })
567}
568
569fn read_guest_bytes(
570 caller: &mut Caller<'_, StoreState>,
571 ptr: i32,
572 len: i32,
573) -> Result<Vec<u8>, SkillExecutionError> {
574 let memory = match caller.get_export("memory") {
575 Some(Extern::Memory(memory)) => memory,
576 _ => {
577 return Err(SkillExecutionError::new(
578 SkillFailureKind::Internal,
579 "wasm module must export memory",
580 ));
581 }
582 };
583 let mut output = vec![0u8; len as usize];
584 memory
585 .read(caller.as_context_mut(), ptr as usize, &mut output)
586 .map_err(|err| classify_memory(err.to_string()))?;
587 Ok(output)
588}
589
590fn write_guest_bytes(
591 caller: &mut Caller<'_, StoreState>,
592 bytes: &[u8],
593) -> Result<(i32, i32), SkillExecutionError> {
594 let alloc = caller
595 .get_export("alloc")
596 .and_then(|export| export.into_func())
597 .ok_or_else(|| {
598 SkillExecutionError::new(SkillFailureKind::Internal, "wasm module must export alloc")
599 })?;
600 let alloc = alloc
601 .typed::<i32, i32>(caller.as_context_mut())
602 .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
603 let memory = match caller.get_export("memory") {
604 Some(Extern::Memory(memory)) => memory,
605 _ => {
606 return Err(SkillExecutionError::new(
607 SkillFailureKind::Internal,
608 "wasm module must export memory",
609 ));
610 }
611 };
612 let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
613 let ptr = alloc
614 .call(caller.as_context_mut(), len)
615 .map_err(|err| classify_trap(err.to_string()))?;
616 memory
617 .write(caller.as_context_mut(), ptr as usize, bytes)
618 .map_err(|err| classify_memory(err.to_string()))?;
619 Ok((ptr, len))
620}
621
622fn extract_memory(
623 store: &mut Store<StoreState>,
624 instance: &Instance,
625) -> Result<Memory, SkillExecutionError> {
626 match instance.get_export(store.as_context_mut(), "memory") {
627 Some(Extern::Memory(memory)) => Ok(memory),
628 _ => Err(SkillExecutionError::new(
629 SkillFailureKind::Internal,
630 "wasm module must export memory",
631 )),
632 }
633}
634
635fn classify_memory(message: String) -> SkillExecutionError {
636 SkillExecutionError::new(SkillFailureKind::MemoryLimitExceeded, message)
637}
638
639fn classify_trap(message: String) -> SkillExecutionError {
640 let kind = if message.contains("all fuel consumed") {
641 SkillFailureKind::Timeout
642 } else if message.contains("out of bounds memory access")
643 || message.contains("memory")
644 || message.contains("limit")
645 {
646 SkillFailureKind::MemoryLimitExceeded
647 } else if message.contains("capability") {
648 SkillFailureKind::CapabilityDenied
649 } else {
650 SkillFailureKind::Trap
651 };
652 SkillExecutionError::new(kind, message)
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use axum::{Json, Router, routing::get};
659 use rain_engine_core::{
660 AgentContextSnapshot, AgentId, AgentStateSnapshot, EnginePolicy, ResourcePolicy,
661 RetryPolicy,
662 };
663 use serde_json::json;
664
665 fn manifest(timeout_ms: u64, max_memory_bytes: usize) -> SkillManifest {
666 SkillManifest {
667 name: "echo".to_string(),
668 description: "echo".to_string(),
669 input_schema: json!({"type": "object"}),
670 required_scopes: vec!["tool:run".to_string()],
671 capability_grants: vec![SkillCapability::StructuredLog],
672 resource_policy: ResourcePolicy {
673 timeout_ms,
674 max_memory_bytes,
675 max_fuel: Some(1_000_000),
676 priority_class: 0,
677 retry_policy: RetryPolicy::default(),
678 dry_run_supported: false,
679 },
680 approval_required: false,
681 circuit_breaker_threshold: 0.5,
682 }
683 }
684
685 fn invocation(manifest: SkillManifest) -> SkillInvocation {
686 SkillInvocation {
687 call_id: "call-1".to_string(),
688 manifest,
689 args: json!({"value": 1}),
690 dry_run: false,
691 context: AgentContextSnapshot {
692 session_id: "session".to_string(),
693 granted_scopes: vec!["tool:run".to_string()],
694 trigger_id: "trigger".to_string(),
695 idempotency_key: None,
696 current_step: 0,
697 max_steps: 4,
698 history: Vec::new(),
699 prior_tool_results: Vec::new(),
700 session_cost_usd: 0.0,
701 state: AgentStateSnapshot {
702 agent_id: AgentId("session".to_string()),
703 profile: None,
704 goals: Vec::new(),
705 tasks: Vec::new(),
706 observations: Vec::new(),
707 artifacts: Vec::new(),
708 resources: Vec::new(),
709 relationships: Vec::new(),
710 pending_wake: None,
711 },
712 policy: EnginePolicy::default(),
713 active_execution_plan: None,
714 },
715 }
716 }
717
718 #[tokio::test]
719 async fn executes_successful_wasm_skill() {
720 let module = wat::parse_str(
721 r#"
722 (module
723 (memory (export "memory") 1)
724 (global $heap (mut i32) (i32.const 4096))
725 (func (export "alloc") (param $len i32) (result i32)
726 (local $ptr i32)
727 global.get $heap
728 local.set $ptr
729 global.get $heap
730 local.get $len
731 i32.add
732 global.set $heap
733 local.get $ptr)
734 (func (export "dealloc") (param i32 i32))
735 (data (i32.const 0) "{\"ok\":true,\"value\":{\"status\":\"ok\"},\"error\":null}")
736 (func (export "run") (param i32 i32) (result i64)
737 i64.const 206158430208)
738 )
739 "#,
740 )
741 .expect("wat");
742
743 let manifest = manifest(1_000, 65_536);
744 let executor = WasmSkillExecutor::new(WasmSkillConfig {
745 manifest: manifest.clone(),
746 wasm_bytes: Arc::new(module),
747 capabilities: Arc::new(NoopCapabilityHost),
748 })
749 .expect("executor");
750
751 let output = executor.execute(invocation(manifest)).await.expect("value");
752
753 assert_eq!(output, json!({"status": "ok"}));
754 }
755
756 #[tokio::test]
757 async fn allowed_http_capability_only_reaches_allowlisted_hosts() {
758 let app = Router::new().route("/ok", get(|| async { Json(json!({"ok": true})) }));
759 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
760 .await
761 .expect("bind");
762 let addr = listener.local_addr().expect("addr");
763 tokio::spawn(async move {
764 axum::serve(listener, app).await.expect("server");
765 });
766
767 let request_json = format!(
768 "{{\"url\":\"http://{}/ok\",\"method\":\"GET\",\"headers\":{{}},\"body\":null}}",
769 addr
770 );
771 let wat_request_json = request_json.replace('\\', "\\\\").replace('"', "\\\"");
772 let module = wat::parse_str(format!(
773 r#"
774 (module
775 (import "env" "host_http_fetch" (func $host_http_fetch (param i32 i32) (result i64)))
776 (memory (export "memory") 1)
777 (global $heap (mut i32) (i32.const 4096))
778 (func (export "alloc") (param $len i32) (result i32)
779 (local $ptr i32)
780 global.get $heap
781 local.set $ptr
782 global.get $heap
783 local.get $len
784 i32.add
785 global.set $heap
786 local.get $ptr)
787 (func (export "dealloc") (param i32 i32))
788 (data (i32.const 0) "{wat_request_json}")
789 (func (export "run") (param i32 i32) (result i64)
790 i32.const 0
791 i32.const {len}
792 call $host_http_fetch)
793 )
794 "#,
795 len = request_json.len()
796 ))
797 .expect("wat");
798
799 let allowed_manifest = SkillManifest {
800 capability_grants: vec![
801 SkillCapability::HttpOutbound {
802 allow_hosts: vec!["127.0.0.1".to_string()],
803 },
804 SkillCapability::StructuredLog,
805 ],
806 ..manifest(1_000, 65_536)
807 };
808 let executor = WasmSkillExecutor::new(WasmSkillConfig {
809 manifest: allowed_manifest.clone(),
810 wasm_bytes: Arc::new(module.clone()),
811 capabilities: Arc::new(InMemoryCapabilityHost::default().with_http_client()),
812 })
813 .expect("executor");
814 let output = executor
815 .execute(invocation(allowed_manifest))
816 .await
817 .expect("http output");
818 assert_eq!(output["status"], json!(200));
819
820 let denied_manifest = SkillManifest {
821 capability_grants: vec![SkillCapability::StructuredLog],
822 ..manifest(1_000, 65_536)
823 };
824 let denied = WasmSkillExecutor::new(WasmSkillConfig {
825 manifest: denied_manifest.clone(),
826 wasm_bytes: Arc::new(module),
827 capabilities: Arc::new(InMemoryCapabilityHost::default().with_http_client()),
828 })
829 .expect("executor");
830 let error = denied
831 .execute(invocation(denied_manifest))
832 .await
833 .expect_err("capability denied");
834 assert_eq!(error.kind, SkillFailureKind::CapabilityDenied);
835 }
836
837 #[tokio::test]
838 async fn wasm_traps_are_contained() {
839 let module = wat::parse_str(
840 r#"
841 (module
842 (memory (export "memory") 1)
843 (func (export "alloc") (param i32) (result i32) (i32.const 0))
844 (func (export "dealloc") (param i32 i32))
845 (func (export "run") (param i32 i32) (result i64)
846 unreachable)
847 )
848 "#,
849 )
850 .expect("wat");
851
852 let manifest = manifest(1_000, 65_536);
853 let executor = WasmSkillExecutor::new(WasmSkillConfig {
854 manifest: manifest.clone(),
855 wasm_bytes: Arc::new(module),
856 capabilities: Arc::new(NoopCapabilityHost),
857 })
858 .expect("executor");
859
860 let err = executor
861 .execute(invocation(manifest))
862 .await
863 .expect_err("trap");
864
865 assert_eq!(err.kind, SkillFailureKind::Trap);
866 }
867
868 #[tokio::test]
869 async fn wasm_timeout_is_reported() {
870 let module = wat::parse_str(
871 r#"
872 (module
873 (memory (export "memory") 1)
874 (func (export "alloc") (param i32) (result i32) (i32.const 0))
875 (func (export "dealloc") (param i32 i32))
876 (func (export "run") (param i32 i32) (result i64)
877 (loop br 0)
878 i64.const 0)
879 )
880 "#,
881 )
882 .expect("wat");
883
884 let manifest = manifest(10, 65_536);
885 let executor = WasmSkillExecutor::new(WasmSkillConfig {
886 manifest: manifest.clone(),
887 wasm_bytes: Arc::new(module),
888 capabilities: Arc::new(NoopCapabilityHost),
889 })
890 .expect("executor");
891
892 let err = executor
893 .execute(invocation(manifest))
894 .await
895 .expect_err("timeout");
896
897 assert!(matches!(
898 err.kind,
899 SkillFailureKind::Timeout | SkillFailureKind::Trap
900 ));
901 }
902}