1use std::{
17 collections::HashMap,
18 sync::{
19 Arc,
20 atomic::{AtomicU64, Ordering},
21 },
22 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
23};
24
25#[cfg(feature = "vm")]
26use base64::{Engine, engine::general_purpose::STANDARD};
27use mimobox_sdk::{Config, DirEntry, ExecuteResult, FileType, IsolationLevel, Sandbox, SdkError};
28use rmcp::handler::server::wrapper::Json;
29use rmcp::schemars::JsonSchema;
30use rmcp::{
31 ServerHandler,
32 handler::server::{router::tool::ToolRouter, wrapper::Parameters},
33 model::{ServerCapabilities, ServerInfo},
34 tool, tool_handler, tool_router,
35};
36use serde::{Deserialize, Serialize};
37use tokio::sync::Mutex;
38use tokio::task::JoinError;
39use tracing::error;
40
41#[derive(Clone)]
42pub struct MimoboxServer {
43 pub(crate) sandboxes: Arc<Mutex<HashMap<u64, ManagedSandbox>>>,
44 pub next_id: Arc<AtomicU64>,
45 pub tool_router: ToolRouter<Self>,
46}
47
48struct ManagedSandbox {
49 sandbox: Sandbox,
50 created_at_ms: u64,
51 created_at_instant: Instant,
52}
53
54#[derive(Debug, Deserialize, JsonSchema)]
55pub struct CreateSandboxRequest {
56 isolation_level: Option<String>,
58 timeout_ms: Option<u64>,
60 memory_limit_mb: Option<u64>,
62}
63
64#[derive(Debug, Serialize, JsonSchema)]
65pub struct CreateSandboxResponse {
66 sandbox_id: u64,
67 isolation_level: String,
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
71pub struct ExecuteCodeRequest {
72 sandbox_id: Option<u64>,
74 code: String,
76 language: Option<String>,
78 timeout_ms: Option<u64>,
80}
81
82#[derive(Debug, Deserialize, JsonSchema)]
83pub struct ExecuteCommandRequest {
84 sandbox_id: Option<u64>,
86 command: String,
88 timeout_ms: Option<u64>,
90}
91
92#[derive(Debug, Deserialize, JsonSchema)]
93pub struct DestroySandboxRequest {
94 sandbox_id: u64,
96}
97
98#[derive(Debug, Deserialize, JsonSchema)]
99pub struct ListSandboxesRequest {}
100
101#[derive(Debug, Deserialize, JsonSchema)]
102#[cfg_attr(not(feature = "vm"), allow(dead_code))]
103pub struct ReadFileRequest {
104 sandbox_id: u64,
106 path: String,
108}
109
110#[derive(Debug, Deserialize, JsonSchema)]
111#[cfg_attr(not(feature = "vm"), allow(dead_code))]
112pub struct WriteFileRequest {
113 sandbox_id: u64,
115 path: String,
117 content: String,
119}
120
121#[derive(Debug, Deserialize, JsonSchema)]
122#[cfg_attr(not(feature = "vm"), allow(dead_code))]
123pub struct SnapshotRequest {
124 sandbox_id: u64,
126}
127
128#[derive(Debug, Deserialize, JsonSchema)]
129#[cfg_attr(not(feature = "vm"), allow(dead_code))]
130pub struct ForkRequest {
131 sandbox_id: u64,
133}
134
135#[derive(Debug, Deserialize, JsonSchema)]
136#[cfg_attr(not(feature = "vm"), allow(dead_code))]
137pub struct McpHttpRequest {
138 sandbox_id: u64,
140 url: String,
142 method: String,
144}
145
146#[derive(Debug, Deserialize, JsonSchema)]
147pub struct ListDirRequest {
148 sandbox_id: u64,
150 path: String,
152}
153
154#[derive(Debug, Serialize, JsonSchema)]
155pub struct ExecuteResponse {
156 stdout: String,
157 stderr: String,
158 exit_code: Option<i32>,
159 timed_out: bool,
160 elapsed_ms: u128,
161}
162
163#[derive(Debug, Serialize, JsonSchema)]
164pub struct DestroySandboxResponse {
165 sandbox_id: u64,
166 destroyed: bool,
167}
168
169#[derive(Debug, Serialize, JsonSchema)]
170pub struct ListSandboxesResponse {
171 sandboxes: Vec<SandboxSummary>,
172}
173
174#[derive(Debug, Serialize, JsonSchema)]
175pub struct SandboxSummary {
176 sandbox_id: u64,
177 isolation_level: Option<String>,
178 created_at: u64,
179 uptime_ms: u128,
180}
181
182#[derive(Debug, Serialize, JsonSchema)]
183pub struct ReadFileResponse {
184 sandbox_id: u64,
185 path: String,
186 content: String,
187 size_bytes: usize,
188}
189
190#[derive(Debug, Serialize, JsonSchema)]
191pub struct WriteFileResponse {
192 sandbox_id: u64,
193 path: String,
194 size_bytes: usize,
195 written: bool,
196}
197
198#[derive(Debug, Serialize, JsonSchema)]
199pub struct SnapshotResponse {
200 sandbox_id: u64,
201 size_bytes: usize,
202}
203
204#[derive(Debug, Serialize, JsonSchema)]
205pub struct ForkResponse {
206 original_sandbox_id: u64,
207 new_sandbox_id: u64,
208}
209
210#[derive(Debug, Serialize, JsonSchema)]
211pub struct McpHttpResponse {
212 sandbox_id: u64,
213 status: u16,
214 headers: HashMap<String, String>,
215 body: String,
216}
217
218#[derive(Debug, Serialize, JsonSchema)]
219pub struct ListDirEntry {
220 name: String,
221 file_type: String,
222 size: u64,
223 is_symlink: bool,
224}
225
226#[derive(Debug, Serialize, JsonSchema)]
227pub struct ListDirResponse {
228 sandbox_id: u64,
229 path: String,
230 entries: Vec<ListDirEntry>,
231}
232
233#[derive(Debug, Serialize, JsonSchema)]
234pub struct ErrorResponse {
235 error: String,
236}
237
238impl MimoboxServer {
239 pub fn new() -> Self {
240 Self {
241 sandboxes: Arc::new(Mutex::new(HashMap::new())),
242 next_id: Arc::new(AtomicU64::new(1)),
243 tool_router: Self::tool_router(),
244 }
245 }
246
247 pub async fn cleanup_all(&self) {
249 let mut sandboxes = self.sandboxes.lock().await;
250 let count = sandboxes.len();
251 let drained = sandboxes.drain().collect::<Vec<_>>();
252 drop(sandboxes);
253
254 for (id, managed) in drained {
255 tracing::debug!(sandbox_id = id, "Signal cleanup: destroying sandbox");
256 match tokio::task::spawn_blocking(move || managed.sandbox.destroy()).await {
257 Ok(Ok(())) => {}
258 Ok(Err(err)) => {
259 tracing::warn!(
260 sandbox_id = id,
261 error = %format_sdk_error(err),
262 "Failed to destroy sandbox during signal cleanup"
263 );
264 }
265 Err(err) => {
266 tracing::warn!(
267 sandbox_id = id,
268 error = %format_join_error(err),
269 "Sandbox cleanup task failed during signal cleanup"
270 );
271 }
272 }
273 }
274 tracing::info!(count, "Signal cleanup complete");
275 }
276
277 async fn with_managed_sandbox<T, F>(&self, sandbox_id: u64, operation: F) -> Result<T, String>
278 where
279 T: Send + 'static,
280 F: FnOnce(&mut Sandbox) -> Result<T, SdkError> + Send + 'static,
281 {
282 let mut sandboxes = self.sandboxes.lock().await;
283 let mut managed = sandboxes
284 .remove(&sandbox_id)
285 .ok_or_else(|| sandbox_not_found(sandbox_id))?;
286 drop(sandboxes);
287
288 let (managed, result) = tokio::task::spawn_blocking(move || {
289 let result = operation(&mut managed.sandbox);
290 (managed, result)
291 })
292 .await
293 .map_err(format_join_error)?;
294
295 let mut sandboxes = self.sandboxes.lock().await;
296 sandboxes.insert(sandbox_id, managed);
297
298 result.map_err(format_sdk_error)
299 }
300}
301
302impl Default for MimoboxServer {
303 fn default() -> Self {
304 Self::new()
305 }
306}
307
308#[tool_router]
309impl MimoboxServer {
310 #[tool(description = "Create a reusable mimobox sandbox instance")]
311 async fn create_sandbox(
312 &self,
313 Parameters(request): Parameters<CreateSandboxRequest>,
314 ) -> Result<Json<CreateSandboxResponse>, Json<ErrorResponse>> {
315 let isolation =
316 parse_isolation_level(request.isolation_level.as_deref()).map_err(to_error)?;
317 let timeout_ms = request.timeout_ms;
318 let memory_limit_mb = request.memory_limit_mb;
319 let sandbox = tokio::task::spawn_blocking(move || {
320 create_sandbox_with_options(isolation, timeout_ms, memory_limit_mb)
321 })
322 .await
323 .map_err(|error| to_error(format_join_error(error)))?
324 .map_err(|error| to_error(format_sdk_error(error)))?;
325
326 let sandbox_id = self.next_id.fetch_add(1, Ordering::Relaxed);
327
328 let mut sandboxes = self.sandboxes.lock().await;
329 sandboxes.insert(
330 sandbox_id,
331 ManagedSandbox {
332 sandbox,
333 created_at_ms: unix_timestamp_ms(),
334 created_at_instant: Instant::now(),
335 },
336 );
337
338 Ok(Json(CreateSandboxResponse {
339 sandbox_id,
340 isolation_level: format_isolation_level(isolation).to_string(),
341 }))
342 }
343
344 #[tool(description = "Destroy a reusable mimobox sandbox and release its resources")]
345 async fn destroy_sandbox(
346 &self,
347 Parameters(request): Parameters<DestroySandboxRequest>,
348 ) -> Result<Json<DestroySandboxResponse>, Json<ErrorResponse>> {
349 let mut sandboxes = self.sandboxes.lock().await;
350 let managed = sandboxes
351 .remove(&request.sandbox_id)
352 .ok_or_else(|| to_error(sandbox_not_found(request.sandbox_id)))?;
353 drop(sandboxes);
354
355 match tokio::task::spawn_blocking(move || managed.sandbox.destroy()).await {
356 Ok(Ok(())) => {}
357 Ok(Err(err)) => {
358 error!(
359 sandbox_id = request.sandbox_id,
360 error = %format_sdk_error(err),
361 "Sandbox destroy failed, instance removed from active list"
362 );
363 }
364 Err(err) => {
365 error!(
366 sandbox_id = request.sandbox_id,
367 error = %format_join_error(err),
368 "Sandbox destroy task failed, instance removed from active list"
369 );
370 }
371 }
372
373 Ok(Json(DestroySandboxResponse {
374 sandbox_id: request.sandbox_id,
375 destroyed: true,
376 }))
377 }
378
379 #[tool(description = "List active mimobox sandboxes with their IDs and basic metadata")]
380 async fn list_sandboxes(
381 &self,
382 Parameters(_request): Parameters<ListSandboxesRequest>,
383 ) -> Result<Json<ListSandboxesResponse>, Json<ErrorResponse>> {
384 let sandboxes = self.sandboxes.lock().await;
385 let mut summaries = sandboxes
386 .iter()
387 .map(|(sandbox_id, managed)| SandboxSummary {
388 sandbox_id: *sandbox_id,
389 isolation_level: managed
390 .sandbox
391 .active_isolation()
392 .map(format_isolation_level)
393 .map(str::to_string),
394 created_at: managed.created_at_ms,
395 uptime_ms: managed.created_at_instant.elapsed().as_millis(),
396 })
397 .collect::<Vec<_>>();
398 summaries.sort_by_key(|summary| summary.sandbox_id);
399
400 Ok(Json(ListSandboxesResponse {
401 sandboxes: summaries,
402 }))
403 }
404
405 #[tool(description = "Execute a code snippet in a mimobox sandbox")]
406 async fn execute_code(
407 &self,
408 Parameters(request): Parameters<ExecuteCodeRequest>,
409 ) -> Result<Json<ExecuteResponse>, Json<ErrorResponse>> {
410 let command =
411 build_code_command(request.language.as_deref(), &request.code).map_err(to_error)?;
412 let result = self
413 .execute_with_optional_sandbox(request.sandbox_id, &command, request.timeout_ms)
414 .await
415 .map_err(to_error)?;
416
417 Ok(Json(format_execute_result(result)))
418 }
419
420 #[tool(description = "Execute a shell command in a mimobox sandbox")]
421 async fn execute_command(
422 &self,
423 Parameters(request): Parameters<ExecuteCommandRequest>,
424 ) -> Result<Json<ExecuteResponse>, Json<ErrorResponse>> {
425 let result = self
426 .execute_with_optional_sandbox(request.sandbox_id, &request.command, request.timeout_ms)
427 .await
428 .map_err(to_error)?;
429
430 Ok(Json(format_execute_result(result)))
431 }
432
433 #[tool(description = "Read a file from a microVM-backed mimobox sandbox as base64")]
434 async fn read_file(
435 &self,
436 Parameters(request): Parameters<ReadFileRequest>,
437 ) -> Result<Json<ReadFileResponse>, Json<ErrorResponse>> {
438 #[cfg(feature = "vm")]
439 {
440 let path = request.path;
441 let content = self
442 .with_managed_sandbox(request.sandbox_id, {
443 let path = path.clone();
444 move |sandbox| sandbox.read_file(&path)
445 })
446 .await
447 .map_err(to_error)?;
448 let size_bytes = content.len();
449
450 Ok(Json(ReadFileResponse {
451 sandbox_id: request.sandbox_id,
452 path,
453 content: STANDARD.encode(&content),
454 size_bytes,
455 }))
456 }
457
458 #[cfg(not(feature = "vm"))]
459 {
460 let _ = request;
461 Err(to_error(vm_feature_required("read_file")))
462 }
463 }
464
465 #[tool(description = "Write a base64-encoded file into a microVM-backed mimobox sandbox")]
466 async fn write_file(
467 &self,
468 Parameters(request): Parameters<WriteFileRequest>,
469 ) -> Result<Json<WriteFileResponse>, Json<ErrorResponse>> {
470 #[cfg(feature = "vm")]
471 {
472 let data = STANDARD
473 .decode(&request.content)
474 .map_err(|err| to_error(format!("content is not valid base64: {err}")))?;
475 let size_bytes = data.len();
476 let path = request.path;
477 self.with_managed_sandbox(request.sandbox_id, {
478 let path = path.clone();
479 move |sandbox| sandbox.write_file(&path, &data)
480 })
481 .await
482 .map_err(to_error)?;
483
484 Ok(Json(WriteFileResponse {
485 sandbox_id: request.sandbox_id,
486 path,
487 size_bytes,
488 written: true,
489 }))
490 }
491
492 #[cfg(not(feature = "vm"))]
493 {
494 let _ = request;
495 Err(to_error(vm_feature_required("write_file")))
496 }
497 }
498
499 #[tool(description = "Create a memory snapshot of a microVM-backed sandbox")]
500 async fn snapshot(
501 &self,
502 Parameters(request): Parameters<SnapshotRequest>,
503 ) -> Result<Json<SnapshotResponse>, Json<ErrorResponse>> {
504 #[cfg(feature = "vm")]
505 {
506 let snapshot = self
507 .with_managed_sandbox(request.sandbox_id, |sandbox| sandbox.snapshot())
508 .await
509 .map_err(to_error)?;
510
511 Ok(Json(SnapshotResponse {
512 sandbox_id: request.sandbox_id,
513 size_bytes: snapshot.size(),
514 }))
515 }
516
517 #[cfg(not(feature = "vm"))]
518 {
519 let _ = request;
520 Err(to_error(vm_feature_required("snapshot")))
521 }
522 }
523
524 #[tool(
525 description = "Fork a microVM-backed sandbox, creating an independent copy with CoW memory"
526 )]
527 async fn fork(
528 &self,
529 Parameters(request): Parameters<ForkRequest>,
530 ) -> Result<Json<ForkResponse>, Json<ErrorResponse>> {
531 #[cfg(feature = "vm")]
532 {
533 let mut sandboxes = self.sandboxes.lock().await;
534 let mut managed = sandboxes
535 .remove(&request.sandbox_id)
536 .ok_or_else(|| to_error(sandbox_not_found(request.sandbox_id)))?;
537 drop(sandboxes);
538
539 let (managed, fork_result) = tokio::task::spawn_blocking(move || {
540 let fork_result = managed.sandbox.fork();
541 (managed, fork_result)
542 })
543 .await
544 .map_err(|error| to_error(format_join_error(error)))?;
545
546 let forked = match fork_result {
547 Ok(forked) => forked,
548 Err(error) => {
549 let mut sandboxes = self.sandboxes.lock().await;
550 sandboxes.insert(request.sandbox_id, managed);
551 return Err(to_error(format_sdk_error(error)));
552 }
553 };
554
555 let new_id = self.next_id.fetch_add(1, Ordering::Relaxed);
556
557 let mut sandboxes = self.sandboxes.lock().await;
558 sandboxes.insert(request.sandbox_id, managed);
559 sandboxes.insert(
560 new_id,
561 ManagedSandbox {
562 sandbox: forked,
563 created_at_ms: unix_timestamp_ms(),
564 created_at_instant: Instant::now(),
565 },
566 );
567
568 Ok(Json(ForkResponse {
569 original_sandbox_id: request.sandbox_id,
570 new_sandbox_id: new_id,
571 }))
572 }
573
574 #[cfg(not(feature = "vm"))]
575 {
576 let _ = request;
577 Err(to_error(vm_feature_required("fork")))
578 }
579 }
580
581 #[tool(
582 description = "Execute an HTTP request from a microVM sandbox through a controlled proxy with domain whitelist"
583 )]
584 async fn http_request(
585 &self,
586 Parameters(request): Parameters<McpHttpRequest>,
587 ) -> Result<Json<McpHttpResponse>, Json<ErrorResponse>> {
588 #[cfg(feature = "vm")]
589 {
590 let method = request.method.to_ascii_uppercase();
591 if !matches!(method.as_str(), "GET" | "POST") {
592 return Err(to_error("method only supports GET and POST".to_string()));
593 }
594
595 let url = request.url;
596 let response = self
597 .with_managed_sandbox(request.sandbox_id, move |sandbox| {
598 sandbox.http_request(&method, &url, HashMap::new(), None)
599 })
600 .await
601 .map_err(to_error)?;
602
603 Ok(Json(McpHttpResponse {
604 sandbox_id: request.sandbox_id,
605 status: response.status,
606 headers: response.headers,
607 body: String::from_utf8_lossy(&response.body).into_owned(),
608 }))
609 }
610
611 #[cfg(not(feature = "vm"))]
612 {
613 let _ = request;
614 Err(to_error(vm_feature_required("http_request")))
615 }
616 }
617
618 #[tool(description = "List directory entries in a mimobox sandbox")]
619 async fn list_dir(
620 &self,
621 Parameters(request): Parameters<ListDirRequest>,
622 ) -> Result<Json<ListDirResponse>, Json<ErrorResponse>> {
623 let entries = self
624 .with_managed_sandbox(request.sandbox_id, {
625 let path = request.path.clone();
626 move |sandbox| sandbox.list_dir(&path)
627 })
628 .await
629 .map_err(to_error)?;
630
631 Ok(Json(ListDirResponse {
632 sandbox_id: request.sandbox_id,
633 path: request.path,
634 entries: entries.into_iter().map(format_list_dir_entry).collect(),
635 }))
636 }
637
638 async fn execute_with_optional_sandbox(
639 &self,
640 sandbox_id: Option<u64>,
641 command: &str,
642 timeout_ms: Option<u64>,
643 ) -> Result<ExecuteResult, String> {
644 if let Some(sandbox_id) = sandbox_id {
645 let command = command.to_string();
646 return self
647 .with_managed_sandbox(sandbox_id, move |sandbox| sandbox.execute(&command))
648 .await;
649 }
650
651 let command = command.to_string();
652 tokio::task::spawn_blocking(move || {
653 let mut sandbox = create_sandbox_with_options(IsolationLevel::Auto, timeout_ms, None)?;
654 let result = sandbox.execute(&command);
655 if let Err(err) = sandbox.destroy() {
656 error!(error = %format_sdk_error(err), "Temporary sandbox destroy failed");
657 }
658 result
659 })
660 .await
661 .map_err(format_join_error)?
662 .map_err(format_sdk_error)
663 }
664}
665
666#[tool_handler(router = self.tool_router)]
667impl ServerHandler for MimoboxServer {
668 fn get_info(&self) -> ServerInfo {
669 ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
670 "MimoBox MCP Server — Local Sandbox Runtime for AI Agents. Provides sandbox lifecycle, code execution, file transfer, snapshot, fork, and HTTP proxy tools.",
671 )
672 }
673}
674
675fn unix_timestamp_ms() -> u64 {
676 let millis = SystemTime::now()
677 .duration_since(UNIX_EPOCH)
678 .unwrap_or_default()
679 .as_millis();
680 millis.min(u128::from(u64::MAX)) as u64
681}
682
683fn create_sandbox_with_options(
684 isolation: IsolationLevel,
685 timeout_ms: Option<u64>,
686 memory_limit_mb: Option<u64>,
687) -> Result<Sandbox, SdkError> {
688 let mut builder = Config::builder().isolation(isolation);
689 if let Some(timeout_ms) = timeout_ms {
690 builder = builder.timeout(Duration::from_millis(timeout_ms));
691 }
692 if let Some(memory_limit_mb) = memory_limit_mb {
693 builder = builder.memory_limit_mb(memory_limit_mb);
694 }
695
696 Sandbox::with_config(builder.build())
697}
698
699fn parse_isolation_level(value: Option<&str>) -> Result<IsolationLevel, String> {
700 match value.unwrap_or("auto").to_ascii_lowercase().as_str() {
701 "auto" => Ok(IsolationLevel::Auto),
702 "os" => Ok(IsolationLevel::Os),
703 "wasm" => Ok(IsolationLevel::Wasm),
704 "microvm" | "micro_vm" | "micro-vm" | "vm" => Ok(IsolationLevel::MicroVm),
705 other => Err(format!(
706 "unsupported isolation_level={other}, valid values: auto, os, wasm, microvm"
707 )),
708 }
709}
710
711fn format_isolation_level(level: IsolationLevel) -> &'static str {
712 match level {
713 IsolationLevel::Auto => "auto",
714 IsolationLevel::Os => "os",
715 IsolationLevel::Wasm => "wasm",
716 IsolationLevel::MicroVm => "microvm",
717 }
718}
719
720fn sandbox_not_found(sandbox_id: u64) -> String {
721 format!("sandbox instance not found for sandbox_id={sandbox_id}")
722}
723
724#[cfg(not(feature = "vm"))]
725fn vm_feature_required(operation: &str) -> String {
726 format!(
727 "{operation} requires microVM backend; enable vm feature and use MicroVm isolation level"
728 )
729}
730
731fn build_code_command(language: Option<&str>, code: &str) -> Result<String, String> {
732 let escaped_code = shell_single_quote(code);
733 match language.unwrap_or("bash").to_ascii_lowercase().as_str() {
734 "python" | "python3" | "py" => Ok(format!("python3 -c {escaped_code}")),
735 "javascript" | "js" | "node" | "nodejs" => Ok(format!("node -e {escaped_code}")),
736 "bash" => Ok(format!("bash -c {escaped_code}")),
737 "sh" | "shell" => Ok(format!("sh -c {escaped_code}")),
738 other => Err(format!(
739 "unsupported language={other}, valid values: python, node, bash, sh"
740 )),
741 }
742}
743
744fn shell_single_quote(value: &str) -> String {
745 format!("'{}'", value.replace('\'', "'\\''"))
746}
747
748fn format_execute_result(result: ExecuteResult) -> ExecuteResponse {
749 ExecuteResponse {
750 stdout: String::from_utf8_lossy(&result.stdout).into_owned(),
751 stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
752 exit_code: result.exit_code,
753 timed_out: result.timed_out,
754 elapsed_ms: result.elapsed.as_millis(),
755 }
756}
757
758fn format_list_dir_entry(entry: DirEntry) -> ListDirEntry {
759 ListDirEntry {
760 name: entry.name,
761 file_type: match entry.file_type {
762 FileType::File => "file".to_string(),
763 FileType::Dir => "dir".to_string(),
764 FileType::Symlink => "symlink".to_string(),
765 _ => "other".to_string(),
766 },
767 size: entry.size,
768 is_symlink: entry.is_symlink,
769 }
770}
771
772fn format_sdk_error(error: SdkError) -> String {
773 match error {
774 SdkError::Sandbox {
775 code,
776 message,
777 suggestion,
778 } => match suggestion {
779 Some(suggestion) => format!("[{}] {message}; suggestion: {suggestion}", code.as_str()),
780 None => format!("[{}] {message}", code.as_str()),
781 },
782 SdkError::BackendUnavailable(message) => format!("backend unavailable: {message}"),
783 SdkError::Config(message) => format!("config error: {message}"),
784 SdkError::Io(error) => format!("I/O error: {error}"),
785 error => format!("SDK error: {error}"),
786 }
787}
788
789fn format_join_error(error: JoinError) -> String {
790 format!("blocking task failed: {error}")
791}
792
793fn to_error(error: impl Into<String>) -> Json<ErrorResponse> {
794 Json(ErrorResponse {
795 error: error.into(),
796 })
797}
798
799#[cfg(test)]
800#[allow(clippy::unwrap_used)]
801mod tests {
802 use super::*;
803 use mimobox_sdk::ErrorCode;
804 use std::time::Duration;
805
806 #[test]
809 fn test_parse_isolation_none_defaults_to_auto() {
810 let result = parse_isolation_level(None);
811 assert!(result.is_ok());
812 assert_eq!(result.unwrap(), IsolationLevel::Auto);
813 }
814
815 #[test]
816 fn test_parse_isolation_explicit_values() {
817 assert_eq!(
818 parse_isolation_level(Some("os")).unwrap(),
819 IsolationLevel::Os
820 );
821 assert_eq!(
822 parse_isolation_level(Some("wasm")).unwrap(),
823 IsolationLevel::Wasm
824 );
825 assert_eq!(
826 parse_isolation_level(Some("microvm")).unwrap(),
827 IsolationLevel::MicroVm
828 );
829 }
830
831 #[test]
832 fn test_parse_isolation_aliases() {
833 let aliases = ["micro_vm", "micro-vm", "vm"];
834 for alias in aliases {
835 assert_eq!(
836 parse_isolation_level(Some(alias)).unwrap(),
837 IsolationLevel::MicroVm,
838 "alias '{alias}' should resolve to MicroVm"
839 );
840 }
841 }
842
843 #[test]
844 fn test_parse_isolation_case_insensitive() {
845 assert_eq!(
846 parse_isolation_level(Some("AUTO")).unwrap(),
847 IsolationLevel::Auto
848 );
849 assert_eq!(
850 parse_isolation_level(Some("Os")).unwrap(),
851 IsolationLevel::Os
852 );
853 assert_eq!(
854 parse_isolation_level(Some("WASM")).unwrap(),
855 IsolationLevel::Wasm
856 );
857 assert_eq!(
858 parse_isolation_level(Some("MICROVM")).unwrap(),
859 IsolationLevel::MicroVm
860 );
861 }
862
863 #[test]
864 fn test_parse_isolation_invalid_values() {
865 let invalid = ["invalid", "docker", ""];
866 for val in invalid {
867 assert!(
868 parse_isolation_level(Some(val)).is_err(),
869 "'{val}' should be invalid"
870 );
871 }
872 }
873
874 #[test]
875 fn test_parse_isolation_none_same_as_auto_string() {
876 let from_none = parse_isolation_level(None).unwrap();
877 let from_auto = parse_isolation_level(Some("auto")).unwrap();
878 assert_eq!(from_none, from_auto);
879 }
880
881 #[test]
884 fn test_build_code_command_python_aliases() {
885 for lang in ["python", "python3", "py"] {
886 let cmd = build_code_command(Some(lang), "print(1)").unwrap();
887 assert!(
888 cmd.starts_with("python3 -c "),
889 "language='{lang}' should generate python3 command, got: {cmd}"
890 );
891 }
892 }
893
894 #[test]
895 fn test_build_code_command_node_aliases() {
896 for lang in ["node", "javascript", "js", "nodejs"] {
897 let cmd = build_code_command(Some(lang), "console.log(1)").unwrap();
898 assert!(
899 cmd.starts_with("node -e "),
900 "language='{lang}' should generate node command, got: {cmd}"
901 );
902 }
903 }
904
905 #[test]
906 fn test_build_code_command_bash_default() {
907 let cmd = build_code_command(None, "hello").unwrap();
908 assert_eq!(cmd, "bash -c 'hello'");
909 }
910
911 #[test]
912 fn test_build_code_command_sh_and_shell() {
913 let cmd_sh = build_code_command(Some("sh"), "echo hi").unwrap();
914 assert!(cmd_sh.starts_with("sh -c "));
915
916 let cmd_shell = build_code_command(Some("shell"), "echo hi").unwrap();
917 assert!(cmd_shell.starts_with("sh -c "));
918 }
919
920 #[test]
921 fn test_build_code_command_unsupported_language() {
922 let result = build_code_command(Some("ruby"), "puts 1");
923 assert!(result.is_err());
924 assert!(result.unwrap_err().contains("ruby"));
925 }
926
927 #[test]
930 fn test_shell_single_quote_simple() {
931 assert_eq!(shell_single_quote("hello"), "'hello'");
932 }
933
934 #[test]
935 fn test_shell_single_quote_empty() {
936 assert_eq!(shell_single_quote(""), "''");
937 }
938
939 #[test]
940 fn test_shell_single_quote_with_single_quote() {
941 assert_eq!(shell_single_quote("it's"), "'it'\\''s'");
943 }
944
945 #[test]
946 fn test_shell_single_quote_special_chars() {
947 let input = r#"hello "world" $var"#;
949 let quoted = shell_single_quote(input);
950 assert!(quoted.starts_with('\''));
951 assert!(quoted.ends_with('\''));
952 assert!(quoted.contains(r#"hello "world" $var"#));
953 }
954
955 #[test]
958 fn test_format_sdk_error_sandbox_with_suggestion() {
959 let err = SdkError::sandbox(
960 ErrorCode::CommandTimeout,
961 "timed out",
962 Some("increase timeout".to_string()),
963 );
964 let formatted = format_sdk_error(err);
965 assert!(
966 formatted.contains("[command_timeout]"),
967 "should contain error code"
968 );
969 assert!(formatted.contains("timed out"), "should contain message");
970 assert!(
971 formatted.contains("suggestion: increase timeout"),
972 "should contain suggestion"
973 );
974 }
975
976 #[test]
977 fn test_format_sdk_error_sandbox_without_suggestion() {
978 let err = SdkError::sandbox(ErrorCode::FileNotFound, "file not found", None);
979 let formatted = format_sdk_error(err);
980 assert!(formatted.contains("[file_not_found]"));
981 assert!(formatted.contains("file not found"));
982 assert!(
983 !formatted.contains("suggestion:"),
984 "No suggestion should be output when absent"
985 );
986 }
987
988 #[test]
989 fn test_format_sdk_error_backend_unavailable() {
990 let err = SdkError::BackendUnavailable("microvm");
991 let formatted = format_sdk_error(err);
992 assert!(formatted.contains("backend unavailable"));
993 assert!(formatted.contains("microvm"));
994 }
995
996 #[test]
997 fn test_format_sdk_error_config() {
998 let err = SdkError::Config("invalid config".to_string());
999 let formatted = format_sdk_error(err);
1000 assert!(formatted.contains("config error"));
1001 assert!(formatted.contains("invalid config"));
1002 }
1003
1004 #[test]
1005 fn test_format_sdk_error_io() {
1006 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1007 let err = SdkError::Io(io_err);
1008 let formatted = format_sdk_error(err);
1009 assert!(formatted.contains("I/O error"));
1010 assert!(formatted.contains("file not found"));
1011 }
1012
1013 #[test]
1016 fn test_format_isolation_level_roundtrip() {
1017 assert_eq!(format_isolation_level(IsolationLevel::Auto), "auto");
1018 assert_eq!(format_isolation_level(IsolationLevel::Os), "os");
1019 assert_eq!(format_isolation_level(IsolationLevel::Wasm), "wasm");
1020 assert_eq!(format_isolation_level(IsolationLevel::MicroVm), "microvm");
1021 }
1022
1023 #[test]
1026 fn test_format_execute_result_fields() {
1027 let result = ExecuteResult::new(
1028 b"out".to_vec(),
1029 b"err".to_vec(),
1030 Some(0),
1031 false,
1032 Duration::from_millis(42),
1033 );
1034 let resp = format_execute_result(result);
1035 assert_eq!(resp.stdout, "out");
1036 assert_eq!(resp.stderr, "err");
1037 assert_eq!(resp.exit_code, Some(0));
1038 assert!(!resp.timed_out);
1039 assert_eq!(resp.elapsed_ms, 42);
1040 }
1041
1042 #[test]
1043 fn test_format_execute_result_non_utf8() {
1044 let result = ExecuteResult::new(
1045 vec![0xff, 0xfe],
1046 vec![],
1047 None,
1048 true,
1049 Duration::from_millis(100),
1050 );
1051 let resp = format_execute_result(result);
1052 assert!(!resp.stdout.is_empty());
1054 assert!(resp.stderr.is_empty());
1055 assert_eq!(resp.exit_code, None);
1056 assert!(resp.timed_out);
1057 assert_eq!(resp.elapsed_ms, 100);
1058 }
1059
1060 #[test]
1063 fn test_sandbox_not_found_contains_id() {
1064 let msg = sandbox_not_found(42);
1065 assert!(msg.contains("42"), "should contain sandbox_id");
1066 assert!(msg.contains("not found"), "should contain hint");
1067 }
1068
1069 #[test]
1072 fn test_unix_timestamp_ms_reasonable() {
1073 let ts = unix_timestamp_ms();
1074 assert!(
1076 ts > 1_672_531_200_000,
1077 "Timestamp should be after 2023, got: {ts}"
1078 );
1079 assert!(ts < 4_102_444_800_000, "Timestamp should not exceed 2100");
1081 }
1082
1083 #[test]
1084 fn test_unix_timestamp_ms_monotonic() {
1085 let t1 = unix_timestamp_ms();
1086 let t2 = unix_timestamp_ms();
1087 assert!(
1088 t2 >= t1,
1089 "Consecutive calls should be monotonically non-decreasing"
1090 );
1091 }
1092
1093 #[test]
1096 fn test_to_error_contains_message() {
1097 let Json(err) = to_error("test error");
1098 assert_eq!(err.error, "test error");
1099 }
1100
1101 #[cfg(not(feature = "vm"))]
1104 #[test]
1105 fn test_vm_feature_required_message() {
1106 let msg = vm_feature_required("snapshot");
1107 assert!(msg.contains("snapshot"), "should contain operation name");
1108 assert!(msg.contains("microVM") || msg.contains("vm feature"));
1109 }
1110}