Skip to main content

limit_agent/
sandbox.rs

1use crate::error::AgentError;
2use bollard::container::{Config, CreateContainerOptions, RemoveContainerOptions};
3use bollard::Docker;
4use futures::StreamExt;
5use tokio::time::{timeout, Duration};
6use tracing::instrument;
7pub struct DockerSandbox {
8    docker: Docker,
9}
10
11impl DockerSandbox {
12    /// Check if Docker daemon is available
13    pub async fn check_docker_available() -> bool {
14        if let Ok(docker) = Docker::connect_with_defaults() {
15            if docker.ping().await.is_ok() {
16                return true;
17            }
18        }
19        false
20    }
21
22    /// Create a new DockerSandbox instance
23    pub async fn new() -> Result<Self, AgentError> {
24        let docker = Docker::connect_with_defaults()
25            .map_err(|e| AgentError::SandboxError(format!("Failed to connect to Docker: {}", e)))?;
26
27        // Verify connection
28        docker
29            .ping()
30            .await
31            .map_err(|e| AgentError::SandboxError(format!("Docker ping failed: {}", e)))?;
32
33        Ok(DockerSandbox { docker })
34    }
35
36    /// Create a container with specified image
37    #[instrument(skip(self, image))]
38    pub async fn create_container(&self, image: &str) -> Result<String, AgentError> {
39        // Default to limit-rust-sandbox:latest if not specified
40        let image_name = if image.is_empty() {
41            "limit-rust-sandbox:latest"
42        } else {
43            image
44        };
45
46        let cwd = std::env::current_dir().map_err(|e| {
47            AgentError::SandboxError(format!("Failed to get current directory: {}", e))
48        })?;
49
50        let config = Config {
51            image: Some(image_name.to_string()),
52            tty: Some(false),
53            open_stdin: Some(false),
54            host_config: Some(bollard::models::HostConfig {
55                binds: Some(vec![format!("{}:/workspace:ro", cwd.to_string_lossy())]),
56                memory: Some(512 * 1024 * 1024), // 512MB
57                network_mode: Some("none".to_string()),
58                ..Default::default()
59            }),
60            working_dir: Some("/workspace".to_string()),
61            ..Default::default()
62        };
63
64        let uuid = uuid::Uuid::new_v4().to_string();
65        let short_uuid = uuid.chars().take(8).collect::<String>();
66        let options = Some(CreateContainerOptions {
67            name: format!("limit-sandbox-{}", short_uuid),
68            ..Default::default()
69        });
70
71        let container = self
72            .docker
73            .create_container(options, config)
74            .await
75            .map_err(|e| AgentError::SandboxError(format!("Failed to create container: {}", e)))?;
76
77        Ok(container.id)
78    }
79
80    /// Execute a command in the container
81    #[instrument(skip(self, container, cmd))]
82    pub async fn execute_in_container(
83        &self,
84        container: &str,
85        cmd: &[String],
86    ) -> Result<String, AgentError> {
87        // Create exec instance
88        let exec_config = bollard::exec::CreateExecOptions {
89            cmd: Some(cmd.to_vec()),
90            attach_stdout: Some(true),
91            attach_stderr: Some(true),
92            ..Default::default()
93        };
94
95        let exec = self
96            .docker
97            .create_exec(container, exec_config)
98            .await
99            .map_err(|e| AgentError::SandboxError(format!("Failed to create exec: {}", e)))?;
100
101        // Start exec with 60s timeout
102        let exec_start = bollard::exec::StartExecOptions {
103            detach: false,
104            ..Default::default()
105        };
106
107        let result = timeout(
108            Duration::from_secs(60),
109            self.docker.start_exec(&exec.id, Some(exec_start)),
110        )
111        .await
112        .map_err(|_| AgentError::SandboxError("Command execution timed out".to_string()))?
113        .map_err(|e| AgentError::SandboxError(format!("Failed to start exec: {}", e)))?;
114
115        let output = match result {
116            bollard::exec::StartExecResults::Attached { output, .. } => {
117                let mut full_output = Vec::new();
118
119                let mut stream = output;
120                while let Some(result) = stream.next().await {
121                    let chunk = result.map_err(|e| {
122                        AgentError::SandboxError(format!("Failed to read output: {}", e))
123                    })?;
124                    full_output.extend_from_slice(&chunk.into_bytes());
125                }
126
127                String::from_utf8_lossy(&full_output).to_string()
128            }
129            bollard::exec::StartExecResults::Detached => String::new(),
130        };
131
132        Ok(output)
133    }
134
135    /// Start a container
136    #[instrument(skip(self, container))]
137    pub async fn start_container(&self, container: &str) -> Result<(), AgentError> {
138        self.docker
139            .start_container::<String>(container, None)
140            .await
141            .map_err(|e| AgentError::SandboxError(format!("Failed to start container: {}", e)))?;
142
143        Ok(())
144    }
145
146    /// Stop and remove a container
147    pub async fn cleanup_container(&self, container: &str) {
148        let remove_options = RemoveContainerOptions {
149            force: true,
150            v: true,
151            ..Default::default()
152        };
153
154        let _ = self
155            .docker
156            .remove_container(container, Some(remove_options))
157            .await;
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[tokio::test]
166    async fn test_check_docker_available() {
167        let available = DockerSandbox::check_docker_available().await;
168        // Test doesn't assert - just verifies it runs without panicking
169        println!("Docker available: {}", available);
170    }
171
172    #[tokio::test]
173    async fn test_docker_not_available_returns_error() {
174        // This test verifies that the sandbox handles missing Docker gracefully
175        if !DockerSandbox::check_docker_available().await {
176            let result = DockerSandbox::new().await;
177            assert!(result.is_err());
178        }
179    }
180
181    #[tokio::test]
182    #[ignore] // Requires Docker to be running
183    async fn test_create_and_cleanup_container() {
184        if !DockerSandbox::check_docker_available().await {
185            return;
186        }
187
188        let sandbox = DockerSandbox::new().await.unwrap();
189        let container_id = sandbox.create_container("alpine:latest").await.unwrap();
190
191        // Cleanup should work even if container wasn't started
192        sandbox.cleanup_container(&container_id).await;
193    }
194
195    #[tokio::test]
196    #[ignore] // Requires Docker and alpine image
197    async fn test_execute_command() {
198        if !DockerSandbox::check_docker_available().await {
199            return;
200        }
201
202        let sandbox = DockerSandbox::new().await.unwrap();
203        let container_id = sandbox.create_container("alpine:latest").await.unwrap();
204
205        sandbox.start_container(&container_id).await.unwrap();
206
207        let output = sandbox
208            .execute_in_container(&container_id, &["echo".to_string(), "hello".to_string()])
209            .await
210            .unwrap();
211
212        assert!(output.contains("hello"));
213
214        sandbox.cleanup_container(&container_id).await;
215    }
216}