agent_sandbox/runtime/
mod.rs1use std::sync::{Arc, OnceLock};
2
3use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, Trap};
4use wasmtime_wasi::WasiCtx;
5use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
6
7use crate::config::SandboxConfig;
8use crate::error::{Result, SandboxError};
9
10#[derive(Debug, Clone)]
12pub struct ExecResult {
13 pub exit_code: i32,
14 pub stdout: Vec<u8>,
15 pub stderr: Vec<u8>,
16}
17
18struct SandboxState {
20 wasi: wasmtime_wasi::p1::WasiP1Ctx,
21 limits: StoreLimits,
22}
23
24struct CachedModule {
26 engine: Engine,
27 module: Module,
28}
29
30static MODULE_CACHE: OnceLock<std::result::Result<CachedModule, String>> = OnceLock::new();
33
34fn get_or_compile_module() -> Result<(&'static Engine, &'static Module)> {
35 let cached = MODULE_CACHE.get_or_init(|| {
36 let precompiled_bytes = include_bytes!(env!("TOOLBOX_CWASM_PATH"));
37
38 if precompiled_bytes.is_empty() {
39 return Err("WASM toolbox not available".to_string());
40 }
41
42 let mut engine_config = Config::new();
44 engine_config.consume_fuel(true);
45
46 let engine =
47 Engine::new(&engine_config).map_err(|e| format!("engine creation failed: {e}"))?;
48
49 let module = unsafe { Module::deserialize(&engine, precompiled_bytes) }
52 .map_err(|e| format!("module deserialization failed: {e}"))?;
53
54 Ok(CachedModule { engine, module })
55 });
56
57 match cached {
58 Ok(c) => Ok((&c.engine, &c.module)),
59 Err(e) => Err(SandboxError::Other(e.clone())),
60 }
61}
62
63pub struct WasiRuntime {
65 engine: &'static Engine,
66 module: &'static Module,
67 config: Arc<SandboxConfig>,
68}
69
70impl WasiRuntime {
71 pub fn new(config: SandboxConfig) -> Result<Self> {
74 let (engine, module) = get_or_compile_module()?;
75
76 Ok(Self {
77 engine,
78 module,
79 config: Arc::new(config),
80 })
81 }
82
83 pub async fn exec(&self, command: &str, args: &[String]) -> Result<ExecResult> {
85 let config = self.config.clone();
86 let engine = self.engine;
87 let module = self.module;
88 let command = command.to_string();
89 let args = args.to_vec();
90 let timeout = config.timeout;
91
92 let task = tokio::task::spawn_blocking(move || {
94 exec_sync(engine, module, &config, &command, &args)
95 });
96
97 match tokio::time::timeout(timeout, task).await {
98 Ok(Ok(result)) => result,
99 Ok(Err(e)) => Err(SandboxError::Other(format!("task join error: {}", e))),
100 Err(_) => Err(SandboxError::Timeout(timeout)),
101 }
102 }
103}
104
105fn exec_sync(
106 engine: &Engine,
107 module: &Module,
108 config: &SandboxConfig,
109 command: &str,
110 args: &[String],
111) -> Result<ExecResult> {
112 let mut argv: Vec<String> = vec![command.to_string()];
114 argv.extend(args.iter().cloned());
115
116 let argv_refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
117
118 let stdout_pipe = MemoryOutputPipe::new(1024 * 1024); let stderr_pipe = MemoryOutputPipe::new(1024 * 1024);
121
122 let mut builder = WasiCtx::builder();
124 builder.args(&argv_refs);
125 builder.stdin(MemoryInputPipe::new(b"" as &[u8])); builder.stdout(stdout_pipe.clone());
127 builder.stderr(stderr_pipe.clone());
128
129 builder.env("TOOLBOX_CMD", command);
131
132 for (key, value) in &config.env_vars {
134 builder.env(key, value);
135 }
136
137 let work_dir = config.work_dir.canonicalize().map_err(|e| {
139 SandboxError::Io(std::io::Error::new(
140 std::io::ErrorKind::NotFound,
141 format!("work_dir '{}': {}", config.work_dir.display(), e),
142 ))
143 })?;
144
145 let dir = wasmtime_wasi::DirPerms::all();
146 let file = wasmtime_wasi::FilePerms::all();
147 builder.preopened_dir(&work_dir, "/work", dir, file)?;
148
149 for mount in &config.mounts {
151 let host = mount.host_path.canonicalize().map_err(|e| {
152 SandboxError::Io(std::io::Error::new(
153 std::io::ErrorKind::NotFound,
154 format!("mount '{}': {}", mount.host_path.display(), e),
155 ))
156 })?;
157
158 let (d, f) = if mount.writable {
159 (
160 wasmtime_wasi::DirPerms::all(),
161 wasmtime_wasi::FilePerms::all(),
162 )
163 } else {
164 (
165 wasmtime_wasi::DirPerms::READ,
166 wasmtime_wasi::FilePerms::READ,
167 )
168 };
169
170 builder.preopened_dir(&host, &mount.guest_path, d, f)?;
171 }
172
173 let wasi_p1 = builder.build_p1();
175
176 let limits = StoreLimitsBuilder::new()
178 .memory_size(config.memory_limit_bytes as usize)
179 .build();
180
181 let mut store = Store::new(
182 engine,
183 SandboxState {
184 wasi: wasi_p1,
185 limits,
186 },
187 );
188 store.limiter(|state| &mut state.limits);
189
190 store.set_fuel(config.fuel_limit)?;
192
193 let mut linker = Linker::new(engine);
195 wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |state: &mut SandboxState| &mut state.wasi)?;
196
197 linker.module(&mut store, "", module)?;
198
199 let func = linker
201 .get_default(&mut store, "")?
202 .typed::<(), ()>(&store)?;
203
204 let exit_code = match func.call(&mut store, ()) {
205 Ok(()) => 0,
206 Err(e) => {
207 if let Some(exit) = e.downcast_ref::<wasmtime_wasi::I32Exit>() {
209 exit.0
210 } else if e.downcast_ref::<Trap>() == Some(&Trap::OutOfFuel) {
211 return Err(SandboxError::Timeout(config.timeout));
212 } else {
213 return Err(SandboxError::Runtime(e));
214 }
215 }
216 };
217
218 let stdout_bytes = stdout_pipe.contents().to_vec();
219 let stderr_bytes = stderr_pipe.contents().to_vec();
220
221 Ok(ExecResult {
222 exit_code,
223 stdout: stdout_bytes,
224 stderr: stderr_bytes,
225 })
226}