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 ) -> 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 if let Err(e) = ensure_container_running(&name, &config).await {
469 return ToolExecutionResult::tool_error(e);
470 }
471
472 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 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
539pub 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 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 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
665pub 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 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 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 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 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
805pub 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 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 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 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
941pub 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 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 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 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 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#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 #[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 #[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 #[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 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 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}