Skip to main content

everruns_integrations_docker/
lib.rs

1//! Docker Container Integration (Experimental)
2//!
3//! This capability provides tools for running and interacting with a Docker container
4//! tied to the session lifecycle. The container is lazily started on first tool use
5//! and persists for the duration of the session.
6//!
7//! Decision: External integration crate, auto-registered via inventory plugin system
8//! Decision: Experimental-only (gated behind DeploymentGrade::Dev)
9//! Decision: Gated behind FEATURE_DOCKER_CAPABILITY flag (disabled by default on all envs)
10//! Decision: Single container per session, named everruns-{session_id}
11//! Decision: Lazy start on first tool use, host networking
12//!
13//! Configuration (via AgentCapabilityConfig.config):
14//! ```json
15//! {
16//!   "image": "mcr.microsoft.com/devcontainers/python:3.11",
17//!   "working_dir": "/workspace"  // optional, defaults to /workspace
18//! }
19//! ```
20
21use everruns_core::ToolHints;
22use everruns_core::capabilities::{
23    Capability, CapabilityLocalization, CapabilityStatus, IntegrationPlugin, RiskLevel,
24};
25use everruns_core::tool_output_sanitizer::{
26    READ_FILE_DEFAULT_LIMIT, build_bytes_read_file_result, parse_read_file_window_args,
27};
28use everruns_core::tools::{Tool, ToolExecutionResult};
29use everruns_core::traits::ToolContext;
30
31use async_trait::async_trait;
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34use std::process::Stdio;
35use std::sync::LazyLock;
36use tokio::process::Command;
37use tracing::{debug, error, info, warn};
38
39// ============================================================================
40// Integration Plugin Registration
41// ============================================================================
42
43inventory::submit! {
44    IntegrationPlugin {
45        experimental_only: true,
46        feature_flag: Some("docker_capability"),
47        factory: || Box::new(DockerContainerCapability),
48    }
49}
50
51// ============================================================================
52// Constants
53// ============================================================================
54
55/// Default Docker image if none specified in config
56const DEFAULT_IMAGE: &str = "mcr.microsoft.com/devcontainers/python:3.11";
57
58/// Default working directory inside the container
59const DEFAULT_WORKING_DIR: &str = "/workspace";
60
61/// Container name prefix
62const CONTAINER_PREFIX: &str = "everruns";
63
64// ============================================================================
65// Configuration
66// ============================================================================
67
68/// Configuration schema for the Docker Container capability
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DockerContainerConfig {
71    /// Docker image to use (e.g., "mcr.microsoft.com/devcontainers/python:3.11")
72    #[serde(default = "default_image")]
73    pub image: String,
74
75    /// Working directory inside the container
76    #[serde(default = "default_working_dir")]
77    pub working_dir: String,
78}
79
80fn default_image() -> String {
81    DEFAULT_IMAGE.to_string()
82}
83
84fn default_working_dir() -> String {
85    DEFAULT_WORKING_DIR.to_string()
86}
87
88impl Default for DockerContainerConfig {
89    fn default() -> Self {
90        Self {
91            image: default_image(),
92            working_dir: default_working_dir(),
93        }
94    }
95}
96
97// ============================================================================
98// DockerContainerCapability
99// ============================================================================
100
101static SYSTEM_PROMPT: LazyLock<String> = LazyLock::new(|| {
102    let mut prompt = String::from(
103        "This session has one lazily-started Docker container with host networking. Calls reuse the same container; default working directory is `/workspace`, files persist for the session, and stopping removes/resets it. Check exit codes and clean up when done.",
104    );
105    prompt.push_str(everruns_core::tool_output_sanitizer::EXEC_OUTPUT_HINT);
106    prompt
107});
108
109pub struct DockerContainerCapability;
110
111impl Capability for DockerContainerCapability {
112    fn id(&self) -> &str {
113        "docker_container"
114    }
115
116    fn name(&self) -> &str {
117        "[Experimental] Docker Container"
118    }
119
120    fn description(&self) -> &str {
121        "Run commands and manage files in a Docker container tied to the session. \
122         Container is lazily started on first use and persists for the session duration. \
123         EXPERIMENTAL: This capability may change significantly."
124    }
125
126    fn status(&self) -> CapabilityStatus {
127        CapabilityStatus::Available
128    }
129
130    fn risk_level(&self) -> RiskLevel {
131        RiskLevel::High
132    }
133
134    fn icon(&self) -> Option<&str> {
135        Some("container")
136    }
137
138    fn category(&self) -> Option<&str> {
139        Some("Sandboxes")
140    }
141
142    fn system_prompt_addition(&self) -> Option<&str> {
143        Some(&SYSTEM_PROMPT)
144    }
145
146    fn tools(&self) -> Vec<Box<dyn Tool>> {
147        vec![
148            Box::new(DockerExecTool),
149            Box::new(DockerReadFileTool),
150            Box::new(DockerWriteFileTool),
151            Box::new(DockerLogsTool),
152            Box::new(DockerStopTool),
153        ]
154    }
155
156    fn config_schema(&self) -> Option<Value> {
157        Some(json!({
158            "type": "object",
159            "title": "Docker Container Settings",
160            "properties": {
161                "image": {
162                    "type": "string",
163                    "title": "Docker Image",
164                    "description": "Custom base image for the container.",
165                    "default": DEFAULT_IMAGE,
166                    "examples": [DEFAULT_IMAGE]
167                },
168                "working_dir": {
169                    "type": "string",
170                    "title": "Working Directory",
171                    "description": "Default working directory inside the container.",
172                    "default": DEFAULT_WORKING_DIR,
173                    "examples": [DEFAULT_WORKING_DIR]
174                }
175            }
176        }))
177    }
178
179    fn config_ui_schema(&self) -> Option<Value> {
180        Some(json!({
181            "ui:submitButtonOptions": { "norender": true },
182            "ui:order": ["image", "working_dir"],
183            "image": {
184                "ui:placeholder": DEFAULT_IMAGE
185            },
186            "working_dir": {
187                "ui:placeholder": DEFAULT_WORKING_DIR
188            }
189        }))
190    }
191
192    fn validate_config(&self, config: &Value) -> Result<(), String> {
193        if config.is_null() {
194            return Ok(());
195        }
196        serde_json::from_value::<DockerContainerConfig>(config.clone())
197            .map(|_| ())
198            .map_err(|error| format!("Invalid docker_container config: {error}"))
199    }
200
201    fn localizations(&self) -> Vec<CapabilityLocalization> {
202        vec![
203            CapabilityLocalization {
204                locale: "en",
205                name: None,
206                description: None,
207                config_description: Some(
208                    "Choose the container base image and the default working directory inside it.",
209                ),
210                config_overlay: None,
211            },
212            CapabilityLocalization {
213                locale: "uk",
214                name: Some("[Експериментально] Контейнер Docker"),
215                description: Some(
216                    "Виконуйте команди та керуйте файлами в контейнері Docker, прив'язаному до \
217                     сесії. Контейнер ліниво запускається при першому використанні та \
218                     зберігається протягом сесії. ЕКСПЕРИМЕНТАЛЬНО: ця можливість може суттєво \
219                     змінитися.",
220                ),
221                config_description: Some(
222                    "Визначає базовий образ контейнера та типовий робочий каталог усередині нього.",
223                ),
224                config_overlay: Some(json!({
225                    "properties": {
226                        "image": {
227                            "title": "Образ Docker",
228                            "description": "Власний базовий образ для контейнера."
229                        },
230                        "working_dir": {
231                            "title": "Робочий каталог",
232                            "description": "Типовий робочий каталог усередині контейнера."
233                        }
234                    }
235                })),
236            },
237        ]
238    }
239}
240
241// ============================================================================
242// Helper Functions
243// ============================================================================
244
245/// Generate container name from session ID
246fn container_name(session_id: &everruns_core::typed_id::SessionId) -> String {
247    format!("{}-{}", CONTAINER_PREFIX, session_id.uuid())
248}
249
250/// Check if Docker is available on the system
251async fn is_docker_available() -> bool {
252    match Command::new("docker")
253        .arg("version")
254        .stdout(Stdio::null())
255        .stderr(Stdio::null())
256        .status()
257        .await
258    {
259        Ok(status) => status.success(),
260        Err(e) => {
261            debug!("Docker not available: {}", e);
262            false
263        }
264    }
265}
266
267/// Check if a container exists and is running
268async fn is_container_running(name: &str) -> bool {
269    match Command::new("docker")
270        .args(["inspect", "-f", "{{.State.Running}}", name])
271        .output()
272        .await
273    {
274        Ok(output) => {
275            let stdout = String::from_utf8_lossy(&output.stdout);
276            stdout.trim() == "true"
277        }
278        Err(_) => false,
279    }
280}
281
282/// Check if a container exists (running or stopped)
283async fn container_exists(name: &str) -> bool {
284    Command::new("docker")
285        .args(["inspect", name])
286        .stdout(Stdio::null())
287        .stderr(Stdio::null())
288        .status()
289        .await
290        .map(|s| s.success())
291        .unwrap_or(false)
292}
293
294/// Ensure container is running, starting it if necessary
295async fn ensure_container_running(
296    name: &str,
297    config: &DockerContainerConfig,
298) -> Result<(), String> {
299    // Check if Docker is available
300    if !is_docker_available().await {
301        return Err(
302            "Docker is not available. Please ensure Docker is installed and running.".to_string(),
303        );
304    }
305
306    // If container is already running, we're done
307    if is_container_running(name).await {
308        debug!("Container {} is already running", name);
309        return Ok(());
310    }
311
312    // If container exists but is stopped, start it
313    if container_exists(name).await {
314        info!("Starting existing container: {}", name);
315        let output = Command::new("docker")
316            .args(["start", name])
317            .output()
318            .await
319            .map_err(|e| format!("Failed to start container: {}", e))?;
320
321        if !output.status.success() {
322            let stderr = String::from_utf8_lossy(&output.stderr);
323            return Err(format!("Failed to start container: {}", stderr));
324        }
325        return Ok(());
326    }
327
328    // Create and start a new container
329    info!(
330        "Creating new container: {} with image: {}",
331        name, config.image
332    );
333
334    let output = Command::new("docker")
335        .args([
336            "run",
337            "-d", // Detached mode
338            "--name",
339            name, // Container name
340            "--network",
341            "host", // Host networking
342            "-w",
343            &config.working_dir, // Working directory
344            "--init",            // Use init process
345            &config.image,       // Image
346            "tail",
347            "-f",
348            "/dev/null", // Keep container running
349        ])
350        .output()
351        .await
352        .map_err(|e| format!("Failed to create container: {}", e))?;
353
354    if !output.status.success() {
355        let stderr = String::from_utf8_lossy(&output.stderr);
356        error!("Failed to create container {}: {}", name, stderr);
357        return Err(format!("Failed to create container: {}", stderr));
358    }
359
360    info!("Container {} created and running", name);
361    Ok(())
362}
363
364/// Parse capability config from JSON value
365fn parse_config(config: &Value) -> DockerContainerConfig {
366    serde_json::from_value(config.clone()).unwrap_or_default()
367}
368
369// ============================================================================
370// DockerExecTool
371// ============================================================================
372
373pub struct DockerExecTool;
374
375#[async_trait]
376impl Tool for DockerExecTool {
377    fn narrate(
378        &self,
379        tool_call: &everruns_core::tool_types::ToolCall,
380        phase: everruns_core::tool_narration::ToolNarrationPhase,
381        locale: Option<&str>,
382    ) -> Option<String> {
383        let fallback = self.display_name().unwrap_or("Docker");
384        Some(everruns_core::tool_narration::narrate_shell_exec(
385            &tool_call.arguments,
386            fallback,
387            phase,
388            locale,
389        ))
390    }
391
392    fn name(&self) -> &str {
393        "docker_exec"
394    }
395
396    fn description(&self) -> &str {
397        "Execute a command inside the Docker container. Returns stdout, stderr, and exit code. \
398         The container is automatically started if not already running."
399    }
400
401    fn parameters_schema(&self) -> Value {
402        json!({
403            "type": "object",
404            "properties": {
405                "command": {
406                    "type": "string",
407                    "description": "The command to execute (e.g., 'ls -la' or 'python script.py')"
408                },
409                "working_dir": {
410                    "type": "string",
411                    "description": "Working directory for the command (optional, defaults to container's working dir)"
412                },
413                "config": {
414                    "type": "object",
415                    "description": "Container configuration (image, working_dir). Usually provided by capability config.",
416                    "properties": {
417                        "image": { "type": "string" },
418                        "working_dir": { "type": "string" }
419                    }
420                },
421                "output": everruns_core::tool_output_sanitizer::output_verbosity_schema()
422            },
423            "required": ["command"],
424            "additionalProperties": false
425        })
426    }
427
428    fn hints(&self) -> ToolHints {
429        ToolHints::default()
430            .with_open_world(true)
431            .with_long_running(true)
432            .with_persist_output(true)
433    }
434
435    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
436        ToolExecutionResult::tool_error(
437            "docker_exec requires context. This tool must be executed with session context.",
438        )
439    }
440
441    async fn execute_with_context(
442        &self,
443        arguments: Value,
444        context: &ToolContext,
445    ) -> ToolExecutionResult {
446        let command = match arguments.get("command").and_then(|v| v.as_str()) {
447            Some(c) => c,
448            None => return ToolExecutionResult::tool_error("Missing required parameter: command"),
449        };
450
451        let config = arguments
452            .get("config")
453            .map(parse_config)
454            .unwrap_or_default();
455
456        let working_dir = arguments
457            .get("working_dir")
458            .and_then(|v| v.as_str())
459            .map(String::from);
460        let output_mode = arguments
461            .get("output")
462            .and_then(|v| v.as_str())
463            .unwrap_or("auto");
464
465        let name = container_name(&context.session_id);
466
467        // Ensure container is running
468        if let Err(e) = ensure_container_running(&name, &config).await {
469            return ToolExecutionResult::tool_error(e);
470        }
471
472        // Build exec command
473        let mut args = vec!["exec".to_string()];
474
475        if let Some(ref wd) = working_dir {
476            args.push("-w".to_string());
477            args.push(wd.clone());
478        }
479
480        args.push(name.clone());
481        args.push("sh".to_string());
482        args.push("-c".to_string());
483        args.push(command.to_string());
484
485        debug!("Executing in container {}: {}", name, command);
486
487        // Execute command
488        let output = match Command::new("docker").args(&args).output().await {
489            Ok(o) => o,
490            Err(e) => {
491                error!("Failed to execute command in container: {}", e);
492                return ToolExecutionResult::internal_error_msg(format!(
493                    "Failed to execute command: {}",
494                    e
495                ));
496            }
497        };
498
499        let exit_code = output.status.code().unwrap_or(-1);
500        let stdout_raw = String::from_utf8_lossy(&output.stdout);
501        let stderr_raw = String::from_utf8_lossy(&output.stderr);
502
503        use everruns_core::tool_output_sanitizer::{
504            clean_exec_output, output_verbosity_budget, priority_aware_truncate, resolve_auto_mode,
505        };
506        let clean_stdout = clean_exec_output(&stdout_raw);
507        let clean_stderr = clean_exec_output(&stderr_raw);
508        let effective_mode = resolve_auto_mode(output_mode, exit_code);
509        let (stdout, stderr) = if let Some(budget) = output_verbosity_budget(effective_mode) {
510            (
511                priority_aware_truncate(&clean_stdout, budget),
512                priority_aware_truncate(&clean_stderr, budget.min(4096)),
513            )
514        } else {
515            (clean_stdout.clone(), clean_stderr.clone())
516        };
517        let mut raw = clean_stdout;
518        if !clean_stderr.is_empty() {
519            raw.push_str("\n--- stderr ---\n");
520            raw.push_str(&clean_stderr);
521        }
522
523        ToolExecutionResult::success_with_raw_output(
524            json!({
525                "stdout": stdout,
526                "stderr": stderr,
527                "exit_code": exit_code,
528                "success": exit_code == 0
529            }),
530            raw,
531        )
532    }
533
534    fn requires_context(&self) -> bool {
535        true
536    }
537}
538
539// ============================================================================
540// DockerReadFileTool
541// ============================================================================
542
543pub struct DockerReadFileTool;
544
545#[async_trait]
546impl Tool for DockerReadFileTool {
547    fn name(&self) -> &str {
548        "docker_read_file"
549    }
550
551    fn description(&self) -> &str {
552        "Read a file from the Docker container filesystem. \
553         The container is automatically started if not already running."
554    }
555
556    fn parameters_schema(&self) -> Value {
557        json!({
558            "type": "object",
559            "properties": {
560                "path": {
561                    "type": "string",
562                    "description": "Absolute path to the file inside the container (e.g., '/workspace/main.py')"
563                },
564                "offset": {
565                    "type": "integer",
566                    "minimum": 0,
567                    "default": 0,
568                    "description": "Zero-based line offset to start reading from"
569                },
570                "limit": {
571                    "type": "integer",
572                    "minimum": 1,
573                    "default": READ_FILE_DEFAULT_LIMIT,
574                    "description": "Maximum number of lines to return"
575                },
576                "config": {
577                    "type": "object",
578                    "description": "Container configuration (image, working_dir). Usually provided by capability config.",
579                    "properties": {
580                        "image": { "type": "string" },
581                        "working_dir": { "type": "string" }
582                    }
583                }
584            },
585            "required": ["path"],
586            "additionalProperties": false
587        })
588    }
589
590    fn hints(&self) -> ToolHints {
591        ToolHints::default()
592            .with_readonly(true)
593            .with_open_world(true)
594    }
595
596    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
597        ToolExecutionResult::tool_error(
598            "docker_read_file requires context. This tool must be executed with session context.",
599        )
600    }
601
602    async fn execute_with_context(
603        &self,
604        arguments: Value,
605        context: &ToolContext,
606    ) -> ToolExecutionResult {
607        let path = match arguments.get("path").and_then(|v| v.as_str()) {
608            Some(p) => p,
609            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
610        };
611        let (offset, limit) = match parse_read_file_window_args(&arguments) {
612            Ok(window) => window,
613            Err(err) => return ToolExecutionResult::tool_error(err),
614        };
615
616        let config = arguments
617            .get("config")
618            .map(parse_config)
619            .unwrap_or_default();
620
621        let name = container_name(&context.session_id);
622
623        // Ensure container is running
624        if let Err(e) = ensure_container_running(&name, &config).await {
625            return ToolExecutionResult::tool_error(e);
626        }
627
628        debug!("Reading file from container {}: {}", name, path);
629
630        // Use docker exec to cat the file
631        let output = match Command::new("docker")
632            .args(["exec", &name, "cat", path])
633            .output()
634            .await
635        {
636            Ok(o) => o,
637            Err(e) => {
638                error!("Failed to read file from container: {}", e);
639                return ToolExecutionResult::internal_error_msg(format!(
640                    "Failed to read file: {}",
641                    e
642                ));
643            }
644        };
645
646        if !output.status.success() {
647            let stderr = String::from_utf8_lossy(&output.stderr);
648            return ToolExecutionResult::tool_error(format!("Failed to read file: {}", stderr));
649        }
650
651        ToolExecutionResult::success(build_bytes_read_file_result(
652            "docker_read_file",
653            path,
654            &output.stdout,
655            offset,
656            limit,
657        ))
658    }
659
660    fn requires_context(&self) -> bool {
661        true
662    }
663}
664
665// ============================================================================
666// DockerWriteFileTool
667// ============================================================================
668
669pub struct DockerWriteFileTool;
670
671#[async_trait]
672impl Tool for DockerWriteFileTool {
673    fn name(&self) -> &str {
674        "docker_write_file"
675    }
676
677    fn description(&self) -> &str {
678        "Write content to a file in the Docker container filesystem. \
679         Parent directories are created automatically. \
680         The container is automatically started if not already running."
681    }
682
683    fn parameters_schema(&self) -> Value {
684        json!({
685            "type": "object",
686            "properties": {
687                "path": {
688                    "type": "string",
689                    "description": "Absolute path for the file inside the container (e.g., '/workspace/main.py')"
690                },
691                "content": {
692                    "type": "string",
693                    "description": "Content to write to the file"
694                },
695                "config": {
696                    "type": "object",
697                    "description": "Container configuration (image, working_dir). Usually provided by capability config.",
698                    "properties": {
699                        "image": { "type": "string" },
700                        "working_dir": { "type": "string" }
701                    }
702                }
703            },
704            "required": ["path", "content"],
705            "additionalProperties": false
706        })
707    }
708
709    fn hints(&self) -> ToolHints {
710        ToolHints::default().with_open_world(true)
711    }
712
713    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
714        ToolExecutionResult::tool_error(
715            "docker_write_file requires context. This tool must be executed with session context.",
716        )
717    }
718
719    async fn execute_with_context(
720        &self,
721        arguments: Value,
722        context: &ToolContext,
723    ) -> ToolExecutionResult {
724        let path = match arguments.get("path").and_then(|v| v.as_str()) {
725            Some(p) => p,
726            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
727        };
728
729        let content = match arguments.get("content").and_then(|v| v.as_str()) {
730            Some(c) => c,
731            None => return ToolExecutionResult::tool_error("Missing required parameter: content"),
732        };
733
734        let config = arguments
735            .get("config")
736            .map(parse_config)
737            .unwrap_or_default();
738
739        let name = container_name(&context.session_id);
740
741        // Ensure container is running
742        if let Err(e) = ensure_container_running(&name, &config).await {
743            return ToolExecutionResult::tool_error(e);
744        }
745
746        debug!("Writing file to container {}: {}", name, path);
747
748        // Get parent directory
749        let parent_dir = std::path::Path::new(path)
750            .parent()
751            .map(|p| p.to_string_lossy().to_string())
752            .unwrap_or_else(|| "/".to_string());
753
754        // Create parent directories if needed
755        let mkdir_output = Command::new("docker")
756            .args(["exec", &name, "mkdir", "-p", &parent_dir])
757            .output()
758            .await;
759
760        if let Err(e) = mkdir_output {
761            warn!("Failed to create parent directory: {}", e);
762        }
763
764        // Write content using docker exec with base64 encoding to handle special characters
765        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, content);
766
767        let output = match Command::new("docker")
768            .args([
769                "exec",
770                &name,
771                "sh",
772                "-c",
773                &format!("echo '{}' | base64 -d > '{}'", encoded, path),
774            ])
775            .output()
776            .await
777        {
778            Ok(o) => o,
779            Err(e) => {
780                error!("Failed to write file to container: {}", e);
781                return ToolExecutionResult::internal_error_msg(format!(
782                    "Failed to write file: {}",
783                    e
784                ));
785            }
786        };
787
788        if !output.status.success() {
789            let stderr = String::from_utf8_lossy(&output.stderr);
790            return ToolExecutionResult::tool_error(format!("Failed to write file: {}", stderr));
791        }
792
793        ToolExecutionResult::success(json!({
794            "path": path,
795            "size_bytes": content.len(),
796            "success": true
797        }))
798    }
799
800    fn requires_context(&self) -> bool {
801        true
802    }
803}
804
805// ============================================================================
806// DockerStopTool
807// ============================================================================
808
809pub struct DockerStopTool;
810
811#[async_trait]
812impl Tool for DockerStopTool {
813    fn name(&self) -> &str {
814        "docker_stop"
815    }
816
817    fn description(&self) -> &str {
818        "Stop and remove the Docker container associated with this session. \
819         Use this to clean up resources or reset the container state. \
820         A new container will be created on the next docker_exec/read/write call."
821    }
822
823    fn parameters_schema(&self) -> Value {
824        json!({
825            "type": "object",
826            "properties": {
827                "force": {
828                    "type": "boolean",
829                    "description": "Force stop (kill) the container if it doesn't stop gracefully (default: false)"
830                }
831            },
832            "additionalProperties": false
833        })
834    }
835
836    fn hints(&self) -> ToolHints {
837        ToolHints::default()
838            .with_open_world(true)
839            .with_destructive(true)
840    }
841
842    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
843        ToolExecutionResult::tool_error(
844            "docker_stop requires context. This tool must be executed with session context.",
845        )
846    }
847
848    async fn execute_with_context(
849        &self,
850        arguments: Value,
851        context: &ToolContext,
852    ) -> ToolExecutionResult {
853        let force = arguments
854            .get("force")
855            .and_then(|v| v.as_bool())
856            .unwrap_or(false);
857
858        let name = container_name(&context.session_id);
859
860        // Check if container exists
861        if !container_exists(&name).await {
862            return ToolExecutionResult::success(json!({
863                "stopped": false,
864                "removed": false,
865                "message": "Container does not exist",
866                "container_name": name
867            }));
868        }
869
870        debug!("Stopping container: {}", name);
871
872        // Stop the container
873        let stop_args = if force {
874            vec!["kill", &name]
875        } else {
876            vec!["stop", &name]
877        };
878
879        let stop_output = match Command::new("docker").args(&stop_args).output().await {
880            Ok(o) => o,
881            Err(e) => {
882                error!("Failed to stop container: {}", e);
883                return ToolExecutionResult::internal_error_msg(format!(
884                    "Failed to stop container: {}",
885                    e
886                ));
887            }
888        };
889
890        let stopped = stop_output.status.success();
891        if !stopped {
892            let stderr = String::from_utf8_lossy(&stop_output.stderr);
893            warn!("Failed to stop container {}: {}", name, stderr);
894        }
895
896        // Remove the container
897        debug!("Removing container: {}", name);
898
899        let rm_output = match Command::new("docker")
900            .args(["rm", "-f", &name])
901            .output()
902            .await
903        {
904            Ok(o) => o,
905            Err(e) => {
906                error!("Failed to remove container: {}", e);
907                return ToolExecutionResult::internal_error_msg(format!(
908                    "Failed to remove container: {}",
909                    e
910                ));
911            }
912        };
913
914        let removed = rm_output.status.success();
915        if !removed {
916            let stderr = String::from_utf8_lossy(&rm_output.stderr);
917            warn!("Failed to remove container {}: {}", name, stderr);
918        }
919
920        info!("Container {} stopped and removed", name);
921
922        ToolExecutionResult::success(json!({
923            "stopped": stopped,
924            "removed": removed,
925            "container_name": name,
926            "message": if stopped && removed {
927                "Container stopped and removed successfully"
928            } else if removed {
929                "Container removed (was not running)"
930            } else {
931                "Failed to fully clean up container"
932            }
933        }))
934    }
935
936    fn requires_context(&self) -> bool {
937        true
938    }
939}
940
941// ============================================================================
942// DockerLogsTool
943// ============================================================================
944
945pub struct DockerLogsTool;
946
947#[async_trait]
948impl Tool for DockerLogsTool {
949    fn name(&self) -> &str {
950        "docker_logs"
951    }
952
953    fn description(&self) -> &str {
954        "Get logs from the Docker container. Returns stdout/stderr output from the container. \
955         Useful for debugging long-running processes or checking application output."
956    }
957
958    fn parameters_schema(&self) -> Value {
959        json!({
960            "type": "object",
961            "properties": {
962                "tail": {
963                    "type": "integer",
964                    "description": "Number of lines to show from the end of the logs (default: 100)"
965                },
966                "since": {
967                    "type": "string",
968                    "description": "Show logs since timestamp (e.g., '2024-01-01T00:00:00Z') or relative time (e.g., '10m', '1h')"
969                },
970                "timestamps": {
971                    "type": "boolean",
972                    "description": "Show timestamps with each log line (default: false)"
973                }
974            },
975            "additionalProperties": false
976        })
977    }
978
979    fn hints(&self) -> ToolHints {
980        ToolHints::default()
981            .with_readonly(true)
982            .with_open_world(true)
983            .with_idempotent(true)
984    }
985
986    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
987        ToolExecutionResult::tool_error(
988            "docker_logs requires context. This tool must be executed with session context.",
989        )
990    }
991
992    async fn execute_with_context(
993        &self,
994        arguments: Value,
995        context: &ToolContext,
996    ) -> ToolExecutionResult {
997        let tail = arguments
998            .get("tail")
999            .and_then(|v| v.as_i64())
1000            .unwrap_or(100);
1001
1002        let since = arguments.get("since").and_then(|v| v.as_str());
1003
1004        let timestamps = arguments
1005            .get("timestamps")
1006            .and_then(|v| v.as_bool())
1007            .unwrap_or(false);
1008
1009        let name = container_name(&context.session_id);
1010
1011        // Check if container exists
1012        if !container_exists(&name).await {
1013            return ToolExecutionResult::tool_error(format!(
1014                "Container '{}' does not exist. Use docker_exec to start it first.",
1015                name
1016            ));
1017        }
1018
1019        debug!("Getting logs from container: {}", name);
1020
1021        // Build docker logs command
1022        let mut args = vec!["logs".to_string()];
1023
1024        args.push("--tail".to_string());
1025        args.push(tail.to_string());
1026
1027        if let Some(since_val) = since {
1028            args.push("--since".to_string());
1029            args.push(since_val.to_string());
1030        }
1031
1032        if timestamps {
1033            args.push("--timestamps".to_string());
1034        }
1035
1036        args.push(name.clone());
1037
1038        // Execute docker logs
1039        let output = match Command::new("docker").args(&args).output().await {
1040            Ok(o) => o,
1041            Err(e) => {
1042                error!("Failed to get logs from container: {}", e);
1043                return ToolExecutionResult::internal_error_msg(format!(
1044                    "Failed to get logs: {}",
1045                    e
1046                ));
1047            }
1048        };
1049
1050        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1051        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1052
1053        // Docker logs command puts container stderr on command stderr,
1054        // so we combine them for the user
1055        let combined_logs = if stderr.is_empty() {
1056            stdout.clone()
1057        } else if stdout.is_empty() {
1058            stderr.clone()
1059        } else {
1060            format!("{}\n{}", stdout, stderr)
1061        };
1062
1063        ToolExecutionResult::success(json!({
1064            "logs": combined_logs,
1065            "stdout": stdout,
1066            "stderr": stderr,
1067            "container_name": name,
1068            "lines_requested": tail
1069        }))
1070    }
1071
1072    fn requires_context(&self) -> bool {
1073        true
1074    }
1075}
1076
1077// ============================================================================
1078// Tests
1079// ============================================================================
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    // --- Capability metadata tests ---
1086
1087    #[test]
1088    fn test_capability_metadata() {
1089        let cap = DockerContainerCapability;
1090        assert_eq!(cap.id(), "docker_container");
1091        assert_eq!(cap.name(), "[Experimental] Docker Container");
1092        assert_eq!(cap.status(), CapabilityStatus::Available);
1093        assert_eq!(cap.icon(), Some("container"));
1094        assert_eq!(cap.category(), Some("Sandboxes"));
1095    }
1096
1097    #[test]
1098    fn test_capability_has_all_tools() {
1099        let cap = DockerContainerCapability;
1100        let tools = cap.tools();
1101        assert_eq!(tools.len(), 5);
1102
1103        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1104        assert!(names.contains(&"docker_exec"));
1105        assert!(names.contains(&"docker_read_file"));
1106        assert!(names.contains(&"docker_write_file"));
1107        assert!(names.contains(&"docker_logs"));
1108        assert!(names.contains(&"docker_stop"));
1109    }
1110
1111    #[test]
1112    fn test_capability_has_system_prompt() {
1113        let cap = DockerContainerCapability;
1114        let prompt = cap.system_prompt_addition().unwrap();
1115        assert!(prompt.contains("lazily-started Docker container"));
1116        assert!(prompt.contains("host networking"));
1117        assert!(prompt.contains("/workspace"));
1118        assert!(prompt.contains("stopping removes/resets it"));
1119    }
1120
1121    #[tokio::test]
1122    async fn system_prompt_within_budget() {
1123        let cap = DockerContainerCapability;
1124        let ctx = everruns_core::capabilities::SystemPromptContext::without_file_store(
1125            everruns_core::SessionId::new(),
1126        );
1127        let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
1128        assert!(prompt.len() <= 1150, "prompt is {} bytes", prompt.len());
1129    }
1130
1131    #[test]
1132    fn test_all_tools_require_context() {
1133        let cap = DockerContainerCapability;
1134        for tool in cap.tools() {
1135            assert!(
1136                tool.requires_context(),
1137                "Tool {} should require context",
1138                tool.name()
1139            );
1140        }
1141    }
1142
1143    #[test]
1144    fn localizations_cover_schema_summary_and_uk_name() {
1145        let cap = DockerContainerCapability;
1146        assert!(cap.describe_schema(None).is_some());
1147        assert_ne!(cap.localized_name(Some("uk-UA")), cap.name());
1148    }
1149
1150    // --- Config tests ---
1151
1152    #[test]
1153    fn test_config_default() {
1154        let config = DockerContainerConfig::default();
1155        assert_eq!(config.image, DEFAULT_IMAGE);
1156        assert_eq!(config.working_dir, DEFAULT_WORKING_DIR);
1157    }
1158
1159    #[test]
1160    fn test_config_parse() {
1161        let json = json!({
1162            "image": "ubuntu:22.04",
1163            "working_dir": "/app"
1164        });
1165        let config = parse_config(&json);
1166        assert_eq!(config.image, "ubuntu:22.04");
1167        assert_eq!(config.working_dir, "/app");
1168    }
1169
1170    #[test]
1171    fn test_config_parse_partial() {
1172        let json = json!({
1173            "image": "node:18"
1174        });
1175        let config = parse_config(&json);
1176        assert_eq!(config.image, "node:18");
1177        assert_eq!(config.working_dir, DEFAULT_WORKING_DIR);
1178    }
1179
1180    #[test]
1181    fn test_container_name() {
1182        let uuid = uuid::Uuid::parse_str("12345678-1234-1234-1234-123456789012").unwrap();
1183        let session_id = everruns_core::typed_id::SessionId::from_uuid(uuid);
1184        let name = container_name(&session_id);
1185        assert_eq!(name, "everruns-12345678-1234-1234-1234-123456789012");
1186    }
1187
1188    // --- Error path tests ---
1189
1190    #[tokio::test]
1191    async fn test_docker_exec_without_context() {
1192        let tool = DockerExecTool;
1193        let result = tool.execute(json!({"command": "echo hello"})).await;
1194        match result {
1195            ToolExecutionResult::ToolError(msg) => {
1196                assert!(msg.contains("requires context"));
1197            }
1198            _ => panic!("Expected tool error"),
1199        }
1200    }
1201
1202    #[tokio::test]
1203    async fn test_docker_read_file_without_context() {
1204        let tool = DockerReadFileTool;
1205        let result = tool.execute(json!({"path": "/test.txt"})).await;
1206        match result {
1207            ToolExecutionResult::ToolError(msg) => {
1208                assert!(msg.contains("requires context"));
1209            }
1210            _ => panic!("Expected tool error"),
1211        }
1212    }
1213
1214    #[tokio::test]
1215    async fn test_docker_write_file_without_context() {
1216        let tool = DockerWriteFileTool;
1217        let result = tool
1218            .execute(json!({"path": "/test.txt", "content": "hello"}))
1219            .await;
1220        match result {
1221            ToolExecutionResult::ToolError(msg) => {
1222                assert!(msg.contains("requires context"));
1223            }
1224            _ => panic!("Expected tool error"),
1225        }
1226    }
1227
1228    #[tokio::test]
1229    async fn test_docker_stop_without_context() {
1230        let tool = DockerStopTool;
1231        let result = tool.execute(json!({})).await;
1232        match result {
1233            ToolExecutionResult::ToolError(msg) => {
1234                assert!(msg.contains("requires context"));
1235            }
1236            _ => panic!("Expected tool error"),
1237        }
1238    }
1239
1240    #[tokio::test]
1241    async fn test_docker_logs_without_context() {
1242        let tool = DockerLogsTool;
1243        let result = tool.execute(json!({})).await;
1244        match result {
1245            ToolExecutionResult::ToolError(msg) => {
1246                assert!(msg.contains("requires context"));
1247            }
1248            _ => panic!("Expected tool error"),
1249        }
1250    }
1251
1252    #[tokio::test]
1253    async fn test_docker_exec_missing_command() {
1254        let tool = DockerExecTool;
1255        let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
1256            uuid::Uuid::nil(),
1257        ));
1258        let result = tool.execute_with_context(json!({}), &context).await;
1259        match result {
1260            ToolExecutionResult::ToolError(msg) => {
1261                assert!(msg.contains("Missing required parameter"));
1262            }
1263            _ => panic!("Expected tool error for missing command"),
1264        }
1265    }
1266
1267    #[tokio::test]
1268    async fn test_docker_read_file_missing_path() {
1269        let tool = DockerReadFileTool;
1270        let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
1271            uuid::Uuid::nil(),
1272        ));
1273        let result = tool.execute_with_context(json!({}), &context).await;
1274        match result {
1275            ToolExecutionResult::ToolError(msg) => {
1276                assert!(msg.contains("Missing required parameter"));
1277            }
1278            _ => panic!("Expected tool error for missing path"),
1279        }
1280    }
1281
1282    #[tokio::test]
1283    async fn test_docker_write_file_missing_params() {
1284        let tool = DockerWriteFileTool;
1285        let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
1286            uuid::Uuid::nil(),
1287        ));
1288
1289        // Missing path
1290        let result = tool
1291            .execute_with_context(json!({"content": "hello"}), &context)
1292            .await;
1293        match result {
1294            ToolExecutionResult::ToolError(msg) => {
1295                assert!(msg.contains("Missing required parameter"));
1296            }
1297            _ => panic!("Expected tool error for missing path"),
1298        }
1299
1300        // Missing content
1301        let result = tool
1302            .execute_with_context(json!({"path": "/test.txt"}), &context)
1303            .await;
1304        match result {
1305            ToolExecutionResult::ToolError(msg) => {
1306                assert!(msg.contains("Missing required parameter"));
1307            }
1308            _ => panic!("Expected tool error for missing content"),
1309        }
1310    }
1311}