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        _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
383    ) -> Option<String> {
384        let fallback = self.display_name().unwrap_or("Docker");
385        Some(everruns_core::tool_narration::narrate_shell_exec(
386            &tool_call.arguments,
387            fallback,
388            phase,
389            locale,
390        ))
391    }
392
393    fn name(&self) -> &str {
394        "docker_exec"
395    }
396
397    fn description(&self) -> &str {
398        "Execute a command inside the Docker container. Returns stdout, stderr, and exit code. \
399         The container is automatically started if not already running."
400    }
401
402    fn parameters_schema(&self) -> Value {
403        json!({
404            "type": "object",
405            "properties": {
406                "command": {
407                    "type": "string",
408                    "description": "The command to execute (e.g., 'ls -la' or 'python script.py')"
409                },
410                "working_dir": {
411                    "type": "string",
412                    "description": "Working directory for the command (optional, defaults to container's working dir)"
413                },
414                "config": {
415                    "type": "object",
416                    "description": "Container configuration (image, working_dir). Usually provided by capability config.",
417                    "properties": {
418                        "image": { "type": "string" },
419                        "working_dir": { "type": "string" }
420                    }
421                },
422                "output": everruns_core::tool_output_sanitizer::output_verbosity_schema()
423            },
424            "required": ["command"],
425            "additionalProperties": false
426        })
427    }
428
429    fn hints(&self) -> ToolHints {
430        ToolHints::default()
431            .with_open_world(true)
432            .with_long_running(true)
433            .with_persist_output(true)
434    }
435
436    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
437        ToolExecutionResult::tool_error(
438            "docker_exec requires context. This tool must be executed with session context.",
439        )
440    }
441
442    async fn execute_with_context(
443        &self,
444        arguments: Value,
445        context: &ToolContext,
446    ) -> ToolExecutionResult {
447        let command = match arguments.get("command").and_then(|v| v.as_str()) {
448            Some(c) => c,
449            None => return ToolExecutionResult::tool_error("Missing required parameter: command"),
450        };
451
452        let config = arguments
453            .get("config")
454            .map(parse_config)
455            .unwrap_or_default();
456
457        let working_dir = arguments
458            .get("working_dir")
459            .and_then(|v| v.as_str())
460            .map(String::from);
461        let output_mode = arguments
462            .get("output")
463            .and_then(|v| v.as_str())
464            .unwrap_or("auto");
465
466        let name = container_name(&context.session_id);
467
468        // Ensure container is running
469        if let Err(e) = ensure_container_running(&name, &config).await {
470            return ToolExecutionResult::tool_error(e);
471        }
472
473        // Build exec command
474        let mut args = vec!["exec".to_string()];
475
476        if let Some(ref wd) = working_dir {
477            args.push("-w".to_string());
478            args.push(wd.clone());
479        }
480
481        args.push(name.clone());
482        args.push("sh".to_string());
483        args.push("-c".to_string());
484        args.push(command.to_string());
485
486        debug!("Executing in container {}: {}", name, command);
487
488        // Execute command
489        let output = match Command::new("docker").args(&args).output().await {
490            Ok(o) => o,
491            Err(e) => {
492                error!("Failed to execute command in container: {}", e);
493                return ToolExecutionResult::internal_error_msg(format!(
494                    "Failed to execute command: {}",
495                    e
496                ));
497            }
498        };
499
500        let exit_code = output.status.code().unwrap_or(-1);
501        let stdout_raw = String::from_utf8_lossy(&output.stdout);
502        let stderr_raw = String::from_utf8_lossy(&output.stderr);
503
504        use everruns_core::tool_output_sanitizer::{
505            clean_exec_output, output_verbosity_budget, priority_aware_truncate, resolve_auto_mode,
506        };
507        let clean_stdout = clean_exec_output(&stdout_raw);
508        let clean_stderr = clean_exec_output(&stderr_raw);
509        let effective_mode = resolve_auto_mode(output_mode, exit_code);
510        let (stdout, stderr) = if let Some(budget) = output_verbosity_budget(effective_mode) {
511            (
512                priority_aware_truncate(&clean_stdout, budget),
513                priority_aware_truncate(&clean_stderr, budget.min(4096)),
514            )
515        } else {
516            (clean_stdout.clone(), clean_stderr.clone())
517        };
518        let mut raw = clean_stdout;
519        if !clean_stderr.is_empty() {
520            raw.push_str("\n--- stderr ---\n");
521            raw.push_str(&clean_stderr);
522        }
523
524        ToolExecutionResult::success_with_raw_output(
525            json!({
526                "stdout": stdout,
527                "stderr": stderr,
528                "exit_code": exit_code,
529                "success": exit_code == 0
530            }),
531            raw,
532        )
533    }
534
535    fn requires_context(&self) -> bool {
536        true
537    }
538}
539
540// ============================================================================
541// DockerReadFileTool
542// ============================================================================
543
544pub struct DockerReadFileTool;
545
546#[async_trait]
547impl Tool for DockerReadFileTool {
548    fn name(&self) -> &str {
549        "docker_read_file"
550    }
551
552    fn description(&self) -> &str {
553        "Read a file from the Docker container filesystem. \
554         The container is automatically started if not already running."
555    }
556
557    fn parameters_schema(&self) -> Value {
558        json!({
559            "type": "object",
560            "properties": {
561                "path": {
562                    "type": "string",
563                    "description": "Absolute path to the file inside the container (e.g., '/workspace/main.py')"
564                },
565                "offset": {
566                    "type": "integer",
567                    "minimum": 0,
568                    "default": 0,
569                    "description": "Zero-based line offset to start reading from"
570                },
571                "limit": {
572                    "type": "integer",
573                    "minimum": 1,
574                    "default": READ_FILE_DEFAULT_LIMIT,
575                    "description": "Maximum number of lines to return"
576                },
577                "config": {
578                    "type": "object",
579                    "description": "Container configuration (image, working_dir). Usually provided by capability config.",
580                    "properties": {
581                        "image": { "type": "string" },
582                        "working_dir": { "type": "string" }
583                    }
584                }
585            },
586            "required": ["path"],
587            "additionalProperties": false
588        })
589    }
590
591    fn hints(&self) -> ToolHints {
592        ToolHints::default()
593            .with_readonly(true)
594            .with_open_world(true)
595    }
596
597    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
598        ToolExecutionResult::tool_error(
599            "docker_read_file requires context. This tool must be executed with session context.",
600        )
601    }
602
603    async fn execute_with_context(
604        &self,
605        arguments: Value,
606        context: &ToolContext,
607    ) -> ToolExecutionResult {
608        let path = match arguments.get("path").and_then(|v| v.as_str()) {
609            Some(p) => p,
610            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
611        };
612        let (offset, limit) = match parse_read_file_window_args(&arguments) {
613            Ok(window) => window,
614            Err(err) => return ToolExecutionResult::tool_error(err),
615        };
616
617        let config = arguments
618            .get("config")
619            .map(parse_config)
620            .unwrap_or_default();
621
622        let name = container_name(&context.session_id);
623
624        // Ensure container is running
625        if let Err(e) = ensure_container_running(&name, &config).await {
626            return ToolExecutionResult::tool_error(e);
627        }
628
629        debug!("Reading file from container {}: {}", name, path);
630
631        // Use docker exec to cat the file
632        let output = match Command::new("docker")
633            .args(["exec", &name, "cat", path])
634            .output()
635            .await
636        {
637            Ok(o) => o,
638            Err(e) => {
639                error!("Failed to read file from container: {}", e);
640                return ToolExecutionResult::internal_error_msg(format!(
641                    "Failed to read file: {}",
642                    e
643                ));
644            }
645        };
646
647        if !output.status.success() {
648            let stderr = String::from_utf8_lossy(&output.stderr);
649            return ToolExecutionResult::tool_error(format!("Failed to read file: {}", stderr));
650        }
651
652        ToolExecutionResult::success(build_bytes_read_file_result(
653            "docker_read_file",
654            path,
655            &output.stdout,
656            offset,
657            limit,
658        ))
659    }
660
661    fn requires_context(&self) -> bool {
662        true
663    }
664}
665
666// ============================================================================
667// DockerWriteFileTool
668// ============================================================================
669
670pub struct DockerWriteFileTool;
671
672#[async_trait]
673impl Tool for DockerWriteFileTool {
674    fn name(&self) -> &str {
675        "docker_write_file"
676    }
677
678    fn description(&self) -> &str {
679        "Write content to a file in the Docker container filesystem. \
680         Parent directories are created automatically. \
681         The container is automatically started if not already running."
682    }
683
684    fn parameters_schema(&self) -> Value {
685        json!({
686            "type": "object",
687            "properties": {
688                "path": {
689                    "type": "string",
690                    "description": "Absolute path for the file inside the container (e.g., '/workspace/main.py')"
691                },
692                "content": {
693                    "type": "string",
694                    "description": "Content to write to the file"
695                },
696                "config": {
697                    "type": "object",
698                    "description": "Container configuration (image, working_dir). Usually provided by capability config.",
699                    "properties": {
700                        "image": { "type": "string" },
701                        "working_dir": { "type": "string" }
702                    }
703                }
704            },
705            "required": ["path", "content"],
706            "additionalProperties": false
707        })
708    }
709
710    fn hints(&self) -> ToolHints {
711        ToolHints::default().with_open_world(true)
712    }
713
714    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
715        ToolExecutionResult::tool_error(
716            "docker_write_file requires context. This tool must be executed with session context.",
717        )
718    }
719
720    async fn execute_with_context(
721        &self,
722        arguments: Value,
723        context: &ToolContext,
724    ) -> ToolExecutionResult {
725        let path = match arguments.get("path").and_then(|v| v.as_str()) {
726            Some(p) => p,
727            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
728        };
729
730        let content = match arguments.get("content").and_then(|v| v.as_str()) {
731            Some(c) => c,
732            None => return ToolExecutionResult::tool_error("Missing required parameter: content"),
733        };
734
735        let config = arguments
736            .get("config")
737            .map(parse_config)
738            .unwrap_or_default();
739
740        let name = container_name(&context.session_id);
741
742        // Ensure container is running
743        if let Err(e) = ensure_container_running(&name, &config).await {
744            return ToolExecutionResult::tool_error(e);
745        }
746
747        debug!("Writing file to container {}: {}", name, path);
748
749        // Get parent directory
750        let parent_dir = std::path::Path::new(path)
751            .parent()
752            .map(|p| p.to_string_lossy().to_string())
753            .unwrap_or_else(|| "/".to_string());
754
755        // Create parent directories if needed
756        let mkdir_output = Command::new("docker")
757            .args(["exec", &name, "mkdir", "-p", &parent_dir])
758            .output()
759            .await;
760
761        if let Err(e) = mkdir_output {
762            warn!("Failed to create parent directory: {}", e);
763        }
764
765        // Write content using docker exec with base64 encoding to handle special characters
766        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, content);
767
768        let output = match Command::new("docker")
769            .args([
770                "exec",
771                &name,
772                "sh",
773                "-c",
774                &format!("echo '{}' | base64 -d > '{}'", encoded, path),
775            ])
776            .output()
777            .await
778        {
779            Ok(o) => o,
780            Err(e) => {
781                error!("Failed to write file to container: {}", e);
782                return ToolExecutionResult::internal_error_msg(format!(
783                    "Failed to write file: {}",
784                    e
785                ));
786            }
787        };
788
789        if !output.status.success() {
790            let stderr = String::from_utf8_lossy(&output.stderr);
791            return ToolExecutionResult::tool_error(format!("Failed to write file: {}", stderr));
792        }
793
794        ToolExecutionResult::success(json!({
795            "path": path,
796            "size_bytes": content.len(),
797            "success": true
798        }))
799    }
800
801    fn requires_context(&self) -> bool {
802        true
803    }
804}
805
806// ============================================================================
807// DockerStopTool
808// ============================================================================
809
810pub struct DockerStopTool;
811
812#[async_trait]
813impl Tool for DockerStopTool {
814    fn name(&self) -> &str {
815        "docker_stop"
816    }
817
818    fn description(&self) -> &str {
819        "Stop and remove the Docker container associated with this session. \
820         Use this to clean up resources or reset the container state. \
821         A new container will be created on the next docker_exec/read/write call."
822    }
823
824    fn parameters_schema(&self) -> Value {
825        json!({
826            "type": "object",
827            "properties": {
828                "force": {
829                    "type": "boolean",
830                    "description": "Force stop (kill) the container if it doesn't stop gracefully (default: false)"
831                }
832            },
833            "additionalProperties": false
834        })
835    }
836
837    fn hints(&self) -> ToolHints {
838        ToolHints::default()
839            .with_open_world(true)
840            .with_destructive(true)
841    }
842
843    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
844        ToolExecutionResult::tool_error(
845            "docker_stop requires context. This tool must be executed with session context.",
846        )
847    }
848
849    async fn execute_with_context(
850        &self,
851        arguments: Value,
852        context: &ToolContext,
853    ) -> ToolExecutionResult {
854        let force = arguments
855            .get("force")
856            .and_then(|v| v.as_bool())
857            .unwrap_or(false);
858
859        let name = container_name(&context.session_id);
860
861        // Check if container exists
862        if !container_exists(&name).await {
863            return ToolExecutionResult::success(json!({
864                "stopped": false,
865                "removed": false,
866                "message": "Container does not exist",
867                "container_name": name
868            }));
869        }
870
871        debug!("Stopping container: {}", name);
872
873        // Stop the container
874        let stop_args = if force {
875            vec!["kill", &name]
876        } else {
877            vec!["stop", &name]
878        };
879
880        let stop_output = match Command::new("docker").args(&stop_args).output().await {
881            Ok(o) => o,
882            Err(e) => {
883                error!("Failed to stop container: {}", e);
884                return ToolExecutionResult::internal_error_msg(format!(
885                    "Failed to stop container: {}",
886                    e
887                ));
888            }
889        };
890
891        let stopped = stop_output.status.success();
892        if !stopped {
893            let stderr = String::from_utf8_lossy(&stop_output.stderr);
894            warn!("Failed to stop container {}: {}", name, stderr);
895        }
896
897        // Remove the container
898        debug!("Removing container: {}", name);
899
900        let rm_output = match Command::new("docker")
901            .args(["rm", "-f", &name])
902            .output()
903            .await
904        {
905            Ok(o) => o,
906            Err(e) => {
907                error!("Failed to remove container: {}", e);
908                return ToolExecutionResult::internal_error_msg(format!(
909                    "Failed to remove container: {}",
910                    e
911                ));
912            }
913        };
914
915        let removed = rm_output.status.success();
916        if !removed {
917            let stderr = String::from_utf8_lossy(&rm_output.stderr);
918            warn!("Failed to remove container {}: {}", name, stderr);
919        }
920
921        info!("Container {} stopped and removed", name);
922
923        ToolExecutionResult::success(json!({
924            "stopped": stopped,
925            "removed": removed,
926            "container_name": name,
927            "message": if stopped && removed {
928                "Container stopped and removed successfully"
929            } else if removed {
930                "Container removed (was not running)"
931            } else {
932                "Failed to fully clean up container"
933            }
934        }))
935    }
936
937    fn requires_context(&self) -> bool {
938        true
939    }
940}
941
942// ============================================================================
943// DockerLogsTool
944// ============================================================================
945
946pub struct DockerLogsTool;
947
948#[async_trait]
949impl Tool for DockerLogsTool {
950    fn name(&self) -> &str {
951        "docker_logs"
952    }
953
954    fn description(&self) -> &str {
955        "Get logs from the Docker container. Returns stdout/stderr output from the container. \
956         Useful for debugging long-running processes or checking application output."
957    }
958
959    fn parameters_schema(&self) -> Value {
960        json!({
961            "type": "object",
962            "properties": {
963                "tail": {
964                    "type": "integer",
965                    "description": "Number of lines to show from the end of the logs (default: 100)"
966                },
967                "since": {
968                    "type": "string",
969                    "description": "Show logs since timestamp (e.g., '2024-01-01T00:00:00Z') or relative time (e.g., '10m', '1h')"
970                },
971                "timestamps": {
972                    "type": "boolean",
973                    "description": "Show timestamps with each log line (default: false)"
974                }
975            },
976            "additionalProperties": false
977        })
978    }
979
980    fn hints(&self) -> ToolHints {
981        ToolHints::default()
982            .with_readonly(true)
983            .with_open_world(true)
984            .with_idempotent(true)
985    }
986
987    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
988        ToolExecutionResult::tool_error(
989            "docker_logs requires context. This tool must be executed with session context.",
990        )
991    }
992
993    async fn execute_with_context(
994        &self,
995        arguments: Value,
996        context: &ToolContext,
997    ) -> ToolExecutionResult {
998        let tail = arguments
999            .get("tail")
1000            .and_then(|v| v.as_i64())
1001            .unwrap_or(100);
1002
1003        let since = arguments.get("since").and_then(|v| v.as_str());
1004
1005        let timestamps = arguments
1006            .get("timestamps")
1007            .and_then(|v| v.as_bool())
1008            .unwrap_or(false);
1009
1010        let name = container_name(&context.session_id);
1011
1012        // Check if container exists
1013        if !container_exists(&name).await {
1014            return ToolExecutionResult::tool_error(format!(
1015                "Container '{}' does not exist. Use docker_exec to start it first.",
1016                name
1017            ));
1018        }
1019
1020        debug!("Getting logs from container: {}", name);
1021
1022        // Build docker logs command
1023        let mut args = vec!["logs".to_string()];
1024
1025        args.push("--tail".to_string());
1026        args.push(tail.to_string());
1027
1028        if let Some(since_val) = since {
1029            args.push("--since".to_string());
1030            args.push(since_val.to_string());
1031        }
1032
1033        if timestamps {
1034            args.push("--timestamps".to_string());
1035        }
1036
1037        args.push(name.clone());
1038
1039        // Execute docker logs
1040        let output = match Command::new("docker").args(&args).output().await {
1041            Ok(o) => o,
1042            Err(e) => {
1043                error!("Failed to get logs from container: {}", e);
1044                return ToolExecutionResult::internal_error_msg(format!(
1045                    "Failed to get logs: {}",
1046                    e
1047                ));
1048            }
1049        };
1050
1051        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1052        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1053
1054        // Docker logs command puts container stderr on command stderr,
1055        // so we combine them for the user
1056        let combined_logs = if stderr.is_empty() {
1057            stdout.clone()
1058        } else if stdout.is_empty() {
1059            stderr.clone()
1060        } else {
1061            format!("{}\n{}", stdout, stderr)
1062        };
1063
1064        ToolExecutionResult::success(json!({
1065            "logs": combined_logs,
1066            "stdout": stdout,
1067            "stderr": stderr,
1068            "container_name": name,
1069            "lines_requested": tail
1070        }))
1071    }
1072
1073    fn requires_context(&self) -> bool {
1074        true
1075    }
1076}
1077
1078// ============================================================================
1079// Tests
1080// ============================================================================
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085
1086    // --- Capability metadata tests ---
1087
1088    #[test]
1089    fn test_capability_metadata() {
1090        let cap = DockerContainerCapability;
1091        assert_eq!(cap.id(), "docker_container");
1092        assert_eq!(cap.name(), "[Experimental] Docker Container");
1093        assert_eq!(cap.status(), CapabilityStatus::Available);
1094        assert_eq!(cap.icon(), Some("container"));
1095        assert_eq!(cap.category(), Some("Sandboxes"));
1096    }
1097
1098    #[test]
1099    fn test_capability_has_all_tools() {
1100        let cap = DockerContainerCapability;
1101        let tools = cap.tools();
1102        assert_eq!(tools.len(), 5);
1103
1104        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1105        assert!(names.contains(&"docker_exec"));
1106        assert!(names.contains(&"docker_read_file"));
1107        assert!(names.contains(&"docker_write_file"));
1108        assert!(names.contains(&"docker_logs"));
1109        assert!(names.contains(&"docker_stop"));
1110    }
1111
1112    #[test]
1113    fn test_capability_has_system_prompt() {
1114        let cap = DockerContainerCapability;
1115        let prompt = cap.system_prompt_addition().unwrap();
1116        assert!(prompt.contains("lazily-started Docker container"));
1117        assert!(prompt.contains("host networking"));
1118        assert!(prompt.contains("/workspace"));
1119        assert!(prompt.contains("stopping removes/resets it"));
1120    }
1121
1122    #[tokio::test]
1123    async fn system_prompt_within_budget() {
1124        let cap = DockerContainerCapability;
1125        let ctx = everruns_core::capabilities::SystemPromptContext::without_file_store(
1126            everruns_core::SessionId::new(),
1127        );
1128        let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
1129        assert!(prompt.len() <= 1150, "prompt is {} bytes", prompt.len());
1130    }
1131
1132    #[test]
1133    fn test_all_tools_require_context() {
1134        let cap = DockerContainerCapability;
1135        for tool in cap.tools() {
1136            assert!(
1137                tool.requires_context(),
1138                "Tool {} should require context",
1139                tool.name()
1140            );
1141        }
1142    }
1143
1144    #[test]
1145    fn localizations_cover_schema_summary_and_uk_name() {
1146        let cap = DockerContainerCapability;
1147        assert!(cap.describe_schema(None).is_some());
1148        assert_ne!(cap.localized_name(Some("uk-UA")), cap.name());
1149    }
1150
1151    // --- Config tests ---
1152
1153    #[test]
1154    fn test_config_default() {
1155        let config = DockerContainerConfig::default();
1156        assert_eq!(config.image, DEFAULT_IMAGE);
1157        assert_eq!(config.working_dir, DEFAULT_WORKING_DIR);
1158    }
1159
1160    #[test]
1161    fn test_config_parse() {
1162        let json = json!({
1163            "image": "ubuntu:22.04",
1164            "working_dir": "/app"
1165        });
1166        let config = parse_config(&json);
1167        assert_eq!(config.image, "ubuntu:22.04");
1168        assert_eq!(config.working_dir, "/app");
1169    }
1170
1171    #[test]
1172    fn test_config_parse_partial() {
1173        let json = json!({
1174            "image": "node:18"
1175        });
1176        let config = parse_config(&json);
1177        assert_eq!(config.image, "node:18");
1178        assert_eq!(config.working_dir, DEFAULT_WORKING_DIR);
1179    }
1180
1181    #[test]
1182    fn test_container_name() {
1183        let uuid = uuid::Uuid::parse_str("12345678-1234-1234-1234-123456789012").unwrap();
1184        let session_id = everruns_core::typed_id::SessionId::from_uuid(uuid);
1185        let name = container_name(&session_id);
1186        assert_eq!(name, "everruns-12345678-1234-1234-1234-123456789012");
1187    }
1188
1189    // --- Error path tests ---
1190
1191    #[tokio::test]
1192    async fn test_docker_exec_without_context() {
1193        let tool = DockerExecTool;
1194        let result = tool.execute(json!({"command": "echo hello"})).await;
1195        match result {
1196            ToolExecutionResult::ToolError(msg) => {
1197                assert!(msg.contains("requires context"));
1198            }
1199            _ => panic!("Expected tool error"),
1200        }
1201    }
1202
1203    #[tokio::test]
1204    async fn test_docker_read_file_without_context() {
1205        let tool = DockerReadFileTool;
1206        let result = tool.execute(json!({"path": "/test.txt"})).await;
1207        match result {
1208            ToolExecutionResult::ToolError(msg) => {
1209                assert!(msg.contains("requires context"));
1210            }
1211            _ => panic!("Expected tool error"),
1212        }
1213    }
1214
1215    #[tokio::test]
1216    async fn test_docker_write_file_without_context() {
1217        let tool = DockerWriteFileTool;
1218        let result = tool
1219            .execute(json!({"path": "/test.txt", "content": "hello"}))
1220            .await;
1221        match result {
1222            ToolExecutionResult::ToolError(msg) => {
1223                assert!(msg.contains("requires context"));
1224            }
1225            _ => panic!("Expected tool error"),
1226        }
1227    }
1228
1229    #[tokio::test]
1230    async fn test_docker_stop_without_context() {
1231        let tool = DockerStopTool;
1232        let result = tool.execute(json!({})).await;
1233        match result {
1234            ToolExecutionResult::ToolError(msg) => {
1235                assert!(msg.contains("requires context"));
1236            }
1237            _ => panic!("Expected tool error"),
1238        }
1239    }
1240
1241    #[tokio::test]
1242    async fn test_docker_logs_without_context() {
1243        let tool = DockerLogsTool;
1244        let result = tool.execute(json!({})).await;
1245        match result {
1246            ToolExecutionResult::ToolError(msg) => {
1247                assert!(msg.contains("requires context"));
1248            }
1249            _ => panic!("Expected tool error"),
1250        }
1251    }
1252
1253    #[tokio::test]
1254    async fn test_docker_exec_missing_command() {
1255        let tool = DockerExecTool;
1256        let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
1257            uuid::Uuid::nil(),
1258        ));
1259        let result = tool.execute_with_context(json!({}), &context).await;
1260        match result {
1261            ToolExecutionResult::ToolError(msg) => {
1262                assert!(msg.contains("Missing required parameter"));
1263            }
1264            _ => panic!("Expected tool error for missing command"),
1265        }
1266    }
1267
1268    #[tokio::test]
1269    async fn test_docker_read_file_missing_path() {
1270        let tool = DockerReadFileTool;
1271        let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
1272            uuid::Uuid::nil(),
1273        ));
1274        let result = tool.execute_with_context(json!({}), &context).await;
1275        match result {
1276            ToolExecutionResult::ToolError(msg) => {
1277                assert!(msg.contains("Missing required parameter"));
1278            }
1279            _ => panic!("Expected tool error for missing path"),
1280        }
1281    }
1282
1283    #[tokio::test]
1284    async fn test_docker_write_file_missing_params() {
1285        let tool = DockerWriteFileTool;
1286        let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
1287            uuid::Uuid::nil(),
1288        ));
1289
1290        // Missing path
1291        let result = tool
1292            .execute_with_context(json!({"content": "hello"}), &context)
1293            .await;
1294        match result {
1295            ToolExecutionResult::ToolError(msg) => {
1296                assert!(msg.contains("Missing required parameter"));
1297            }
1298            _ => panic!("Expected tool error for missing path"),
1299        }
1300
1301        // Missing content
1302        let result = tool
1303            .execute_with_context(json!({"path": "/test.txt"}), &context)
1304            .await;
1305        match result {
1306            ToolExecutionResult::ToolError(msg) => {
1307                assert!(msg.contains("Missing required parameter"));
1308            }
1309            _ => panic!("Expected tool error for missing content"),
1310        }
1311    }
1312}