//! WASM skill runner — executes WASM modules in a wasmtime sandbox. //! //! **Status**: Active module — fully implemented with real wasmtime integration. //! Unlike Director/A2A (feature-gated off), this module is compiled by default //! but only invoked when a `.wasm` skill is loaded. No feature gate needed. //! //! Guest modules target `wasm32-wasi` and communicate via stdin/stdout JSON. //! Host provides optional functions: `zclaw_log`, `zclaw_http_fetch`, `zclaw_file_read`. use async_trait::async_trait; use serde_json::Value; use std::path::PathBuf; use tracing::{debug, warn}; use wasmtime::*; use wasmtime_wasi::p1::{self, WasiP1Ctx}; use wasmtime_wasi::DirPerms; use wasmtime_wasi::FilePerms; use wasmtime_wasi::WasiCtxBuilder; use zclaw_types::Result; use crate::{Skill, SkillContext, SkillManifest, SkillResult}; /// Maximum WASM binary size (10 MB). const MAX_WASM_SIZE: usize = 10 * 1024 * 1024; /// Fuel per second of CPU time (heuristic: ~10M instructions/sec). const FUEL_PER_SEC: u64 = 10_000_000; /// WASM skill that runs in a wasmtime sandbox. #[derive(Debug)] pub struct WasmSkill { manifest: SkillManifest, wasm_bytes: Vec, } impl WasmSkill { /// Load and validate a WASM skill from the given `.wasm` file. pub fn new(manifest: SkillManifest, wasm_path: PathBuf) -> Result { let metadata = std::fs::metadata(&wasm_path).map_err(|e| { zclaw_types::ZclawError::ToolError(format!( "Cannot read WASM file {}: {}", wasm_path.display(), e )) })?; let file_size = metadata.len() as usize; if file_size > MAX_WASM_SIZE { return Err(zclaw_types::ZclawError::InvalidInput(format!( "WASM file too large: {} bytes (max {} bytes)", file_size, MAX_WASM_SIZE ))); } let wasm_bytes = std::fs::read(&wasm_path).map_err(|e| { zclaw_types::ZclawError::ToolError(format!( "Failed to read WASM file {}: {}", wasm_path.display(), e )) })?; // Validate the module before accepting it. let engine = Engine::new(&create_engine_config()) .map_err(|e| zclaw_types::ZclawError::ToolError(format!("Engine init failed: {}", e)))?; Module::validate(&engine, &wasm_bytes).map_err(|e| { zclaw_types::ZclawError::InvalidInput(format!("Invalid WASM module: {}", e)) })?; Ok(Self { manifest, wasm_bytes, }) } } #[async_trait] impl Skill for WasmSkill { fn manifest(&self) -> &SkillManifest { &self.manifest } async fn execute(&self, context: &SkillContext, input: Value) -> Result { let start = std::time::Instant::now(); let wasm_bytes = self.wasm_bytes.clone(); let timeout_secs = context.timeout_secs; let network_allowed = context.network_allowed; let file_access_allowed = context.file_access_allowed; let working_dir = context.working_dir.clone(); let env_vars = context.env.clone(); let input_json = serde_json::to_string(&input).unwrap_or_default(); // Run synchronous wasmtime calls on a blocking thread. let result = tokio::task::spawn_blocking(move || -> Result { run_wasm( &wasm_bytes, &input_json, timeout_secs, network_allowed, file_access_allowed, working_dir.as_deref(), &env_vars, ) }) .await .map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASM task panicked: {}", e)))?; let duration_ms = start.elapsed().as_millis() as u64; match result { Ok(mut sr) => { sr.duration_ms = Some(duration_ms); Ok(sr) } Err(e) => Ok(SkillResult { success: false, output: Value::Null, error: Some(e.to_string()), duration_ms: Some(duration_ms), tokens_used: None, }), } } } /// Core WASM execution logic (blocking). fn run_wasm( wasm_bytes: &[u8], input_json: &str, timeout_secs: u64, network_allowed: bool, file_access_allowed: bool, working_dir: Option<&std::path::Path>, env_vars: &std::collections::HashMap, ) -> Result { let config = create_engine_config(); let engine = Engine::new(&config) .map_err(|e| zclaw_types::ZclawError::ToolError(format!("Engine creation failed: {}", e)))?; let module = Module::from_binary(&engine, wasm_bytes) .map_err(|e| zclaw_types::ZclawError::ToolError(format!("Module compilation failed: {}", e)))?; // Set up WASI context with piped stdin/stdout. let stdout_pipe = wasmtime_wasi::p2::pipe::MemoryOutputPipe::new(1024 * 1024); // 1 MB capacity let mut wasi_builder = WasiCtxBuilder::new(); wasi_builder .stdin(Box::new(wasmtime_wasi::p2::pipe::MemoryInputPipe::new( input_json.as_bytes().to_vec(), ))) .stdout(Box::new(stdout_pipe.clone())) .stderr(Box::new(wasmtime_wasi::p2::pipe::SinkOutputStream)); // Pass skill context as environment variables. let env_pairs: Vec<(String, String)> = env_vars .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); if !env_pairs.is_empty() { wasi_builder.envs(&env_pairs); } // Optionally preopen working directory (read-only). if file_access_allowed { if let Some(dir) = working_dir { wasi_builder .preopened_dir(dir, "/workspace", DirPerms::READ, FilePerms::READ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to preopen dir: {}", e)) })?; } } let wasi_ctx: WasiP1Ctx = wasi_builder.build_p1(); let mut linker: Linker = Linker::new(&engine); p1::add_to_linker_sync(&mut linker, |t| t) .map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASI linker setup failed: {}", e)))?; // Add host functions. add_host_functions(&mut linker, network_allowed)?; let fuel = timeout_secs * FUEL_PER_SEC; let mut store = Store::new(&engine, wasi_ctx); store.set_fuel(fuel).map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to set fuel: {}", e)) })?; let instance = linker .instantiate(&mut store, &module) .map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASM instantiation failed: {}", e)))?; // Run the `_start` function. let start_fn = instance .get_typed_func::<(), ()>(&mut store, "_start") .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("WASM module has no _start: {}", e)) })?; start_fn .call(&mut store, ()) .map_err(|e| zclaw_types::ZclawError::ToolError(format!("WASM execution failed: {}", e)))?; // Read captured stdout. let stdout_data = stdout_pipe.contents(); let stdout_str = String::from_utf8_lossy(&stdout_data); debug!("[WasmSkill] stdout length: {} bytes", stdout_str.len()); // Try to parse as JSON. let output = if stdout_str.trim().is_empty() { Value::Null } else { serde_json::from_str::(stdout_str.trim()) .unwrap_or_else(|_| Value::String(stdout_str.trim().to_string())) }; Ok(SkillResult::success(output)) } /// Configure wasmtime engine with sandbox settings. fn create_engine_config() -> Config { let mut config = Config::new(); config .consume_fuel(true) .max_wasm_stack(2 << 20) // 2 MB stack .wasm_memory64(false); config } /// Add ZCLAW host functions to the wasmtime linker. fn add_host_functions(linker: &mut Linker, _network_allowed: bool) -> Result<()> { linker .func_wrap( "env", "zclaw_log", |_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| { debug!("[WasmSkill] guest called zclaw_log"); }, ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e)) })?; linker .func_wrap( "env", "zclaw_http_fetch", |_caller: Caller<'_, WasiP1Ctx>, _url_ptr: u32, _url_len: u32, _out_ptr: u32, _out_cap: u32| -> i32 { warn!("[WasmSkill] guest called zclaw_http_fetch — denied"); -1 }, ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e)) })?; linker .func_wrap( "env", "zclaw_file_read", |_caller: Caller<'_, WasiP1Ctx>, _path_ptr: u32, _path_len: u32, _out_ptr: u32, _out_cap: u32| -> i32 { warn!("[WasmSkill] guest called zclaw_file_read — denied"); -1 }, ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_file_read: {}", e)) })?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn test_manifest(id: &str) -> SkillManifest { SkillManifest { id: zclaw_types::SkillId::new(id), name: "Test".into(), description: "Test skill".into(), version: "1.0".into(), author: None, mode: crate::SkillMode::Wasm, capabilities: vec![], input_schema: None, output_schema: None, tags: vec![], category: None, triggers: vec![], enabled: true, } } #[test] fn test_oversized_rejection() { let dir = std::env::temp_dir().join("zclaw_test_oversized"); let _ = std::fs::create_dir(&dir); let wasm_path = dir.join("big.wasm"); let big_data = vec![0u8; MAX_WASM_SIZE + 1]; std::fs::write(&wasm_path, &big_data).unwrap(); let result = WasmSkill::new(test_manifest("test-oversized"), wasm_path.clone()); let _ = std::fs::remove_file(&wasm_path); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!( err_msg.contains("too large"), "Expected 'too large', got: {}", err_msg ); } #[test] fn test_invalid_binary_rejection() { let dir = std::env::temp_dir().join("zclaw_test_invalid"); let _ = std::fs::create_dir(&dir); let wasm_path = dir.join("bad.wasm"); std::fs::write(&wasm_path, b"not a real wasm module").unwrap(); let result = WasmSkill::new(test_manifest("test-invalid"), wasm_path.clone()); let _ = std::fs::remove_file(&wasm_path); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!( err_msg.contains("Invalid WASM module"), "Expected 'Invalid WASM module', got: {}", err_msg ); } }