Skip to main content

mimobox_mcp/
lib.rs

1//! mimobox MCP Server.
2//!
3//! Exposes 11 tools over stdio:
4//! - create_sandbox
5//! - destroy_sandbox
6//! - list_sandboxes
7//! - execute_code
8//! - execute_command
9//! - read_file
10//! - write_file
11//! - list_dir
12//! - snapshot
13//! - fork
14//! - http_request
15
16use 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    /// Optional isolation level: auto, os, wasm, microvm.
57    isolation_level: Option<String>,
58    /// Default sandbox timeout in milliseconds.
59    timeout_ms: Option<u64>,
60    /// Sandbox memory limit in MiB.
61    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    /// If not provided, a temporary sandbox is created and destroyed after execution.
73    sandbox_id: Option<u64>,
74    /// Code snippet to execute.
75    code: String,
76    /// Optional language: python, javascript, node, bash, sh.
77    language: Option<String>,
78    /// Execution timeout in milliseconds. Only applies to temporary sandboxes.
79    timeout_ms: Option<u64>,
80}
81
82#[derive(Debug, Deserialize, JsonSchema)]
83pub struct ExecuteCommandRequest {
84    /// If not provided, a temporary sandbox is created and destroyed after execution.
85    sandbox_id: Option<u64>,
86    /// Shell command to execute.
87    command: String,
88    /// Execution timeout in milliseconds. Only applies to temporary sandboxes.
89    timeout_ms: Option<u64>,
90}
91
92#[derive(Debug, Deserialize, JsonSchema)]
93pub struct DestroySandboxRequest {
94    /// ID of the sandbox to destroy.
95    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    /// Target sandbox ID.
105    sandbox_id: u64,
106    /// File path inside the sandbox.
107    path: String,
108}
109
110#[derive(Debug, Deserialize, JsonSchema)]
111#[cfg_attr(not(feature = "vm"), allow(dead_code))]
112pub struct WriteFileRequest {
113    /// Target sandbox ID.
114    sandbox_id: u64,
115    /// File path inside the sandbox.
116    path: String,
117    /// Base64-encoded file content.
118    content: String,
119}
120
121#[derive(Debug, Deserialize, JsonSchema)]
122#[cfg_attr(not(feature = "vm"), allow(dead_code))]
123pub struct SnapshotRequest {
124    /// Target sandbox ID.
125    sandbox_id: u64,
126}
127
128#[derive(Debug, Deserialize, JsonSchema)]
129#[cfg_attr(not(feature = "vm"), allow(dead_code))]
130pub struct ForkRequest {
131    /// ID of the sandbox to fork.
132    sandbox_id: u64,
133}
134
135#[derive(Debug, Deserialize, JsonSchema)]
136#[cfg_attr(not(feature = "vm"), allow(dead_code))]
137pub struct McpHttpRequest {
138    /// Target sandbox ID.
139    sandbox_id: u64,
140    /// Request URL (HTTPS only).
141    url: String,
142    /// HTTP method: GET or POST.
143    method: String,
144}
145
146#[derive(Debug, Deserialize, JsonSchema)]
147pub struct ListDirRequest {
148    /// Target sandbox ID.
149    sandbox_id: u64,
150    /// Directory path inside the sandbox.
151    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    /// Clean up all active sandbox instances. Called on SIGTERM/SIGINT.
248    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    // ── parse_isolation_level ──────────────────────────────────────────
807
808    #[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    // ── build_code_command ─────────────────────────────────────────────
882
883    #[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    // ── shell_single_quote ─────────────────────────────────────────────
928
929    #[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        // "it's" -> 'it'\''s'
942        assert_eq!(shell_single_quote("it's"), "'it'\\''s'");
943    }
944
945    #[test]
946    fn test_shell_single_quote_special_chars() {
947        // Ensure double quotes and $ are preserved inside quotes
948        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    // ── format_sdk_error ───────────────────────────────────────────────
956
957    #[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    // ── format_isolation_level ─────────────────────────────────────────
1014
1015    #[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    // ── format_execute_result ──────────────────────────────────────────
1024
1025    #[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        // String::from_utf8_lossy replaces invalid UTF-8 with replacement character
1053        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    // ── sandbox_not_found ──────────────────────────────────────────────
1061
1062    #[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    // ── unix_timestamp_ms ──────────────────────────────────────────────
1070
1071    #[test]
1072    fn test_unix_timestamp_ms_reasonable() {
1073        let ts = unix_timestamp_ms();
1074        // 2023-01-01 00:00:00 UTC ≈ 1_672_531_200_000
1075        assert!(
1076            ts > 1_672_531_200_000,
1077            "Timestamp should be after 2023, got: {ts}"
1078        );
1079        // Should not exceed the future upper bound (2100 ≈ 4_102_444_800_000)
1080        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    // ── to_error helper ────────────────────────────────────────────────
1094
1095    #[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    // ── vm_feature_required (non-vm builds) ────────────────────────────
1102
1103    #[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}