1use 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
39inventory::submit! {
44 IntegrationPlugin {
45 experimental_only: true,
46 feature_flag: Some("docker_capability"),
47 factory: || Box::new(DockerContainerCapability),
48 }
49}
50
51const DEFAULT_IMAGE: &str = "mcr.microsoft.com/devcontainers/python:3.11";
57
58const DEFAULT_WORKING_DIR: &str = "/workspace";
60
61const CONTAINER_PREFIX: &str = "everruns";
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DockerContainerConfig {
71 #[serde(default = "default_image")]
73 pub image: String,
74
75 #[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
97static 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
241fn container_name(session_id: &everruns_core::typed_id::SessionId) -> String {
247 format!("{}-{}", CONTAINER_PREFIX, session_id.uuid())
248}
249
250async 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
267async 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
282async 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
294async fn ensure_container_running(
296 name: &str,
297 config: &DockerContainerConfig,
298) -> Result<(), String> {
299 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 is_container_running(name).await {
308 debug!("Container {} is already running", name);
309 return Ok(());
310 }
311
312 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 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", "--name",
339 name, "--network",
341 "host", "-w",
343 &config.working_dir, "--init", &config.image, "tail",
347 "-f",
348 "/dev/null", ])
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
364fn parse_config(config: &Value) -> DockerContainerConfig {
366 serde_json::from_value(config.clone()).unwrap_or_default()
367}
368
369pub 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 if let Err(e) = ensure_container_running(&name, &config).await {
470 return ToolExecutionResult::tool_error(e);
471 }
472
473 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 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
540pub 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 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 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
666pub 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 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 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 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 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
806pub 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 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 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 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
942pub 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 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 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 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 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#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[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 #[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 #[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 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 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}