use everruns_core::ToolHints;
use everruns_core::capabilities::{
Capability, CapabilityLocalization, CapabilityStatus, IntegrationPlugin, RiskLevel,
};
use everruns_core::tool_output_sanitizer::{
READ_FILE_DEFAULT_LIMIT, build_bytes_read_file_result, parse_read_file_window_args,
};
use everruns_core::tools::{Tool, ToolExecutionResult};
use everruns_core::traits::ToolContext;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::process::Stdio;
use std::sync::LazyLock;
use tokio::process::Command;
use tracing::{debug, error, info, warn};
inventory::submit! {
IntegrationPlugin {
experimental_only: true,
feature_flag: Some("docker_capability"),
factory: || Box::new(DockerContainerCapability),
}
}
const DEFAULT_IMAGE: &str = "mcr.microsoft.com/devcontainers/python:3.11";
const DEFAULT_WORKING_DIR: &str = "/workspace";
const CONTAINER_PREFIX: &str = "everruns";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerContainerConfig {
#[serde(default = "default_image")]
pub image: String,
#[serde(default = "default_working_dir")]
pub working_dir: String,
}
fn default_image() -> String {
DEFAULT_IMAGE.to_string()
}
fn default_working_dir() -> String {
DEFAULT_WORKING_DIR.to_string()
}
impl Default for DockerContainerConfig {
fn default() -> Self {
Self {
image: default_image(),
working_dir: default_working_dir(),
}
}
}
static SYSTEM_PROMPT: LazyLock<String> = LazyLock::new(|| {
let mut prompt = String::from(
"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.",
);
prompt.push_str(everruns_core::tool_output_sanitizer::EXEC_OUTPUT_HINT);
prompt
});
pub struct DockerContainerCapability;
impl Capability for DockerContainerCapability {
fn id(&self) -> &str {
"docker_container"
}
fn name(&self) -> &str {
"[Experimental] Docker Container"
}
fn description(&self) -> &str {
"Run commands and manage files in a Docker container tied to the session. \
Container is lazily started on first use and persists for the session duration. \
EXPERIMENTAL: This capability may change significantly."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::High
}
fn icon(&self) -> Option<&str> {
Some("container")
}
fn category(&self) -> Option<&str> {
Some("Sandboxes")
}
fn system_prompt_addition(&self) -> Option<&str> {
Some(&SYSTEM_PROMPT)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(DockerExecTool),
Box::new(DockerReadFileTool),
Box::new(DockerWriteFileTool),
Box::new(DockerLogsTool),
Box::new(DockerStopTool),
]
}
fn config_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"title": "Docker Container Settings",
"properties": {
"image": {
"type": "string",
"title": "Docker Image",
"description": "Custom base image for the container.",
"default": DEFAULT_IMAGE,
"examples": [DEFAULT_IMAGE]
},
"working_dir": {
"type": "string",
"title": "Working Directory",
"description": "Default working directory inside the container.",
"default": DEFAULT_WORKING_DIR,
"examples": [DEFAULT_WORKING_DIR]
}
}
}))
}
fn config_ui_schema(&self) -> Option<Value> {
Some(json!({
"ui:submitButtonOptions": { "norender": true },
"ui:order": ["image", "working_dir"],
"image": {
"ui:placeholder": DEFAULT_IMAGE
},
"working_dir": {
"ui:placeholder": DEFAULT_WORKING_DIR
}
}))
}
fn validate_config(&self, config: &Value) -> Result<(), String> {
if config.is_null() {
return Ok(());
}
serde_json::from_value::<DockerContainerConfig>(config.clone())
.map(|_| ())
.map_err(|error| format!("Invalid docker_container config: {error}"))
}
fn localizations(&self) -> Vec<CapabilityLocalization> {
vec![
CapabilityLocalization {
locale: "en",
name: None,
description: None,
config_description: Some(
"Choose the container base image and the default working directory inside it.",
),
config_overlay: None,
},
CapabilityLocalization {
locale: "uk",
name: Some("[Експериментально] Контейнер Docker"),
description: Some(
"Виконуйте команди та керуйте файлами в контейнері Docker, прив'язаному до \
сесії. Контейнер ліниво запускається при першому використанні та \
зберігається протягом сесії. ЕКСПЕРИМЕНТАЛЬНО: ця можливість може суттєво \
змінитися.",
),
config_description: Some(
"Визначає базовий образ контейнера та типовий робочий каталог усередині нього.",
),
config_overlay: Some(json!({
"properties": {
"image": {
"title": "Образ Docker",
"description": "Власний базовий образ для контейнера."
},
"working_dir": {
"title": "Робочий каталог",
"description": "Типовий робочий каталог усередині контейнера."
}
}
})),
},
]
}
}
fn container_name(session_id: &everruns_core::typed_id::SessionId) -> String {
format!("{}-{}", CONTAINER_PREFIX, session_id.uuid())
}
async fn is_docker_available() -> bool {
match Command::new("docker")
.arg("version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
{
Ok(status) => status.success(),
Err(e) => {
debug!("Docker not available: {}", e);
false
}
}
}
async fn is_container_running(name: &str) -> bool {
match Command::new("docker")
.args(["inspect", "-f", "{{.State.Running}}", name])
.output()
.await
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.trim() == "true"
}
Err(_) => false,
}
}
async fn container_exists(name: &str) -> bool {
Command::new("docker")
.args(["inspect", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
async fn ensure_container_running(
name: &str,
config: &DockerContainerConfig,
) -> Result<(), String> {
if !is_docker_available().await {
return Err(
"Docker is not available. Please ensure Docker is installed and running.".to_string(),
);
}
if is_container_running(name).await {
debug!("Container {} is already running", name);
return Ok(());
}
if container_exists(name).await {
info!("Starting existing container: {}", name);
let output = Command::new("docker")
.args(["start", name])
.output()
.await
.map_err(|e| format!("Failed to start container: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to start container: {}", stderr));
}
return Ok(());
}
info!(
"Creating new container: {} with image: {}",
name, config.image
);
let output = Command::new("docker")
.args([
"run",
"-d", "--name",
name, "--network",
"host", "-w",
&config.working_dir, "--init", &config.image, "tail",
"-f",
"/dev/null", ])
.output()
.await
.map_err(|e| format!("Failed to create container: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("Failed to create container {}: {}", name, stderr);
return Err(format!("Failed to create container: {}", stderr));
}
info!("Container {} created and running", name);
Ok(())
}
fn parse_config(config: &Value) -> DockerContainerConfig {
serde_json::from_value(config.clone()).unwrap_or_default()
}
pub struct DockerExecTool;
#[async_trait]
impl Tool for DockerExecTool {
fn narrate(
&self,
tool_call: &everruns_core::tool_types::ToolCall,
phase: everruns_core::tool_narration::ToolNarrationPhase,
locale: Option<&str>,
) -> Option<String> {
let fallback = self.display_name().unwrap_or("Docker");
Some(everruns_core::tool_narration::narrate_shell_exec(
&tool_call.arguments,
fallback,
phase,
locale,
))
}
fn name(&self) -> &str {
"docker_exec"
}
fn description(&self) -> &str {
"Execute a command inside the Docker container. Returns stdout, stderr, and exit code. \
The container is automatically started if not already running."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The command to execute (e.g., 'ls -la' or 'python script.py')"
},
"working_dir": {
"type": "string",
"description": "Working directory for the command (optional, defaults to container's working dir)"
},
"config": {
"type": "object",
"description": "Container configuration (image, working_dir). Usually provided by capability config.",
"properties": {
"image": { "type": "string" },
"working_dir": { "type": "string" }
}
},
"output": everruns_core::tool_output_sanitizer::output_verbosity_schema()
},
"required": ["command"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_open_world(true)
.with_long_running(true)
.with_persist_output(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"docker_exec requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let command = match arguments.get("command").and_then(|v| v.as_str()) {
Some(c) => c,
None => return ToolExecutionResult::tool_error("Missing required parameter: command"),
};
let config = arguments
.get("config")
.map(parse_config)
.unwrap_or_default();
let working_dir = arguments
.get("working_dir")
.and_then(|v| v.as_str())
.map(String::from);
let output_mode = arguments
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("auto");
let name = container_name(&context.session_id);
if let Err(e) = ensure_container_running(&name, &config).await {
return ToolExecutionResult::tool_error(e);
}
let mut args = vec!["exec".to_string()];
if let Some(ref wd) = working_dir {
args.push("-w".to_string());
args.push(wd.clone());
}
args.push(name.clone());
args.push("sh".to_string());
args.push("-c".to_string());
args.push(command.to_string());
debug!("Executing in container {}: {}", name, command);
let output = match Command::new("docker").args(&args).output().await {
Ok(o) => o,
Err(e) => {
error!("Failed to execute command in container: {}", e);
return ToolExecutionResult::internal_error_msg(format!(
"Failed to execute command: {}",
e
));
}
};
let exit_code = output.status.code().unwrap_or(-1);
let stdout_raw = String::from_utf8_lossy(&output.stdout);
let stderr_raw = String::from_utf8_lossy(&output.stderr);
use everruns_core::tool_output_sanitizer::{
clean_exec_output, output_verbosity_budget, priority_aware_truncate, resolve_auto_mode,
};
let clean_stdout = clean_exec_output(&stdout_raw);
let clean_stderr = clean_exec_output(&stderr_raw);
let effective_mode = resolve_auto_mode(output_mode, exit_code);
let (stdout, stderr) = if let Some(budget) = output_verbosity_budget(effective_mode) {
(
priority_aware_truncate(&clean_stdout, budget),
priority_aware_truncate(&clean_stderr, budget.min(4096)),
)
} else {
(clean_stdout.clone(), clean_stderr.clone())
};
let mut raw = clean_stdout;
if !clean_stderr.is_empty() {
raw.push_str("\n--- stderr ---\n");
raw.push_str(&clean_stderr);
}
ToolExecutionResult::success_with_raw_output(
json!({
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"success": exit_code == 0
}),
raw,
)
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DockerReadFileTool;
#[async_trait]
impl Tool for DockerReadFileTool {
fn name(&self) -> &str {
"docker_read_file"
}
fn description(&self) -> &str {
"Read a file from the Docker container filesystem. \
The container is automatically started if not already running."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file inside the container (e.g., '/workspace/main.py')"
},
"offset": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Zero-based line offset to start reading from"
},
"limit": {
"type": "integer",
"minimum": 1,
"default": READ_FILE_DEFAULT_LIMIT,
"description": "Maximum number of lines to return"
},
"config": {
"type": "object",
"description": "Container configuration (image, working_dir). Usually provided by capability config.",
"properties": {
"image": { "type": "string" },
"working_dir": { "type": "string" }
}
}
},
"required": ["path"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(true)
.with_open_world(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"docker_read_file requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let path = match arguments.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
};
let (offset, limit) = match parse_read_file_window_args(&arguments) {
Ok(window) => window,
Err(err) => return ToolExecutionResult::tool_error(err),
};
let config = arguments
.get("config")
.map(parse_config)
.unwrap_or_default();
let name = container_name(&context.session_id);
if let Err(e) = ensure_container_running(&name, &config).await {
return ToolExecutionResult::tool_error(e);
}
debug!("Reading file from container {}: {}", name, path);
let output = match Command::new("docker")
.args(["exec", &name, "cat", path])
.output()
.await
{
Ok(o) => o,
Err(e) => {
error!("Failed to read file from container: {}", e);
return ToolExecutionResult::internal_error_msg(format!(
"Failed to read file: {}",
e
));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return ToolExecutionResult::tool_error(format!("Failed to read file: {}", stderr));
}
ToolExecutionResult::success(build_bytes_read_file_result(
"docker_read_file",
path,
&output.stdout,
offset,
limit,
))
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DockerWriteFileTool;
#[async_trait]
impl Tool for DockerWriteFileTool {
fn name(&self) -> &str {
"docker_write_file"
}
fn description(&self) -> &str {
"Write content to a file in the Docker container filesystem. \
Parent directories are created automatically. \
The container is automatically started if not already running."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path for the file inside the container (e.g., '/workspace/main.py')"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"config": {
"type": "object",
"description": "Container configuration (image, working_dir). Usually provided by capability config.",
"properties": {
"image": { "type": "string" },
"working_dir": { "type": "string" }
}
}
},
"required": ["path", "content"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default().with_open_world(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"docker_write_file requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let path = match arguments.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
};
let content = match arguments.get("content").and_then(|v| v.as_str()) {
Some(c) => c,
None => return ToolExecutionResult::tool_error("Missing required parameter: content"),
};
let config = arguments
.get("config")
.map(parse_config)
.unwrap_or_default();
let name = container_name(&context.session_id);
if let Err(e) = ensure_container_running(&name, &config).await {
return ToolExecutionResult::tool_error(e);
}
debug!("Writing file to container {}: {}", name, path);
let parent_dir = std::path::Path::new(path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
let mkdir_output = Command::new("docker")
.args(["exec", &name, "mkdir", "-p", &parent_dir])
.output()
.await;
if let Err(e) = mkdir_output {
warn!("Failed to create parent directory: {}", e);
}
let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, content);
let output = match Command::new("docker")
.args([
"exec",
&name,
"sh",
"-c",
&format!("echo '{}' | base64 -d > '{}'", encoded, path),
])
.output()
.await
{
Ok(o) => o,
Err(e) => {
error!("Failed to write file to container: {}", e);
return ToolExecutionResult::internal_error_msg(format!(
"Failed to write file: {}",
e
));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return ToolExecutionResult::tool_error(format!("Failed to write file: {}", stderr));
}
ToolExecutionResult::success(json!({
"path": path,
"size_bytes": content.len(),
"success": true
}))
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DockerStopTool;
#[async_trait]
impl Tool for DockerStopTool {
fn name(&self) -> &str {
"docker_stop"
}
fn description(&self) -> &str {
"Stop and remove the Docker container associated with this session. \
Use this to clean up resources or reset the container state. \
A new container will be created on the next docker_exec/read/write call."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"force": {
"type": "boolean",
"description": "Force stop (kill) the container if it doesn't stop gracefully (default: false)"
}
},
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_open_world(true)
.with_destructive(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"docker_stop requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let force = arguments
.get("force")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let name = container_name(&context.session_id);
if !container_exists(&name).await {
return ToolExecutionResult::success(json!({
"stopped": false,
"removed": false,
"message": "Container does not exist",
"container_name": name
}));
}
debug!("Stopping container: {}", name);
let stop_args = if force {
vec!["kill", &name]
} else {
vec!["stop", &name]
};
let stop_output = match Command::new("docker").args(&stop_args).output().await {
Ok(o) => o,
Err(e) => {
error!("Failed to stop container: {}", e);
return ToolExecutionResult::internal_error_msg(format!(
"Failed to stop container: {}",
e
));
}
};
let stopped = stop_output.status.success();
if !stopped {
let stderr = String::from_utf8_lossy(&stop_output.stderr);
warn!("Failed to stop container {}: {}", name, stderr);
}
debug!("Removing container: {}", name);
let rm_output = match Command::new("docker")
.args(["rm", "-f", &name])
.output()
.await
{
Ok(o) => o,
Err(e) => {
error!("Failed to remove container: {}", e);
return ToolExecutionResult::internal_error_msg(format!(
"Failed to remove container: {}",
e
));
}
};
let removed = rm_output.status.success();
if !removed {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
warn!("Failed to remove container {}: {}", name, stderr);
}
info!("Container {} stopped and removed", name);
ToolExecutionResult::success(json!({
"stopped": stopped,
"removed": removed,
"container_name": name,
"message": if stopped && removed {
"Container stopped and removed successfully"
} else if removed {
"Container removed (was not running)"
} else {
"Failed to fully clean up container"
}
}))
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DockerLogsTool;
#[async_trait]
impl Tool for DockerLogsTool {
fn name(&self) -> &str {
"docker_logs"
}
fn description(&self) -> &str {
"Get logs from the Docker container. Returns stdout/stderr output from the container. \
Useful for debugging long-running processes or checking application output."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"tail": {
"type": "integer",
"description": "Number of lines to show from the end of the logs (default: 100)"
},
"since": {
"type": "string",
"description": "Show logs since timestamp (e.g., '2024-01-01T00:00:00Z') or relative time (e.g., '10m', '1h')"
},
"timestamps": {
"type": "boolean",
"description": "Show timestamps with each log line (default: false)"
}
},
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(true)
.with_open_world(true)
.with_idempotent(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"docker_logs requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let tail = arguments
.get("tail")
.and_then(|v| v.as_i64())
.unwrap_or(100);
let since = arguments.get("since").and_then(|v| v.as_str());
let timestamps = arguments
.get("timestamps")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let name = container_name(&context.session_id);
if !container_exists(&name).await {
return ToolExecutionResult::tool_error(format!(
"Container '{}' does not exist. Use docker_exec to start it first.",
name
));
}
debug!("Getting logs from container: {}", name);
let mut args = vec!["logs".to_string()];
args.push("--tail".to_string());
args.push(tail.to_string());
if let Some(since_val) = since {
args.push("--since".to_string());
args.push(since_val.to_string());
}
if timestamps {
args.push("--timestamps".to_string());
}
args.push(name.clone());
let output = match Command::new("docker").args(&args).output().await {
Ok(o) => o,
Err(e) => {
error!("Failed to get logs from container: {}", e);
return ToolExecutionResult::internal_error_msg(format!(
"Failed to get logs: {}",
e
));
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined_logs = if stderr.is_empty() {
stdout.clone()
} else if stdout.is_empty() {
stderr.clone()
} else {
format!("{}\n{}", stdout, stderr)
};
ToolExecutionResult::success(json!({
"logs": combined_logs,
"stdout": stdout,
"stderr": stderr,
"container_name": name,
"lines_requested": tail
}))
}
fn requires_context(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_capability_metadata() {
let cap = DockerContainerCapability;
assert_eq!(cap.id(), "docker_container");
assert_eq!(cap.name(), "[Experimental] Docker Container");
assert_eq!(cap.status(), CapabilityStatus::Available);
assert_eq!(cap.icon(), Some("container"));
assert_eq!(cap.category(), Some("Sandboxes"));
}
#[test]
fn test_capability_has_all_tools() {
let cap = DockerContainerCapability;
let tools = cap.tools();
assert_eq!(tools.len(), 5);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"docker_exec"));
assert!(names.contains(&"docker_read_file"));
assert!(names.contains(&"docker_write_file"));
assert!(names.contains(&"docker_logs"));
assert!(names.contains(&"docker_stop"));
}
#[test]
fn test_capability_has_system_prompt() {
let cap = DockerContainerCapability;
let prompt = cap.system_prompt_addition().unwrap();
assert!(prompt.contains("lazily-started Docker container"));
assert!(prompt.contains("host networking"));
assert!(prompt.contains("/workspace"));
assert!(prompt.contains("stopping removes/resets it"));
}
#[tokio::test]
async fn system_prompt_within_budget() {
let cap = DockerContainerCapability;
let ctx = everruns_core::capabilities::SystemPromptContext::without_file_store(
everruns_core::SessionId::new(),
);
let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
assert!(prompt.len() <= 1150, "prompt is {} bytes", prompt.len());
}
#[test]
fn test_all_tools_require_context() {
let cap = DockerContainerCapability;
for tool in cap.tools() {
assert!(
tool.requires_context(),
"Tool {} should require context",
tool.name()
);
}
}
#[test]
fn localizations_cover_schema_summary_and_uk_name() {
let cap = DockerContainerCapability;
assert!(cap.describe_schema(None).is_some());
assert_ne!(cap.localized_name(Some("uk-UA")), cap.name());
}
#[test]
fn test_config_default() {
let config = DockerContainerConfig::default();
assert_eq!(config.image, DEFAULT_IMAGE);
assert_eq!(config.working_dir, DEFAULT_WORKING_DIR);
}
#[test]
fn test_config_parse() {
let json = json!({
"image": "ubuntu:22.04",
"working_dir": "/app"
});
let config = parse_config(&json);
assert_eq!(config.image, "ubuntu:22.04");
assert_eq!(config.working_dir, "/app");
}
#[test]
fn test_config_parse_partial() {
let json = json!({
"image": "node:18"
});
let config = parse_config(&json);
assert_eq!(config.image, "node:18");
assert_eq!(config.working_dir, DEFAULT_WORKING_DIR);
}
#[test]
fn test_container_name() {
let uuid = uuid::Uuid::parse_str("12345678-1234-1234-1234-123456789012").unwrap();
let session_id = everruns_core::typed_id::SessionId::from_uuid(uuid);
let name = container_name(&session_id);
assert_eq!(name, "everruns-12345678-1234-1234-1234-123456789012");
}
#[tokio::test]
async fn test_docker_exec_without_context() {
let tool = DockerExecTool;
let result = tool.execute(json!({"command": "echo hello"})).await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("requires context"));
}
_ => panic!("Expected tool error"),
}
}
#[tokio::test]
async fn test_docker_read_file_without_context() {
let tool = DockerReadFileTool;
let result = tool.execute(json!({"path": "/test.txt"})).await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("requires context"));
}
_ => panic!("Expected tool error"),
}
}
#[tokio::test]
async fn test_docker_write_file_without_context() {
let tool = DockerWriteFileTool;
let result = tool
.execute(json!({"path": "/test.txt", "content": "hello"}))
.await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("requires context"));
}
_ => panic!("Expected tool error"),
}
}
#[tokio::test]
async fn test_docker_stop_without_context() {
let tool = DockerStopTool;
let result = tool.execute(json!({})).await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("requires context"));
}
_ => panic!("Expected tool error"),
}
}
#[tokio::test]
async fn test_docker_logs_without_context() {
let tool = DockerLogsTool;
let result = tool.execute(json!({})).await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("requires context"));
}
_ => panic!("Expected tool error"),
}
}
#[tokio::test]
async fn test_docker_exec_missing_command() {
let tool = DockerExecTool;
let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
uuid::Uuid::nil(),
));
let result = tool.execute_with_context(json!({}), &context).await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("Missing required parameter"));
}
_ => panic!("Expected tool error for missing command"),
}
}
#[tokio::test]
async fn test_docker_read_file_missing_path() {
let tool = DockerReadFileTool;
let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
uuid::Uuid::nil(),
));
let result = tool.execute_with_context(json!({}), &context).await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("Missing required parameter"));
}
_ => panic!("Expected tool error for missing path"),
}
}
#[tokio::test]
async fn test_docker_write_file_missing_params() {
let tool = DockerWriteFileTool;
let context = ToolContext::new(everruns_core::typed_id::SessionId::from_uuid(
uuid::Uuid::nil(),
));
let result = tool
.execute_with_context(json!({"content": "hello"}), &context)
.await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("Missing required parameter"));
}
_ => panic!("Expected tool error for missing path"),
}
let result = tool
.execute_with_context(json!({"path": "/test.txt"}), &context)
.await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(msg.contains("Missing required parameter"));
}
_ => panic!("Expected tool error for missing content"),
}
}
}