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        /// # Errors
268        ///
269        /// Returns [`ExecutionError::InternalError`] if the Docker daemon is
270        /// unreachable (not installed, not running, or socket permission denied).
271        pub fn new(config: DockerConfig) -> std::result::Result<Self, ExecutionError> {
272            let docker = Docker::connect_with_local_defaults().map_err(|e| {
273                ExecutionError::InternalError(format!(
274                    "failed to connect to Docker daemon: {e}. Is Docker installed and running?"
275                ))
276            })?;
277            Ok(Self { config, docker, state: RwLock::new(None) })
278        }
279
280        /// Create with a custom Docker connection.
281        pub fn with_docker(config: DockerConfig, docker: Docker) -> Self {
282            Self { config, docker, state: RwLock::new(None) }
283        }
284
285        /// Explicitly stop and remove the Docker container.
286        ///
287        /// Prefer calling this method before dropping the executor to ensure
288        /// reliable cleanup. The [`Drop`] implementation is best-effort and may
289        /// not work if no tokio runtime is available.
290        pub async fn cleanup(&self) -> Result<(), ExecutionError> {
291            let mut state = self.state.write().await;
292            if let Some(s) = state.take() {
293                info!(container_id = %s.id, "cleaning up container");
294                self.docker
295                    .remove_container(
296                        &s.id,
297                        Some(RemoveContainerOptions { force: true, ..Default::default() }),
298                    )
299                    .await
300                    .map_err(|e| {
301                        ExecutionError::ExecutionFailed(format!("failed to remove container: {e}"))
302                    })?;
303            }
304            Ok(())
305        }
306
307        /// Generate a unique container name.
308        fn container_name(&self) -> String {
309            let suffix: u32 = rand::rng().random_range(100_000..999_999);
310            format!("{}-{suffix}", self.config.container_name_prefix)
311        }
312
313        /// Get the file extension for a language.
314        fn file_extension(lang: &ExecutionLanguage) -> &'static str {
315            match lang {
316                ExecutionLanguage::Python => "py",
317                ExecutionLanguage::JavaScript => "js",
318                ExecutionLanguage::Rust => "rs",
319                ExecutionLanguage::Command => "sh",
320                ExecutionLanguage::Wasm => "wasm",
321            }
322        }
323
324        /// Get the execution command for a language and filename.
325        fn exec_command(lang: &ExecutionLanguage, filename: &str) -> Vec<String> {
326            match lang {
327                ExecutionLanguage::Python => {
328                    vec!["python3".to_string(), filename.to_string()]
329                }
330                ExecutionLanguage::JavaScript => {
331                    vec!["node".to_string(), filename.to_string()]
332                }
333                ExecutionLanguage::Command => {
334                    vec!["sh".to_string(), filename.to_string()]
335                }
336                _ => vec![],
337            }
338        }
339
340        /// Write a file inside the running container.
341        async fn write_file(
342            &self,
343            container_id: &str,
344            path: &str,
345            content: &str,
346        ) -> Result<(), ExecutionError> {
347            // Use a heredoc-style approach to write file content safely.
348            // Base64 encode to avoid shell escaping issues.
349            let encoded = base64_encode(content.as_bytes());
350            let cmd = vec![
351                "sh".to_string(),
352                "-c".to_string(),
353                format!("echo '{encoded}' | base64 -d > {path}"),
354            ];
355            self.exec_in_container(container_id, &cmd, None).await?;
356            Ok(())
357        }
358
359        /// Execute a command inside the running container and capture output.
360        async fn exec_in_container(
361            &self,
362            container_id: &str,
363            cmd: &[String],
364            timeout: Option<std::time::Duration>,
365        ) -> Result<(String, String, Option<i64>), ExecutionError> {
366            let exec = self
367                .docker
368                .create_exec(
369                    container_id,
370                    CreateExecOptions {
371                        cmd: Some(cmd.to_vec()),
372                        attach_stdout: Some(true),
373                        attach_stderr: Some(true),
374                        working_dir: Some(self.config.work_dir.clone()),
375                        ..Default::default()
376                    },
377                )
378                .await
379                .map_err(|e| {
380                    ExecutionError::ExecutionFailed(format!("failed to create exec: {e}"))
381                })?;
382
383            let exec_output = async {
384                match self.docker.start_exec(&exec.id, None).await {
385                    Ok(StartExecResults::Attached { mut output, .. }) => {
386                        let mut stdout = String::new();
387                        let mut stderr = String::new();
388
389                        while let Some(chunk) = output.next().await {
390                            match chunk {
391                                Ok(bollard::container::LogOutput::StdOut { message }) => {
392                                    stdout.push_str(&String::from_utf8_lossy(&message));
393                                }
394                                Ok(bollard::container::LogOutput::StdErr { message }) => {
395                                    stderr.push_str(&String::from_utf8_lossy(&message));
396                                }
397                                Ok(_) => {}
398                                Err(e) => {
399                                    return Err(ExecutionError::ExecutionFailed(format!(
400                                        "exec stream error: {e}"
401                                    )));
402                                }
403                            }
404                        }
405
406                        // Get exit code.
407                        let inspect = self.docker.inspect_exec(&exec.id).await.map_err(|e| {
408                            ExecutionError::ExecutionFailed(format!("failed to inspect exec: {e}"))
409                        })?;
410                        let exit_code = inspect.exit_code;
411
412                        Ok((stdout, stderr, exit_code))
413                    }
414                    Ok(StartExecResults::Detached) => Ok((String::new(), String::new(), None)),
415                    Err(e) => {
416                        Err(ExecutionError::ExecutionFailed(format!("failed to start exec: {e}")))
417                    }
418                }
419            };
420
421            if let Some(dur) = timeout {
422                match tokio::time::timeout(dur, exec_output).await {
423                    Ok(result) => result,
424                    Err(_) => Err(ExecutionError::Timeout(dur.as_millis() as u64)),
425                }
426            } else {
427                exec_output.await
428            }
429        }
430    }
431
432    #[async_trait]
433    impl CodeExecutor for DockerExecutor {
434        fn name(&self) -> &str {
435            "docker"
436        }
437
438        fn capabilities(&self) -> BackendCapabilities {
439            BackendCapabilities {
440                isolation: ExecutionIsolation::ContainerPersistent,
441                enforce_network_policy: true,
442                enforce_filesystem_policy: true,
443                enforce_environment_policy: true,
444                enforce_timeout: true,
445                supports_structured_output: true,
446                supports_process_execution: true,
447                supports_persistent_workspace: true,
448                supports_interactive_sessions: false,
449            }
450        }
451
452        fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
453            matches!(
454                lang,
455                ExecutionLanguage::Python
456                    | ExecutionLanguage::JavaScript
457                    | ExecutionLanguage::Command
458            )
459        }
460
461        async fn start(&self) -> Result<(), ExecutionError> {
462            let mut state = self.state.write().await;
463            if state.as_ref().is_some_and(|s| s.running) {
464                return Ok(());
465            }
466
467            let name = self.container_name();
468            info!(image = %self.config.image, container = %name, "creating container");
469
470            // Build container config.
471            let mut host_config = bollard::models::HostConfig::default();
472
473            if self.config.network_disabled {
474                host_config.network_mode = Some("none".to_string());
475            }
476
477            if !self.config.bind_mounts.is_empty() {
478                host_config.binds = Some(self.config.bind_mounts.clone());
479            }
480
481            let env = if self.config.environment.is_empty() {
482                None
483            } else {
484                Some(self.config.environment.clone())
485            };
486
487            let container_config = Config {
488                image: Some(self.config.image.clone()),
489                working_dir: Some(self.config.work_dir.clone()),
490                env,
491                host_config: Some(host_config),
492                // Keep container alive with a long sleep.
493                cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]),
494                tty: Some(false),
495                ..Default::default()
496            };
497
498            let create_opts = CreateContainerOptions { name: name.clone(), ..Default::default() };
499
500            let response =
501                self.docker.create_container(Some(create_opts), container_config).await.map_err(
502                    |e| ExecutionError::ExecutionFailed(format!("failed to create container: {e}")),
503                )?;
504
505            let container_id = response.id;
506            debug!(container_id = %container_id, "container created");
507
508            // Start the container.
509            self.docker
510                .start_container(&container_id, None::<StartContainerOptions<String>>)
511                .await
512                .map_err(|e| {
513                    ExecutionError::ExecutionFailed(format!("failed to start container: {e}"))
514                })?;
515
516            info!(container_id = %container_id, "container started");
517
518            // Create workspace directory.
519            let mkdir_cmd =
520                vec!["mkdir".to_string(), "-p".to_string(), self.config.work_dir.clone()];
521            let _ = self.exec_in_container(&container_id, &mkdir_cmd, None).await;
522
523            // Run setup commands.
524            for setup_cmd in &self.config.setup_commands {
525                info!(cmd = %setup_cmd, "running setup command");
526                let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd.clone()];
527                let (_stdout, stderr, exit_code) =
528                    self.exec_in_container(&container_id, &cmd, None).await?;
529
530                if exit_code != Some(0) {
531                    warn!(
532                        exit_code = ?exit_code,
533                        stderr = %stderr,
534                        "setup command failed"
535                    );
536                    // Clean up on setup failure.
537                    let _ = self
538                        .docker
539                        .remove_container(
540                            &container_id,
541                            Some(RemoveContainerOptions { force: true, ..Default::default() }),
542                        )
543                        .await;
544                    return Err(ExecutionError::ExecutionFailed(format!(
545                        "setup command failed: {setup_cmd}\nstderr: {stderr}"
546                    )));
547                }
548            }
549
550            *state = Some(ContainerState { id: container_id, running: true, file_counter: 0 });
551
552            Ok(())
553        }
554
555        async fn stop(&self) -> Result<(), ExecutionError> {
556            let mut state = self.state.write().await;
557            if let Some(s) = state.take() {
558                info!(container_id = %s.id, "stopping container");
559                let _ = self
560                    .docker
561                    .remove_container(
562                        &s.id,
563                        Some(RemoveContainerOptions { force: true, ..Default::default() }),
564                    )
565                    .await;
566            }
567            Ok(())
568        }
569
570        async fn is_running(&self) -> bool {
571            self.state.read().await.as_ref().is_some_and(|s| s.running)
572        }
573
574        async fn execute(
575            &self,
576            request: ExecutionRequest,
577        ) -> Result<ExecutionResult, ExecutionError> {
578            let supported = [
579                ExecutionLanguage::Python,
580                ExecutionLanguage::JavaScript,
581                ExecutionLanguage::Command,
582            ];
583            validate_request(&self.capabilities(), &supported, &request)?;
584
585            let code = match &request.payload {
586                ExecutionPayload::Source { code } if code.trim().is_empty() => {
587                    return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
588                }
589                ExecutionPayload::Source { code } => code.clone(),
590                ExecutionPayload::GuestModule { .. } => {
591                    return Err(ExecutionError::InvalidRequest(
592                        "DockerExecutor does not support guest modules".to_string(),
593                    ));
594                }
595            };
596
597            // Auto-start if configured and not running.
598            if self.config.auto_start && !self.is_running().await {
599                self.start().await?;
600            }
601
602            // Get container ID and increment file counter.
603            let (container_id, filename) = {
604                let mut state = self.state.write().await;
605                let s = state.as_mut().ok_or_else(|| {
606                    ExecutionError::ExecutionFailed(
607                        "container not started — call start() first".to_string(),
608                    )
609                })?;
610                s.file_counter += 1;
611                let ext = Self::file_extension(&request.language);
612                let filename = format!("{}/code_{}.{ext}", self.config.work_dir, s.file_counter);
613                (s.id.clone(), filename)
614            };
615
616            let start = Instant::now();
617
618            // Write code to file inside container.
619            self.write_file(&container_id, &filename, &code).await?;
620
621            // If there's structured input, write it as a JSON file.
622            if let Some(ref input) = request.input {
623                let input_json = serde_json::to_string(input).unwrap_or_default();
624                let input_path = format!("{}/input.json", self.config.work_dir);
625                self.write_file(&container_id, &input_path, &input_json).await?;
626            }
627
628            // Build execution command.
629            let exec_cmd = Self::exec_command(&request.language, &filename);
630            if exec_cmd.is_empty() {
631                return Err(ExecutionError::UnsupportedLanguage(format!("{}", request.language)));
632            }
633
634            debug!(
635                container_id = %container_id,
636                language = %request.language,
637                filename = %filename,
638                "executing code in container"
639            );
640
641            // Execute with timeout.
642            let (stdout, stderr, exit_code) = self
643                .exec_in_container(&container_id, &exec_cmd, Some(request.sandbox.timeout))
644                .await
645                .map_err(|e| match e {
646                    ExecutionError::Timeout(_) => e,
647                    other => other,
648                })?;
649
650            let duration_ms = start.elapsed().as_millis() as u64;
651
652            let (stdout, stdout_truncated) =
653                truncate_output(stdout, request.sandbox.max_stdout_bytes);
654            let (stderr, stderr_truncated) =
655                truncate_output(stderr, request.sandbox.max_stderr_bytes);
656
657            let (structured_output, display_stdout) = extract_structured_output(&stdout);
658
659            let status = match exit_code {
660                Some(0) => ExecutionStatus::Success,
661                _ => ExecutionStatus::Failed,
662            };
663
664            info!(
665                exit_code = ?exit_code,
666                duration_ms,
667                has_structured_output = structured_output.is_some(),
668                "container execution completed"
669            );
670
671            Ok(ExecutionResult {
672                status,
673                stdout: display_stdout,
674                stderr,
675                output: structured_output,
676                exit_code: exit_code.map(|c| c as i32),
677                stdout_truncated,
678                stderr_truncated,
679                duration_ms,
680                metadata: None,
681            })
682        }
683    }
684
685    impl Drop for DockerExecutor {
686        fn drop(&mut self) {
687            if self.config.auto_remove {
688                // Best-effort cleanup — we can't await in drop, so spawn a task
689                // only if a tokio runtime is available.
690                if let Some(state) = self.state.get_mut().take() {
691                    let docker = self.docker.clone();
692                    let container_id = state.id;
693                    match tokio::runtime::Handle::try_current() {
694                        Ok(handle) => {
695                            handle.spawn(async move {
696                                let _ = docker
697                                    .remove_container(
698                                        &container_id,
699                                        Some(RemoveContainerOptions {
700                                            force: true,
701                                            ..Default::default()
702                                        }),
703                                    )
704                                    .await;
705                            });
706                        }
707                        Err(_) => {
708                            tracing::warn!(
709                                container_id = %container_id,
710                                "no tokio runtime available during DockerExecutor drop, \
711                                 container may leak. Call cleanup() explicitly before dropping."
712                            );
713                        }
714                    }
715                }
716            }
717        }
718    }
719
720    /// Simple base64 encoder (no padding issues with shell).
721    fn base64_encode(data: &[u8]) -> String {
722        const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
723        let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
724        for chunk in data.chunks(3) {
725            let b0 = chunk[0] as u32;
726            let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
727            let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
728            let triple = (b0 << 16) | (b1 << 8) | b2;
729            result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
730            result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
731            if chunk.len() > 1 {
732                result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
733            } else {
734                result.push('=');
735            }
736            if chunk.len() > 2 {
737                result.push(CHARS[(triple & 0x3F) as usize] as char);
738            } else {
739                result.push('=');
740            }
741        }
742        result
743    }
744}
745
746#[cfg(feature = "docker")]
747pub use docker_impl::DockerExecutor;
748
749// ── CLI-based fallback (always available) ──────────────────────────────
750
751/// Configuration for the CLI-based container command executor.
752///
753/// This is the fallback executor that shells out to `docker run` for each
754/// execution. For production use, prefer [`DockerExecutor`] (behind the
755/// `docker` feature) which uses persistent containers.
756///
757/// # Example
758///
759/// ```rust
760/// use adk_code::ContainerConfig;
761///
762/// let config = ContainerConfig::default();
763/// assert_eq!(config.runtime, "docker");
764/// ```
765#[derive(Debug, Clone)]
766pub struct ContainerConfig {
767    /// Container runtime binary (e.g., `"docker"`, `"podman"`).
768    pub runtime: String,
769    /// Default container image when not overridden per-request.
770    pub default_image: String,
771    /// Extra flags passed to the container runtime `run` command.
772    pub extra_flags: Vec<String>,
773    /// Whether to automatically remove the container after execution.
774    pub auto_remove: bool,
775}
776
777impl Default for ContainerConfig {
778    fn default() -> Self {
779        Self {
780            runtime: "docker".to_string(),
781            default_image: "python:3.12-slim".to_string(),
782            extra_flags: vec![],
783            auto_remove: true,
784        }
785    }
786}
787
788/// CLI-based container executor that shells out to `docker run` per execution.
789///
790/// Each [`execute`](CodeExecutor::execute) call spawns a new ephemeral container.
791/// This is simpler but less efficient than [`DockerExecutor`] which reuses a
792/// persistent container.
793///
794/// For production use, prefer [`DockerExecutor`] (behind the `docker` feature).
795///
796/// # Example
797///
798/// ```rust
799/// use adk_code::{CodeExecutor, ContainerCommandExecutor, ContainerConfig, ExecutionIsolation};
800///
801/// let executor = ContainerCommandExecutor::default();
802/// assert_eq!(executor.name(), "container-command");
803/// assert_eq!(executor.capabilities().isolation, ExecutionIsolation::ContainerEphemeral);
804/// ```
805#[derive(Debug, Clone)]
806pub struct ContainerCommandExecutor {
807    config: ContainerConfig,
808}
809
810impl ContainerCommandExecutor {
811    /// Create a new container command executor with the given configuration.
812    pub fn new(config: ContainerConfig) -> Self {
813        Self { config }
814    }
815
816    /// Build the container `run` command arguments for a given request.
817    fn build_run_args(&self, request: &ExecutionRequest) -> Vec<String> {
818        let mut args = vec!["run".to_string()];
819
820        if self.config.auto_remove {
821            args.push("--rm".to_string());
822        }
823
824        args.push("-i".to_string());
825
826        match request.sandbox.network {
827            NetworkPolicy::Disabled => {
828                args.push("--network=none".to_string());
829            }
830            NetworkPolicy::Enabled => {}
831        }
832
833        match &request.sandbox.filesystem {
834            FilesystemPolicy::None => {}
835            FilesystemPolicy::WorkspaceReadOnly { root } => {
836                args.push("-v".to_string());
837                args.push(format!("{}:/workspace:ro", root.display()));
838            }
839            FilesystemPolicy::WorkspaceReadWrite { root } => {
840                args.push("-v".to_string());
841                args.push(format!("{}:/workspace:rw", root.display()));
842            }
843            FilesystemPolicy::Paths { read_only, read_write } => {
844                for path in read_only {
845                    args.push("-v".to_string());
846                    args.push(format!("{}:{}:ro", path.display(), path.display()));
847                }
848                for path in read_write {
849                    args.push("-v".to_string());
850                    args.push(format!("{}:{}:rw", path.display(), path.display()));
851                }
852            }
853        }
854
855        if let EnvironmentPolicy::AllowList(vars) = &request.sandbox.environment {
856            for var in vars {
857                args.push("--env".to_string());
858                args.push(var.clone());
859            }
860        }
861
862        if let Some(ref wd) = request.sandbox.working_directory {
863            args.push("-w".to_string());
864            args.push(wd.display().to_string());
865        }
866
867        args.extend(self.config.extra_flags.clone());
868        args.push(self.config.default_image.clone());
869
870        let code = match &request.payload {
871            ExecutionPayload::Source { code } => code.clone(),
872            ExecutionPayload::GuestModule { .. } => String::new(),
873        };
874
875        match request.language {
876            ExecutionLanguage::Python => {
877                args.push("python3".to_string());
878                args.push("-c".to_string());
879                args.push(code);
880            }
881            ExecutionLanguage::JavaScript => {
882                args.push("node".to_string());
883                args.push("-e".to_string());
884                args.push(code);
885            }
886            ExecutionLanguage::Command => {
887                args.push("sh".to_string());
888                args.push("-c".to_string());
889                args.push(code);
890            }
891            _ => {}
892        }
893
894        args.extend(request.argv.clone());
895        args
896    }
897}
898
899impl Default for ContainerCommandExecutor {
900    fn default() -> Self {
901        Self::new(ContainerConfig::default())
902    }
903}
904
905#[async_trait]
906impl CodeExecutor for ContainerCommandExecutor {
907    fn name(&self) -> &str {
908        "container-command"
909    }
910
911    fn capabilities(&self) -> BackendCapabilities {
912        BackendCapabilities {
913            isolation: ExecutionIsolation::ContainerEphemeral,
914            enforce_network_policy: true,
915            enforce_filesystem_policy: true,
916            enforce_environment_policy: true,
917            enforce_timeout: true,
918            supports_structured_output: true,
919            supports_process_execution: true,
920            supports_persistent_workspace: false,
921            supports_interactive_sessions: false,
922        }
923    }
924
925    fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
926        matches!(
927            lang,
928            ExecutionLanguage::Python | ExecutionLanguage::JavaScript | ExecutionLanguage::Command
929        )
930    }
931
932    async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
933        let supported =
934            [ExecutionLanguage::Python, ExecutionLanguage::JavaScript, ExecutionLanguage::Command];
935        validate_request(&self.capabilities(), &supported, &request)?;
936
937        match &request.payload {
938            ExecutionPayload::Source { code } if code.trim().is_empty() => {
939                return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
940            }
941            ExecutionPayload::Source { .. } => {}
942            ExecutionPayload::GuestModule { .. } => {
943                return Err(ExecutionError::InvalidRequest(
944                    "ContainerCommandExecutor does not support guest modules".to_string(),
945                ));
946            }
947        }
948
949        let start = Instant::now();
950        let run_args = self.build_run_args(&request);
951
952        debug!(
953            runtime = %self.config.runtime,
954            image = %self.config.default_image,
955            language = %request.language,
956            "starting container execution"
957        );
958
959        let mut cmd = tokio::process::Command::new(&self.config.runtime);
960        for arg in &run_args {
961            cmd.arg(arg);
962        }
963
964        cmd.stdin(std::process::Stdio::piped());
965        cmd.stdout(std::process::Stdio::piped());
966        cmd.stderr(std::process::Stdio::piped());
967        cmd.kill_on_drop(true);
968
969        let mut child = cmd.spawn().map_err(|e| {
970            ExecutionError::ExecutionFailed(format!(
971                "failed to spawn container runtime '{}': {e}",
972                self.config.runtime
973            ))
974        })?;
975
976        if let Some(ref input) = request.input {
977            if let Some(mut stdin) = child.stdin.take() {
978                use tokio::io::AsyncWriteExt;
979                let json_bytes = serde_json::to_vec(input).unwrap_or_default();
980                let _ = stdin.write_all(&json_bytes).await;
981                drop(stdin);
982            }
983        } else if let Some(ref raw_stdin) = request.stdin {
984            if let Some(mut stdin) = child.stdin.take() {
985                use tokio::io::AsyncWriteExt;
986                let _ = stdin.write_all(raw_stdin).await;
987                drop(stdin);
988            }
989        } else {
990            drop(child.stdin.take());
991        }
992
993        let output =
994            match tokio::time::timeout(request.sandbox.timeout, child.wait_with_output()).await {
995                Ok(Ok(output)) => output,
996                Ok(Err(e)) => {
997                    return Err(ExecutionError::ExecutionFailed(format!(
998                        "failed to wait for container: {e}"
999                    )));
1000                }
1001                Err(_) => {
1002                    warn!("container execution timed out");
1003                    let duration_ms = start.elapsed().as_millis() as u64;
1004                    return Ok(ExecutionResult {
1005                        status: ExecutionStatus::Timeout,
1006                        stdout: String::new(),
1007                        stderr: String::new(),
1008                        output: None,
1009                        exit_code: None,
1010                        stdout_truncated: false,
1011                        stderr_truncated: false,
1012                        duration_ms,
1013                        metadata: None,
1014                    });
1015                }
1016            };
1017
1018        let duration_ms = start.elapsed().as_millis() as u64;
1019
1020        let raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
1021        let raw_stderr = String::from_utf8_lossy(&output.stderr).to_string();
1022
1023        let (stdout, stdout_truncated) =
1024            truncate_output(raw_stdout, request.sandbox.max_stdout_bytes);
1025        let (stderr, stderr_truncated) =
1026            truncate_output(raw_stderr, request.sandbox.max_stderr_bytes);
1027
1028        let (structured_output, display_stdout) = extract_structured_output(&stdout);
1029
1030        let status = if output.status.success() {
1031            ExecutionStatus::Success
1032        } else {
1033            ExecutionStatus::Failed
1034        };
1035
1036        info!(
1037            exit_code = output.status.code(),
1038            duration_ms,
1039            has_structured_output = structured_output.is_some(),
1040            "container execution completed"
1041        );
1042
1043        Ok(ExecutionResult {
1044            status,
1045            stdout: display_stdout,
1046            stderr,
1047            output: structured_output,
1048            exit_code: output.status.code(),
1049            stdout_truncated,
1050            stderr_truncated,
1051            duration_ms,
1052            metadata: None,
1053        })
1054    }
1055}
1056
1057// ── Shared helpers ─────────────────────────────────────────────────────
1058
1059/// Truncate output to the given byte limit.
1060fn truncate_output(output: String, max_bytes: usize) -> (String, bool) {
1061    if output.len() <= max_bytes {
1062        (output, false)
1063    } else {
1064        let truncated = output
1065            .char_indices()
1066            .take_while(|(i, _)| *i < max_bytes)
1067            .map(|(_, c)| c)
1068            .collect::<String>();
1069        (truncated, true)
1070    }
1071}
1072
1073/// Extract structured JSON output from the last line of stdout.
1074fn extract_structured_output(stdout: &str) -> (Option<serde_json::Value>, String) {
1075    let trimmed = stdout.trim_end();
1076    if trimmed.is_empty() {
1077        return (None, String::new());
1078    }
1079
1080    if let Some(last_newline_pos) = trimmed.rfind('\n') {
1081        let last_line = &trimmed[last_newline_pos + 1..];
1082        let before = &trimmed[..last_newline_pos];
1083
1084        if let Ok(value) = serde_json::from_str::<serde_json::Value>(last_line) {
1085            return (Some(value), before.to_string());
1086        }
1087    } else if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1088        return (Some(value), String::new());
1089    }
1090
1091    (None, stdout.to_string())
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097
1098    #[test]
1099    fn capabilities_are_container_ephemeral() {
1100        let executor = ContainerCommandExecutor::default();
1101        let caps = executor.capabilities();
1102        assert_eq!(caps.isolation, ExecutionIsolation::ContainerEphemeral);
1103        assert!(caps.enforce_network_policy);
1104        assert!(caps.enforce_filesystem_policy);
1105        assert!(caps.enforce_environment_policy);
1106        assert!(caps.enforce_timeout);
1107        assert!(caps.supports_structured_output);
1108        assert!(caps.supports_process_execution);
1109        assert!(!caps.supports_persistent_workspace);
1110        assert!(!caps.supports_interactive_sessions);
1111    }
1112
1113    #[test]
1114    fn supports_python_js_command() {
1115        let executor = ContainerCommandExecutor::default();
1116        assert!(executor.supports_language(&ExecutionLanguage::Python));
1117        assert!(executor.supports_language(&ExecutionLanguage::JavaScript));
1118        assert!(executor.supports_language(&ExecutionLanguage::Command));
1119        assert!(!executor.supports_language(&ExecutionLanguage::Rust));
1120        assert!(!executor.supports_language(&ExecutionLanguage::Wasm));
1121    }
1122
1123    #[test]
1124    fn default_config() {
1125        let config = ContainerConfig::default();
1126        assert_eq!(config.runtime, "docker");
1127        assert_eq!(config.default_image, "python:3.12-slim");
1128        assert!(config.extra_flags.is_empty());
1129        assert!(config.auto_remove);
1130    }
1131
1132    #[test]
1133    fn build_run_args_basic_python() {
1134        let executor = ContainerCommandExecutor::default();
1135        let request = ExecutionRequest {
1136            language: ExecutionLanguage::Python,
1137            payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1138            argv: vec![],
1139            stdin: None,
1140            input: None,
1141            sandbox: crate::SandboxPolicy::strict_rust(),
1142            identity: None,
1143        };
1144
1145        let args = executor.build_run_args(&request);
1146        assert!(args.contains(&"run".to_string()));
1147        assert!(args.contains(&"--rm".to_string()));
1148        assert!(args.contains(&"-i".to_string()));
1149        assert!(args.contains(&"--network=none".to_string()));
1150        assert!(args.contains(&"python3".to_string()));
1151        assert!(args.contains(&"-c".to_string()));
1152        assert!(args.contains(&"print('hello')".to_string()));
1153    }
1154
1155    #[test]
1156    fn build_run_args_with_network_enabled() {
1157        let executor = ContainerCommandExecutor::default();
1158        let mut sandbox = crate::SandboxPolicy::strict_rust();
1159        sandbox.network = NetworkPolicy::Enabled;
1160
1161        let request = ExecutionRequest {
1162            language: ExecutionLanguage::Python,
1163            payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1164            argv: vec![],
1165            stdin: None,
1166            input: None,
1167            sandbox,
1168            identity: None,
1169        };
1170
1171        let args = executor.build_run_args(&request);
1172        assert!(!args.contains(&"--network=none".to_string()));
1173    }
1174
1175    #[test]
1176    fn docker_config_presets() {
1177        let py = DockerConfig::python();
1178        assert_eq!(py.image, "python:3.12-slim");
1179        assert!(py.network_disabled);
1180
1181        let node = DockerConfig::node();
1182        assert_eq!(node.image, "node:20-slim");
1183
1184        let custom = DockerConfig::custom("ubuntu:24.04");
1185        assert_eq!(custom.image, "ubuntu:24.04");
1186    }
1187
1188    #[test]
1189    fn docker_config_builder_methods() {
1190        let config = DockerConfig::python()
1191            .pip_install(&["numpy", "pandas"])
1192            .with_network()
1193            .env("MY_VAR=hello")
1194            .bind_mount("/host/data:/data:ro");
1195
1196        assert!(!config.network_disabled);
1197        assert_eq!(config.setup_commands.len(), 1);
1198        assert!(config.setup_commands[0].contains("numpy"));
1199        assert_eq!(config.environment, vec!["MY_VAR=hello"]);
1200        assert_eq!(config.bind_mounts, vec!["/host/data:/data:ro"]);
1201    }
1202}