Skip to main content

adk_code/
container.rs

1//! Container code executor — persistent Docker-backed execution environment.
2//!
3//! [`DockerExecutor`] manages a persistent Docker container that survives across
4//! multiple [`execute`](CodeExecutor::execute) calls. Code is written to files
5//! inside the container's working directory and executed via `docker exec`,
6//! matching the lifecycle model of AutoGen's `DockerCommandLineCodeExecutor`.
7//!
8//! # Lifecycle
9//!
10//! ```text
11//! DockerExecutor::new(config)
12//!     │
13//!     ▼
14//!   start()  ──► creates & starts container, runs setup commands
15//!     │
16//!     ▼
17//!   execute() ──► writes code to file, exec's inside running container
18//!   execute() ──► reuses same container, accumulates workspace state
19//!   execute() ──► ...
20//!     │
21//!     ▼
22//!   stop()   ──► stops & removes container
23//! ```
24//!
25//! # Isolation Model
26//!
27//! | Capability | Enforced | Mechanism |
28//! |---|---|---|
29//! | Network policy | Yes | `--network=none` on container create |
30//! | Filesystem policy | Yes | Explicit bind mounts only |
31//! | Environment policy | Yes | Only specified env vars |
32//! | Timeout | Yes | `tokio::time::timeout` on exec |
33//! | Structured output | Yes | Last-line JSON extraction from stdout |
34//! | Persistent workspace | Yes | Container survives across executions |
35//! | Interactive sessions | No | Each execute is a separate exec |
36//!
37//! # Example
38//!
39//! ```rust,ignore
40//! # async fn example() -> Result<(), adk_code::ExecutionError> {
41//! use adk_code::{
42//!     CodeExecutor, DockerExecutor, DockerConfig,
43//!     ExecutionLanguage, ExecutionPayload, ExecutionRequest, SandboxPolicy,
44//! };
45//!
46//! let executor = DockerExecutor::new(DockerConfig::python());
47//! executor.start().await?;
48//!
49//! let result = executor.execute(ExecutionRequest {
50//!     language: ExecutionLanguage::Python,
51//!     payload: ExecutionPayload::Source {
52//!         code: "print('hello from persistent container')".to_string(),
53//!     },
54//!     argv: vec![],
55//!     stdin: None,
56//!     input: None,
57//!     sandbox: SandboxPolicy::strict_rust(),
58//!     identity: None,
59//! }).await?;
60//!
61//! // Container is still running — next execute reuses it
62//! executor.stop().await?;
63//! # Ok(())
64//! # }
65//! ```
66
67use std::time::Instant;
68
69use async_trait::async_trait;
70use tracing::{debug, info, warn};
71
72use crate::{
73    BackendCapabilities, CodeExecutor, EnvironmentPolicy, ExecutionError, ExecutionIsolation,
74    ExecutionLanguage, ExecutionPayload, ExecutionRequest, ExecutionResult, ExecutionStatus,
75    FilesystemPolicy, NetworkPolicy, validate_request,
76};
77
78/// Configuration for the Docker executor.
79///
80/// # Example
81///
82/// ```rust
83/// use adk_code::DockerConfig;
84///
85/// let config = DockerConfig::python();
86/// assert_eq!(config.image, "python:3.12-slim");
87/// assert_eq!(config.work_dir, "/workspace");
88/// ```
89#[derive(Debug, Clone)]
90pub struct DockerConfig {
91    /// Container image to use.
92    pub image: String,
93    /// Working directory inside the container.
94    pub work_dir: String,
95    /// Setup commands to run after container creation (e.g., `pip install`).
96    pub setup_commands: Vec<String>,
97    /// Extra environment variables to set in the container.
98    pub environment: Vec<String>,
99    /// Bind mounts in `host:container[:ro]` format.
100    pub bind_mounts: Vec<String>,
101    /// Whether to disable network access.
102    pub network_disabled: bool,
103    /// Container name prefix (a random suffix is appended).
104    pub container_name_prefix: String,
105    /// Whether to auto-start on first execute if not already running.
106    pub auto_start: bool,
107    /// Whether to auto-stop and remove the container on drop.
108    pub auto_remove: bool,
109}
110
111impl DockerConfig {
112    /// Python 3.12 preset.
113    pub fn python() -> Self {
114        Self {
115            image: "python:3.12-slim".to_string(),
116            work_dir: "/workspace".to_string(),
117            setup_commands: vec![],
118            environment: vec![],
119            bind_mounts: vec![],
120            network_disabled: true,
121            container_name_prefix: "adk-python".to_string(),
122            auto_start: true,
123            auto_remove: true,
124        }
125    }
126
127    /// Node.js 20 preset.
128    pub fn node() -> Self {
129        Self {
130            image: "node:20-slim".to_string(),
131            work_dir: "/workspace".to_string(),
132            setup_commands: vec![],
133            environment: vec![],
134            bind_mounts: vec![],
135            network_disabled: true,
136            container_name_prefix: "adk-node".to_string(),
137            auto_start: true,
138            auto_remove: true,
139        }
140    }
141
142    /// Custom image preset.
143    pub fn custom(image: impl Into<String>) -> Self {
144        Self {
145            image: image.into(),
146            work_dir: "/workspace".to_string(),
147            setup_commands: vec![],
148            environment: vec![],
149            bind_mounts: vec![],
150            network_disabled: true,
151            container_name_prefix: "adk-custom".to_string(),
152            auto_start: true,
153            auto_remove: true,
154        }
155    }
156
157    /// Add a setup command to run after container creation.
158    pub fn setup_command(mut self, cmd: impl Into<String>) -> Self {
159        self.setup_commands.push(cmd.into());
160        self
161    }
162
163    /// Add a pip install command for Python dependencies.
164    pub fn pip_install(self, packages: &[&str]) -> Self {
165        self.setup_command(format!("pip install --quiet {}", packages.join(" ")))
166    }
167
168    /// Add an npm install command for Node.js dependencies.
169    pub fn npm_install(self, packages: &[&str]) -> Self {
170        self.setup_command(format!("npm install --silent {}", packages.join(" ")))
171    }
172
173    /// Enable network access (disabled by default).
174    pub fn with_network(mut self) -> Self {
175        self.network_disabled = false;
176        self
177    }
178
179    /// Add a bind mount.
180    pub fn bind_mount(mut self, mount: impl Into<String>) -> Self {
181        self.bind_mounts.push(mount.into());
182        self
183    }
184
185    /// Add an environment variable.
186    pub fn env(mut self, var: impl Into<String>) -> Self {
187        self.environment.push(var.into());
188        self
189    }
190}
191
192impl Default for DockerConfig {
193    fn default() -> Self {
194        Self::python()
195    }
196}
197
198// ── Docker SDK implementation (behind `docker` feature) ────────────────
199
200#[cfg(feature = "docker")]
201mod docker_impl {
202    use super::*;
203    use bollard::Docker;
204    use bollard::container::{
205        Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
206    };
207    use bollard::exec::{CreateExecOptions, StartExecResults};
208    use futures::StreamExt;
209    use rand::Rng;
210    use tokio::sync::RwLock;
211
212    /// State of the managed container.
213    #[derive(Debug)]
214    struct ContainerState {
215        /// Docker container ID.
216        id: String,
217        /// Whether the container is currently running.
218        running: bool,
219        /// Counter for generating unique filenames.
220        file_counter: u64,
221    }
222
223    /// Persistent Docker-backed code execution environment.
224    ///
225    /// Creates a container once via [`start`](CodeExecutor::start), keeps it running,
226    /// and executes code via `docker exec` inside the running container. This matches
227    /// the lifecycle model of AutoGen's `DockerCommandLineCodeExecutor`.
228    ///
229    /// # Cleanup
230    ///
231    /// Call [`cleanup()`](Self::cleanup) explicitly before dropping to ensure
232    /// reliable container removal. The [`Drop`] implementation is best-effort
233    /// and requires a tokio runtime to be available.
234    ///
235    /// # Example
236    ///
237    /// ```rust,no_run
238    /// # async fn example() -> Result<(), adk_code::ExecutionError> {
239    /// use adk_code::{CodeExecutor, DockerExecutor, DockerConfig};
240    ///
241    /// let executor = DockerExecutor::new(DockerConfig::python());
242    /// executor.start().await?;
243    /// assert!(executor.is_running().await);
244    ///
245    /// // Prefer explicit cleanup over relying on Drop
246    /// executor.cleanup().await?;
247    /// # Ok(())
248    /// # }
249    /// ```
250    pub struct DockerExecutor {
251        config: DockerConfig,
252        docker: Docker,
253        state: RwLock<Option<ContainerState>>,
254    }
255
256    impl std::fmt::Debug for DockerExecutor {
257        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258            f.debug_struct("DockerExecutor").field("config", &self.config).finish()
259        }
260    }
261
262    impl DockerExecutor {
263        /// Create a new Docker executor with the given configuration.
264        ///
265        /// This does NOT start the container — call [`start`](CodeExecutor::start)
266        /// or set `auto_start: true` (the default) to have it start on first execute.
267        pub fn new(config: DockerConfig) -> Self {
268            let docker =
269                Docker::connect_with_local_defaults().expect("failed to connect to Docker daemon");
270            Self { config, docker, state: RwLock::new(None) }
271        }
272
273        /// Create with a custom Docker connection.
274        pub fn with_docker(config: DockerConfig, docker: Docker) -> Self {
275            Self { config, docker, state: RwLock::new(None) }
276        }
277
278        /// Explicitly stop and remove the Docker container.
279        ///
280        /// Prefer calling this method before dropping the executor to ensure
281        /// reliable cleanup. The [`Drop`] implementation is best-effort and may
282        /// not work if no tokio runtime is available.
283        pub async fn cleanup(&self) -> Result<(), ExecutionError> {
284            let mut state = self.state.write().await;
285            if let Some(s) = state.take() {
286                info!(container_id = %s.id, "cleaning up container");
287                self.docker
288                    .remove_container(
289                        &s.id,
290                        Some(RemoveContainerOptions { force: true, ..Default::default() }),
291                    )
292                    .await
293                    .map_err(|e| {
294                        ExecutionError::ExecutionFailed(format!("failed to remove container: {e}"))
295                    })?;
296            }
297            Ok(())
298        }
299
300        /// Generate a unique container name.
301        fn container_name(&self) -> String {
302            let suffix: u32 = rand::rng().random_range(100_000..999_999);
303            format!("{}-{suffix}", self.config.container_name_prefix)
304        }
305
306        /// Get the file extension for a language.
307        fn file_extension(lang: &ExecutionLanguage) -> &'static str {
308            match lang {
309                ExecutionLanguage::Python => "py",
310                ExecutionLanguage::JavaScript => "js",
311                ExecutionLanguage::Rust => "rs",
312                ExecutionLanguage::Command => "sh",
313                ExecutionLanguage::Wasm => "wasm",
314            }
315        }
316
317        /// Get the execution command for a language and filename.
318        fn exec_command(lang: &ExecutionLanguage, filename: &str) -> Vec<String> {
319            match lang {
320                ExecutionLanguage::Python => {
321                    vec!["python3".to_string(), filename.to_string()]
322                }
323                ExecutionLanguage::JavaScript => {
324                    vec!["node".to_string(), filename.to_string()]
325                }
326                ExecutionLanguage::Command => {
327                    vec!["sh".to_string(), filename.to_string()]
328                }
329                _ => vec![],
330            }
331        }
332
333        /// Write a file inside the running container.
334        async fn write_file(
335            &self,
336            container_id: &str,
337            path: &str,
338            content: &str,
339        ) -> Result<(), ExecutionError> {
340            // Use a heredoc-style approach to write file content safely.
341            // Base64 encode to avoid shell escaping issues.
342            let encoded = base64_encode(content.as_bytes());
343            let cmd = vec![
344                "sh".to_string(),
345                "-c".to_string(),
346                format!("echo '{encoded}' | base64 -d > {path}"),
347            ];
348            self.exec_in_container(container_id, &cmd, None).await?;
349            Ok(())
350        }
351
352        /// Execute a command inside the running container and capture output.
353        async fn exec_in_container(
354            &self,
355            container_id: &str,
356            cmd: &[String],
357            timeout: Option<std::time::Duration>,
358        ) -> Result<(String, String, Option<i64>), ExecutionError> {
359            let exec = self
360                .docker
361                .create_exec(
362                    container_id,
363                    CreateExecOptions {
364                        cmd: Some(cmd.to_vec()),
365                        attach_stdout: Some(true),
366                        attach_stderr: Some(true),
367                        working_dir: Some(self.config.work_dir.clone()),
368                        ..Default::default()
369                    },
370                )
371                .await
372                .map_err(|e| {
373                    ExecutionError::ExecutionFailed(format!("failed to create exec: {e}"))
374                })?;
375
376            let exec_output = async {
377                match self.docker.start_exec(&exec.id, None).await {
378                    Ok(StartExecResults::Attached { mut output, .. }) => {
379                        let mut stdout = String::new();
380                        let mut stderr = String::new();
381
382                        while let Some(chunk) = output.next().await {
383                            match chunk {
384                                Ok(bollard::container::LogOutput::StdOut { message }) => {
385                                    stdout.push_str(&String::from_utf8_lossy(&message));
386                                }
387                                Ok(bollard::container::LogOutput::StdErr { message }) => {
388                                    stderr.push_str(&String::from_utf8_lossy(&message));
389                                }
390                                Ok(_) => {}
391                                Err(e) => {
392                                    return Err(ExecutionError::ExecutionFailed(format!(
393                                        "exec stream error: {e}"
394                                    )));
395                                }
396                            }
397                        }
398
399                        // Get exit code.
400                        let inspect = self.docker.inspect_exec(&exec.id).await.map_err(|e| {
401                            ExecutionError::ExecutionFailed(format!("failed to inspect exec: {e}"))
402                        })?;
403                        let exit_code = inspect.exit_code;
404
405                        Ok((stdout, stderr, exit_code))
406                    }
407                    Ok(StartExecResults::Detached) => Ok((String::new(), String::new(), None)),
408                    Err(e) => {
409                        Err(ExecutionError::ExecutionFailed(format!("failed to start exec: {e}")))
410                    }
411                }
412            };
413
414            if let Some(dur) = timeout {
415                match tokio::time::timeout(dur, exec_output).await {
416                    Ok(result) => result,
417                    Err(_) => Err(ExecutionError::Timeout(dur.as_millis() as u64)),
418                }
419            } else {
420                exec_output.await
421            }
422        }
423    }
424
425    #[async_trait]
426    impl CodeExecutor for DockerExecutor {
427        fn name(&self) -> &str {
428            "docker"
429        }
430
431        fn capabilities(&self) -> BackendCapabilities {
432            BackendCapabilities {
433                isolation: ExecutionIsolation::ContainerPersistent,
434                enforce_network_policy: true,
435                enforce_filesystem_policy: true,
436                enforce_environment_policy: true,
437                enforce_timeout: true,
438                supports_structured_output: true,
439                supports_process_execution: true,
440                supports_persistent_workspace: true,
441                supports_interactive_sessions: false,
442            }
443        }
444
445        fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
446            matches!(
447                lang,
448                ExecutionLanguage::Python
449                    | ExecutionLanguage::JavaScript
450                    | ExecutionLanguage::Command
451            )
452        }
453
454        async fn start(&self) -> Result<(), ExecutionError> {
455            let mut state = self.state.write().await;
456            if state.as_ref().is_some_and(|s| s.running) {
457                return Ok(());
458            }
459
460            let name = self.container_name();
461            info!(image = %self.config.image, container = %name, "creating container");
462
463            // Build container config.
464            let mut host_config = bollard::models::HostConfig::default();
465
466            if self.config.network_disabled {
467                host_config.network_mode = Some("none".to_string());
468            }
469
470            if !self.config.bind_mounts.is_empty() {
471                host_config.binds = Some(self.config.bind_mounts.clone());
472            }
473
474            let env = if self.config.environment.is_empty() {
475                None
476            } else {
477                Some(self.config.environment.clone())
478            };
479
480            let container_config = Config {
481                image: Some(self.config.image.clone()),
482                working_dir: Some(self.config.work_dir.clone()),
483                env,
484                host_config: Some(host_config),
485                // Keep container alive with a long sleep.
486                cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]),
487                tty: Some(false),
488                ..Default::default()
489            };
490
491            let create_opts = CreateContainerOptions { name: name.clone(), ..Default::default() };
492
493            let response =
494                self.docker.create_container(Some(create_opts), container_config).await.map_err(
495                    |e| ExecutionError::ExecutionFailed(format!("failed to create container: {e}")),
496                )?;
497
498            let container_id = response.id;
499            debug!(container_id = %container_id, "container created");
500
501            // Start the container.
502            self.docker
503                .start_container(&container_id, None::<StartContainerOptions<String>>)
504                .await
505                .map_err(|e| {
506                    ExecutionError::ExecutionFailed(format!("failed to start container: {e}"))
507                })?;
508
509            info!(container_id = %container_id, "container started");
510
511            // Create workspace directory.
512            let mkdir_cmd =
513                vec!["mkdir".to_string(), "-p".to_string(), self.config.work_dir.clone()];
514            let _ = self.exec_in_container(&container_id, &mkdir_cmd, None).await;
515
516            // Run setup commands.
517            for setup_cmd in &self.config.setup_commands {
518                info!(cmd = %setup_cmd, "running setup command");
519                let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd.clone()];
520                let (_stdout, stderr, exit_code) =
521                    self.exec_in_container(&container_id, &cmd, None).await?;
522
523                if exit_code != Some(0) {
524                    warn!(
525                        exit_code = ?exit_code,
526                        stderr = %stderr,
527                        "setup command failed"
528                    );
529                    // Clean up on setup failure.
530                    let _ = self
531                        .docker
532                        .remove_container(
533                            &container_id,
534                            Some(RemoveContainerOptions { force: true, ..Default::default() }),
535                        )
536                        .await;
537                    return Err(ExecutionError::ExecutionFailed(format!(
538                        "setup command failed: {setup_cmd}\nstderr: {stderr}"
539                    )));
540                }
541            }
542
543            *state = Some(ContainerState { id: container_id, running: true, file_counter: 0 });
544
545            Ok(())
546        }
547
548        async fn stop(&self) -> Result<(), ExecutionError> {
549            let mut state = self.state.write().await;
550            if let Some(s) = state.take() {
551                info!(container_id = %s.id, "stopping container");
552                let _ = self
553                    .docker
554                    .remove_container(
555                        &s.id,
556                        Some(RemoveContainerOptions { force: true, ..Default::default() }),
557                    )
558                    .await;
559            }
560            Ok(())
561        }
562
563        async fn is_running(&self) -> bool {
564            self.state.read().await.as_ref().is_some_and(|s| s.running)
565        }
566
567        async fn execute(
568            &self,
569            request: ExecutionRequest,
570        ) -> Result<ExecutionResult, ExecutionError> {
571            let supported = [
572                ExecutionLanguage::Python,
573                ExecutionLanguage::JavaScript,
574                ExecutionLanguage::Command,
575            ];
576            validate_request(&self.capabilities(), &supported, &request)?;
577
578            let code = match &request.payload {
579                ExecutionPayload::Source { code } if code.trim().is_empty() => {
580                    return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
581                }
582                ExecutionPayload::Source { code } => code.clone(),
583                ExecutionPayload::GuestModule { .. } => {
584                    return Err(ExecutionError::InvalidRequest(
585                        "DockerExecutor does not support guest modules".to_string(),
586                    ));
587                }
588            };
589
590            // Auto-start if configured and not running.
591            if self.config.auto_start && !self.is_running().await {
592                self.start().await?;
593            }
594
595            // Get container ID and increment file counter.
596            let (container_id, filename) = {
597                let mut state = self.state.write().await;
598                let s = state.as_mut().ok_or_else(|| {
599                    ExecutionError::ExecutionFailed(
600                        "container not started — call start() first".to_string(),
601                    )
602                })?;
603                s.file_counter += 1;
604                let ext = Self::file_extension(&request.language);
605                let filename = format!("{}/code_{}.{ext}", self.config.work_dir, s.file_counter);
606                (s.id.clone(), filename)
607            };
608
609            let start = Instant::now();
610
611            // Write code to file inside container.
612            self.write_file(&container_id, &filename, &code).await?;
613
614            // If there's structured input, write it as a JSON file.
615            if let Some(ref input) = request.input {
616                let input_json = serde_json::to_string(input).unwrap_or_default();
617                let input_path = format!("{}/input.json", self.config.work_dir);
618                self.write_file(&container_id, &input_path, &input_json).await?;
619            }
620
621            // Build execution command.
622            let exec_cmd = Self::exec_command(&request.language, &filename);
623            if exec_cmd.is_empty() {
624                return Err(ExecutionError::UnsupportedLanguage(format!("{}", request.language)));
625            }
626
627            debug!(
628                container_id = %container_id,
629                language = %request.language,
630                filename = %filename,
631                "executing code in container"
632            );
633
634            // Execute with timeout.
635            let (stdout, stderr, exit_code) = self
636                .exec_in_container(&container_id, &exec_cmd, Some(request.sandbox.timeout))
637                .await
638                .map_err(|e| match e {
639                    ExecutionError::Timeout(_) => e,
640                    other => other,
641                })?;
642
643            let duration_ms = start.elapsed().as_millis() as u64;
644
645            let (stdout, stdout_truncated) =
646                truncate_output(stdout, request.sandbox.max_stdout_bytes);
647            let (stderr, stderr_truncated) =
648                truncate_output(stderr, request.sandbox.max_stderr_bytes);
649
650            let (structured_output, display_stdout) = extract_structured_output(&stdout);
651
652            let status = match exit_code {
653                Some(0) => ExecutionStatus::Success,
654                _ => ExecutionStatus::Failed,
655            };
656
657            info!(
658                exit_code = ?exit_code,
659                duration_ms,
660                has_structured_output = structured_output.is_some(),
661                "container execution completed"
662            );
663
664            Ok(ExecutionResult {
665                status,
666                stdout: display_stdout,
667                stderr,
668                output: structured_output,
669                exit_code: exit_code.map(|c| c as i32),
670                stdout_truncated,
671                stderr_truncated,
672                duration_ms,
673                metadata: None,
674            })
675        }
676    }
677
678    impl Drop for DockerExecutor {
679        fn drop(&mut self) {
680            if self.config.auto_remove {
681                // Best-effort cleanup — we can't await in drop, so spawn a task
682                // only if a tokio runtime is available.
683                if let Some(state) = self.state.get_mut().take() {
684                    let docker = self.docker.clone();
685                    let container_id = state.id;
686                    match tokio::runtime::Handle::try_current() {
687                        Ok(handle) => {
688                            handle.spawn(async move {
689                                let _ = docker
690                                    .remove_container(
691                                        &container_id,
692                                        Some(RemoveContainerOptions {
693                                            force: true,
694                                            ..Default::default()
695                                        }),
696                                    )
697                                    .await;
698                            });
699                        }
700                        Err(_) => {
701                            tracing::warn!(
702                                container_id = %container_id,
703                                "no tokio runtime available during DockerExecutor drop, \
704                                 container may leak. Call cleanup() explicitly before dropping."
705                            );
706                        }
707                    }
708                }
709            }
710        }
711    }
712
713    /// Simple base64 encoder (no padding issues with shell).
714    fn base64_encode(data: &[u8]) -> String {
715        const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
716        let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
717        for chunk in data.chunks(3) {
718            let b0 = chunk[0] as u32;
719            let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
720            let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
721            let triple = (b0 << 16) | (b1 << 8) | b2;
722            result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
723            result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
724            if chunk.len() > 1 {
725                result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
726            } else {
727                result.push('=');
728            }
729            if chunk.len() > 2 {
730                result.push(CHARS[(triple & 0x3F) as usize] as char);
731            } else {
732                result.push('=');
733            }
734        }
735        result
736    }
737}
738
739#[cfg(feature = "docker")]
740pub use docker_impl::DockerExecutor;
741
742// ── CLI-based fallback (always available) ──────────────────────────────
743
744/// Configuration for the CLI-based container command executor.
745///
746/// This is the fallback executor that shells out to `docker run` for each
747/// execution. For production use, prefer [`DockerExecutor`] (behind the
748/// `docker` feature) which uses persistent containers.
749///
750/// # Example
751///
752/// ```rust
753/// use adk_code::ContainerConfig;
754///
755/// let config = ContainerConfig::default();
756/// assert_eq!(config.runtime, "docker");
757/// ```
758#[derive(Debug, Clone)]
759pub struct ContainerConfig {
760    /// Container runtime binary (e.g., `"docker"`, `"podman"`).
761    pub runtime: String,
762    /// Default container image when not overridden per-request.
763    pub default_image: String,
764    /// Extra flags passed to the container runtime `run` command.
765    pub extra_flags: Vec<String>,
766    /// Whether to automatically remove the container after execution.
767    pub auto_remove: bool,
768}
769
770impl Default for ContainerConfig {
771    fn default() -> Self {
772        Self {
773            runtime: "docker".to_string(),
774            default_image: "python:3.12-slim".to_string(),
775            extra_flags: vec![],
776            auto_remove: true,
777        }
778    }
779}
780
781/// CLI-based container executor that shells out to `docker run` per execution.
782///
783/// Each [`execute`](CodeExecutor::execute) call spawns a new ephemeral container.
784/// This is simpler but less efficient than [`DockerExecutor`] which reuses a
785/// persistent container.
786///
787/// For production use, prefer [`DockerExecutor`] (behind the `docker` feature).
788///
789/// # Example
790///
791/// ```rust
792/// use adk_code::{CodeExecutor, ContainerCommandExecutor, ContainerConfig, ExecutionIsolation};
793///
794/// let executor = ContainerCommandExecutor::default();
795/// assert_eq!(executor.name(), "container-command");
796/// assert_eq!(executor.capabilities().isolation, ExecutionIsolation::ContainerEphemeral);
797/// ```
798#[derive(Debug, Clone)]
799pub struct ContainerCommandExecutor {
800    config: ContainerConfig,
801}
802
803impl ContainerCommandExecutor {
804    /// Create a new container command executor with the given configuration.
805    pub fn new(config: ContainerConfig) -> Self {
806        Self { config }
807    }
808
809    /// Build the container `run` command arguments for a given request.
810    fn build_run_args(&self, request: &ExecutionRequest) -> Vec<String> {
811        let mut args = vec!["run".to_string()];
812
813        if self.config.auto_remove {
814            args.push("--rm".to_string());
815        }
816
817        args.push("-i".to_string());
818
819        match request.sandbox.network {
820            NetworkPolicy::Disabled => {
821                args.push("--network=none".to_string());
822            }
823            NetworkPolicy::Enabled => {}
824        }
825
826        match &request.sandbox.filesystem {
827            FilesystemPolicy::None => {}
828            FilesystemPolicy::WorkspaceReadOnly { root } => {
829                args.push("-v".to_string());
830                args.push(format!("{}:/workspace:ro", root.display()));
831            }
832            FilesystemPolicy::WorkspaceReadWrite { root } => {
833                args.push("-v".to_string());
834                args.push(format!("{}:/workspace:rw", root.display()));
835            }
836            FilesystemPolicy::Paths { read_only, read_write } => {
837                for path in read_only {
838                    args.push("-v".to_string());
839                    args.push(format!("{}:{}:ro", path.display(), path.display()));
840                }
841                for path in read_write {
842                    args.push("-v".to_string());
843                    args.push(format!("{}:{}:rw", path.display(), path.display()));
844                }
845            }
846        }
847
848        if let EnvironmentPolicy::AllowList(vars) = &request.sandbox.environment {
849            for var in vars {
850                args.push("--env".to_string());
851                args.push(var.clone());
852            }
853        }
854
855        if let Some(ref wd) = request.sandbox.working_directory {
856            args.push("-w".to_string());
857            args.push(wd.display().to_string());
858        }
859
860        args.extend(self.config.extra_flags.clone());
861        args.push(self.config.default_image.clone());
862
863        let code = match &request.payload {
864            ExecutionPayload::Source { code } => code.clone(),
865            ExecutionPayload::GuestModule { .. } => String::new(),
866        };
867
868        match request.language {
869            ExecutionLanguage::Python => {
870                args.push("python3".to_string());
871                args.push("-c".to_string());
872                args.push(code);
873            }
874            ExecutionLanguage::JavaScript => {
875                args.push("node".to_string());
876                args.push("-e".to_string());
877                args.push(code);
878            }
879            ExecutionLanguage::Command => {
880                args.push("sh".to_string());
881                args.push("-c".to_string());
882                args.push(code);
883            }
884            _ => {}
885        }
886
887        args.extend(request.argv.clone());
888        args
889    }
890}
891
892impl Default for ContainerCommandExecutor {
893    fn default() -> Self {
894        Self::new(ContainerConfig::default())
895    }
896}
897
898#[async_trait]
899impl CodeExecutor for ContainerCommandExecutor {
900    fn name(&self) -> &str {
901        "container-command"
902    }
903
904    fn capabilities(&self) -> BackendCapabilities {
905        BackendCapabilities {
906            isolation: ExecutionIsolation::ContainerEphemeral,
907            enforce_network_policy: true,
908            enforce_filesystem_policy: true,
909            enforce_environment_policy: true,
910            enforce_timeout: true,
911            supports_structured_output: true,
912            supports_process_execution: true,
913            supports_persistent_workspace: false,
914            supports_interactive_sessions: false,
915        }
916    }
917
918    fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
919        matches!(
920            lang,
921            ExecutionLanguage::Python | ExecutionLanguage::JavaScript | ExecutionLanguage::Command
922        )
923    }
924
925    async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
926        let supported =
927            [ExecutionLanguage::Python, ExecutionLanguage::JavaScript, ExecutionLanguage::Command];
928        validate_request(&self.capabilities(), &supported, &request)?;
929
930        match &request.payload {
931            ExecutionPayload::Source { code } if code.trim().is_empty() => {
932                return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
933            }
934            ExecutionPayload::Source { .. } => {}
935            ExecutionPayload::GuestModule { .. } => {
936                return Err(ExecutionError::InvalidRequest(
937                    "ContainerCommandExecutor does not support guest modules".to_string(),
938                ));
939            }
940        }
941
942        let start = Instant::now();
943        let run_args = self.build_run_args(&request);
944
945        debug!(
946            runtime = %self.config.runtime,
947            image = %self.config.default_image,
948            language = %request.language,
949            "starting container execution"
950        );
951
952        let mut cmd = tokio::process::Command::new(&self.config.runtime);
953        for arg in &run_args {
954            cmd.arg(arg);
955        }
956
957        cmd.stdin(std::process::Stdio::piped());
958        cmd.stdout(std::process::Stdio::piped());
959        cmd.stderr(std::process::Stdio::piped());
960        cmd.kill_on_drop(true);
961
962        let mut child = cmd.spawn().map_err(|e| {
963            ExecutionError::ExecutionFailed(format!(
964                "failed to spawn container runtime '{}': {e}",
965                self.config.runtime
966            ))
967        })?;
968
969        if let Some(ref input) = request.input {
970            if let Some(mut stdin) = child.stdin.take() {
971                use tokio::io::AsyncWriteExt;
972                let json_bytes = serde_json::to_vec(input).unwrap_or_default();
973                let _ = stdin.write_all(&json_bytes).await;
974                drop(stdin);
975            }
976        } else if let Some(ref raw_stdin) = request.stdin {
977            if let Some(mut stdin) = child.stdin.take() {
978                use tokio::io::AsyncWriteExt;
979                let _ = stdin.write_all(raw_stdin).await;
980                drop(stdin);
981            }
982        } else {
983            drop(child.stdin.take());
984        }
985
986        let output =
987            match tokio::time::timeout(request.sandbox.timeout, child.wait_with_output()).await {
988                Ok(Ok(output)) => output,
989                Ok(Err(e)) => {
990                    return Err(ExecutionError::ExecutionFailed(format!(
991                        "failed to wait for container: {e}"
992                    )));
993                }
994                Err(_) => {
995                    warn!("container execution timed out");
996                    let duration_ms = start.elapsed().as_millis() as u64;
997                    return Ok(ExecutionResult {
998                        status: ExecutionStatus::Timeout,
999                        stdout: String::new(),
1000                        stderr: String::new(),
1001                        output: None,
1002                        exit_code: None,
1003                        stdout_truncated: false,
1004                        stderr_truncated: false,
1005                        duration_ms,
1006                        metadata: None,
1007                    });
1008                }
1009            };
1010
1011        let duration_ms = start.elapsed().as_millis() as u64;
1012
1013        let raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
1014        let raw_stderr = String::from_utf8_lossy(&output.stderr).to_string();
1015
1016        let (stdout, stdout_truncated) =
1017            truncate_output(raw_stdout, request.sandbox.max_stdout_bytes);
1018        let (stderr, stderr_truncated) =
1019            truncate_output(raw_stderr, request.sandbox.max_stderr_bytes);
1020
1021        let (structured_output, display_stdout) = extract_structured_output(&stdout);
1022
1023        let status = if output.status.success() {
1024            ExecutionStatus::Success
1025        } else {
1026            ExecutionStatus::Failed
1027        };
1028
1029        info!(
1030            exit_code = output.status.code(),
1031            duration_ms,
1032            has_structured_output = structured_output.is_some(),
1033            "container execution completed"
1034        );
1035
1036        Ok(ExecutionResult {
1037            status,
1038            stdout: display_stdout,
1039            stderr,
1040            output: structured_output,
1041            exit_code: output.status.code(),
1042            stdout_truncated,
1043            stderr_truncated,
1044            duration_ms,
1045            metadata: None,
1046        })
1047    }
1048}
1049
1050// ── Shared helpers ─────────────────────────────────────────────────────
1051
1052/// Truncate output to the given byte limit.
1053fn truncate_output(output: String, max_bytes: usize) -> (String, bool) {
1054    if output.len() <= max_bytes {
1055        (output, false)
1056    } else {
1057        let truncated = output
1058            .char_indices()
1059            .take_while(|(i, _)| *i < max_bytes)
1060            .map(|(_, c)| c)
1061            .collect::<String>();
1062        (truncated, true)
1063    }
1064}
1065
1066/// Extract structured JSON output from the last line of stdout.
1067fn extract_structured_output(stdout: &str) -> (Option<serde_json::Value>, String) {
1068    let trimmed = stdout.trim_end();
1069    if trimmed.is_empty() {
1070        return (None, String::new());
1071    }
1072
1073    if let Some(last_newline_pos) = trimmed.rfind('\n') {
1074        let last_line = &trimmed[last_newline_pos + 1..];
1075        let before = &trimmed[..last_newline_pos];
1076
1077        if let Ok(value) = serde_json::from_str::<serde_json::Value>(last_line) {
1078            return (Some(value), before.to_string());
1079        }
1080    } else if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1081        return (Some(value), String::new());
1082    }
1083
1084    (None, stdout.to_string())
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090
1091    #[test]
1092    fn capabilities_are_container_ephemeral() {
1093        let executor = ContainerCommandExecutor::default();
1094        let caps = executor.capabilities();
1095        assert_eq!(caps.isolation, ExecutionIsolation::ContainerEphemeral);
1096        assert!(caps.enforce_network_policy);
1097        assert!(caps.enforce_filesystem_policy);
1098        assert!(caps.enforce_environment_policy);
1099        assert!(caps.enforce_timeout);
1100        assert!(caps.supports_structured_output);
1101        assert!(caps.supports_process_execution);
1102        assert!(!caps.supports_persistent_workspace);
1103        assert!(!caps.supports_interactive_sessions);
1104    }
1105
1106    #[test]
1107    fn supports_python_js_command() {
1108        let executor = ContainerCommandExecutor::default();
1109        assert!(executor.supports_language(&ExecutionLanguage::Python));
1110        assert!(executor.supports_language(&ExecutionLanguage::JavaScript));
1111        assert!(executor.supports_language(&ExecutionLanguage::Command));
1112        assert!(!executor.supports_language(&ExecutionLanguage::Rust));
1113        assert!(!executor.supports_language(&ExecutionLanguage::Wasm));
1114    }
1115
1116    #[test]
1117    fn default_config() {
1118        let config = ContainerConfig::default();
1119        assert_eq!(config.runtime, "docker");
1120        assert_eq!(config.default_image, "python:3.12-slim");
1121        assert!(config.extra_flags.is_empty());
1122        assert!(config.auto_remove);
1123    }
1124
1125    #[test]
1126    fn build_run_args_basic_python() {
1127        let executor = ContainerCommandExecutor::default();
1128        let request = ExecutionRequest {
1129            language: ExecutionLanguage::Python,
1130            payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1131            argv: vec![],
1132            stdin: None,
1133            input: None,
1134            sandbox: crate::SandboxPolicy::strict_rust(),
1135            identity: None,
1136        };
1137
1138        let args = executor.build_run_args(&request);
1139        assert!(args.contains(&"run".to_string()));
1140        assert!(args.contains(&"--rm".to_string()));
1141        assert!(args.contains(&"-i".to_string()));
1142        assert!(args.contains(&"--network=none".to_string()));
1143        assert!(args.contains(&"python3".to_string()));
1144        assert!(args.contains(&"-c".to_string()));
1145        assert!(args.contains(&"print('hello')".to_string()));
1146    }
1147
1148    #[test]
1149    fn build_run_args_with_network_enabled() {
1150        let executor = ContainerCommandExecutor::default();
1151        let mut sandbox = crate::SandboxPolicy::strict_rust();
1152        sandbox.network = NetworkPolicy::Enabled;
1153
1154        let request = ExecutionRequest {
1155            language: ExecutionLanguage::Python,
1156            payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1157            argv: vec![],
1158            stdin: None,
1159            input: None,
1160            sandbox,
1161            identity: None,
1162        };
1163
1164        let args = executor.build_run_args(&request);
1165        assert!(!args.contains(&"--network=none".to_string()));
1166    }
1167
1168    #[test]
1169    fn docker_config_presets() {
1170        let py = DockerConfig::python();
1171        assert_eq!(py.image, "python:3.12-slim");
1172        assert!(py.network_disabled);
1173
1174        let node = DockerConfig::node();
1175        assert_eq!(node.image, "node:20-slim");
1176
1177        let custom = DockerConfig::custom("ubuntu:24.04");
1178        assert_eq!(custom.image, "ubuntu:24.04");
1179    }
1180
1181    #[test]
1182    fn docker_config_builder_methods() {
1183        let config = DockerConfig::python()
1184            .pip_install(&["numpy", "pandas"])
1185            .with_network()
1186            .env("MY_VAR=hello")
1187            .bind_mount("/host/data:/data:ro");
1188
1189        assert!(!config.network_disabled);
1190        assert_eq!(config.setup_commands.len(), 1);
1191        assert!(config.setup_commands[0].contains("numpy"));
1192        assert_eq!(config.environment, vec!["MY_VAR=hello"]);
1193        assert_eq!(config.bind_mounts, vec!["/host/data:/data:ro"]);
1194    }
1195}