1use std::path::{Component, Path, PathBuf};
73use std::process::Stdio;
74use std::time::Instant;
75
76use async_trait::async_trait;
77use tracing::{debug, info, warn};
78
79use crate::host_process;
80use crate::{
81 BackendCapabilities, CodeExecutor, EnvironmentPolicy, ExecutionError, ExecutionIsolation,
82 ExecutionLanguage, ExecutionPayload, ExecutionRequest, ExecutionResult, ExecutionStatus,
83 FilesystemPolicy, NetworkPolicy, SandboxPolicy, validate_request,
84};
85
86#[derive(Debug, Clone)]
98pub struct DockerConfig {
99 pub image: String,
101 pub work_dir: String,
103 pub setup_commands: Vec<String>,
105 pub environment: Vec<String>,
107 pub bind_mounts: Vec<String>,
109 pub network_disabled: bool,
111 pub container_name_prefix: String,
113 pub auto_start: bool,
115 pub auto_remove: bool,
117}
118
119impl DockerConfig {
120 pub fn python() -> Self {
122 Self {
123 image: "python:3.12-slim".to_string(),
124 work_dir: "/workspace".to_string(),
125 setup_commands: vec![],
126 environment: vec![],
127 bind_mounts: vec![],
128 network_disabled: true,
129 container_name_prefix: "adk-python".to_string(),
130 auto_start: true,
131 auto_remove: true,
132 }
133 }
134
135 pub fn node() -> Self {
137 Self {
138 image: "node:20-slim".to_string(),
139 work_dir: "/workspace".to_string(),
140 setup_commands: vec![],
141 environment: vec![],
142 bind_mounts: vec![],
143 network_disabled: true,
144 container_name_prefix: "adk-node".to_string(),
145 auto_start: true,
146 auto_remove: true,
147 }
148 }
149
150 pub fn custom(image: impl Into<String>) -> Self {
152 Self {
153 image: image.into(),
154 work_dir: "/workspace".to_string(),
155 setup_commands: vec![],
156 environment: vec![],
157 bind_mounts: vec![],
158 network_disabled: true,
159 container_name_prefix: "adk-custom".to_string(),
160 auto_start: true,
161 auto_remove: true,
162 }
163 }
164
165 pub fn setup_command(mut self, cmd: impl Into<String>) -> Self {
167 self.setup_commands.push(cmd.into());
168 self
169 }
170
171 pub fn pip_install(self, packages: &[&str]) -> Self {
173 self.setup_command(format!("pip install --quiet {}", packages.join(" ")))
174 }
175
176 pub fn npm_install(self, packages: &[&str]) -> Self {
178 self.setup_command(format!("npm install --silent {}", packages.join(" ")))
179 }
180
181 pub fn with_network(mut self) -> Self {
183 self.network_disabled = false;
184 self
185 }
186
187 pub fn bind_mount(mut self, mount: impl Into<String>) -> Self {
189 self.bind_mounts.push(mount.into());
190 self
191 }
192
193 pub fn env(mut self, var: impl Into<String>) -> Self {
195 self.environment.push(var.into());
196 self
197 }
198
199 pub fn validate_sandbox_policy(&self, policy: &SandboxPolicy) -> Result<(), ExecutionError> {
242 if matches!(policy.network, NetworkPolicy::Disabled) && !self.network_disabled {
243 return Err(ExecutionError::UnsupportedPolicy(
244 "the policy disables network access, but the container was created with network \
245 access; drop `with_network()` from the DockerConfig or use \
246 `NetworkPolicy::Enabled`"
247 .to_string(),
248 ));
249 }
250
251 for mount in &self.bind_mounts {
252 let parts: Vec<&str> = mount.split(':').collect();
254 let has_drive_letter = parts.len() > 2
255 && parts[0].len() == 1
256 && parts[0].chars().all(|c| c.is_ascii_alphabetic());
257 let (host, rest) = if has_drive_letter {
258 (&mount[..parts[0].len() + 1 + parts[1].len()], &parts[2..])
259 } else {
260 (parts[0], &parts[1..])
261 };
262 let read_only = rest
263 .get(1)
264 .is_some_and(|options| options.split(',').any(|o| o == "ro" || o == "readonly"));
265
266 let host = Path::new(host);
270 let confined = host.has_root()
271 && !host.components().any(|component| matches!(component, Component::ParentDir));
272 let under = |root: &PathBuf| confined && host.starts_with(root);
273
274 let permitted = match &policy.filesystem {
275 FilesystemPolicy::None => false,
276 FilesystemPolicy::WorkspaceReadOnly { root } => read_only && under(root),
277 FilesystemPolicy::WorkspaceReadWrite { root } => under(root),
278 FilesystemPolicy::Paths { read_only: read_only_paths, read_write } => {
279 read_write.iter().any(under) || (read_only && read_only_paths.iter().any(under))
280 }
281 };
282 if !permitted {
283 return Err(ExecutionError::UnsupportedPolicy(format!(
284 "bind mount `{mount}` exceeds the filesystem policy {:?}; remove the mount \
285 or grant its host path in the policy",
286 policy.filesystem
287 )));
288 }
289 }
290
291 for entry in &self.environment {
292 let name = entry.split_once('=').map_or(entry.as_str(), |(name, _)| name);
293 let allowed = match &policy.environment {
294 EnvironmentPolicy::None => false,
295 EnvironmentPolicy::AllowList(names) => names.iter().any(|allowed| allowed == name),
296 };
297 if !allowed {
298 return Err(ExecutionError::UnsupportedPolicy(format!(
299 "container environment variable `{name}` is not allowed by the environment \
300 policy; remove it from the DockerConfig or add it to \
301 `EnvironmentPolicy::AllowList`"
302 )));
303 }
304 }
305
306 Ok(())
307 }
308}
309
310impl Default for DockerConfig {
311 fn default() -> Self {
312 Self::python()
313 }
314}
315
316#[cfg(feature = "docker")]
319mod docker_impl {
320 use super::*;
321 use bollard::Docker;
322 use bollard::container::{
323 Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
324 };
325 use bollard::exec::{CreateExecOptions, StartExecResults};
326 use futures::StreamExt;
327 use rand::Rng;
328 use tokio::sync::RwLock;
329
330 #[derive(Debug)]
332 struct ContainerState {
333 id: String,
335 running: bool,
337 file_counter: u64,
339 }
340
341 pub struct DockerExecutor {
378 config: DockerConfig,
379 docker: Docker,
380 state: RwLock<Option<ContainerState>>,
381 }
382
383 impl std::fmt::Debug for DockerExecutor {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 f.debug_struct("DockerExecutor").field("config", &self.config).finish()
386 }
387 }
388
389 impl DockerExecutor {
390 pub fn new(config: DockerConfig) -> std::result::Result<Self, ExecutionError> {
399 let docker = Docker::connect_with_local_defaults().map_err(|e| {
400 ExecutionError::InternalError(format!(
401 "failed to connect to Docker daemon: {e}. Is Docker installed and running?"
402 ))
403 })?;
404 Ok(Self { config, docker, state: RwLock::new(None) })
405 }
406
407 pub fn with_docker(config: DockerConfig, docker: Docker) -> Self {
409 Self { config, docker, state: RwLock::new(None) }
410 }
411
412 pub async fn cleanup(&self) -> Result<(), ExecutionError> {
418 let mut state = self.state.write().await;
419 if let Some(s) = state.take() {
420 info!(container_id = %s.id, "cleaning up container");
421 self.docker
422 .remove_container(
423 &s.id,
424 Some(RemoveContainerOptions { force: true, ..Default::default() }),
425 )
426 .await
427 .map_err(|e| {
428 ExecutionError::ExecutionFailed(format!("failed to remove container: {e}"))
429 })?;
430 }
431 Ok(())
432 }
433
434 fn container_name(&self) -> String {
436 let suffix: u32 = rand::rng().random_range(100_000..999_999);
437 format!("{}-{suffix}", self.config.container_name_prefix)
438 }
439
440 fn file_extension(lang: &ExecutionLanguage) -> &'static str {
442 match lang {
443 ExecutionLanguage::Python => "py",
444 ExecutionLanguage::JavaScript => "js",
445 ExecutionLanguage::Rust => "rs",
446 ExecutionLanguage::Command => "sh",
447 ExecutionLanguage::Wasm => "wasm",
448 }
449 }
450
451 fn exec_command(lang: &ExecutionLanguage, filename: &str) -> Vec<String> {
453 match lang {
454 ExecutionLanguage::Python => {
455 vec!["python3".to_string(), filename.to_string()]
456 }
457 ExecutionLanguage::JavaScript => {
458 vec!["node".to_string(), filename.to_string()]
459 }
460 ExecutionLanguage::Command => {
461 vec!["sh".to_string(), filename.to_string()]
462 }
463 _ => vec![],
464 }
465 }
466
467 async fn write_file(
469 &self,
470 container_id: &str,
471 path: &str,
472 content: &str,
473 ) -> Result<(), ExecutionError> {
474 let encoded = base64_encode(content.as_bytes());
477 let cmd = vec![
478 "sh".to_string(),
479 "-c".to_string(),
480 format!("echo '{encoded}' | base64 -d > {path}"),
481 ];
482 self.exec_in_container(container_id, &cmd, None, None).await?;
483 Ok(())
484 }
485
486 async fn exec_in_container(
491 &self,
492 container_id: &str,
493 cmd: &[String],
494 env: Option<Vec<String>>,
495 timeout: Option<std::time::Duration>,
496 ) -> Result<(String, String, Option<i64>), ExecutionError> {
497 let exec = self
498 .docker
499 .create_exec(
500 container_id,
501 CreateExecOptions {
502 cmd: Some(cmd.to_vec()),
503 env,
504 attach_stdout: Some(true),
505 attach_stderr: Some(true),
506 working_dir: Some(self.config.work_dir.clone()),
507 ..Default::default()
508 },
509 )
510 .await
511 .map_err(|e| {
512 ExecutionError::ExecutionFailed(format!("failed to create exec: {e}"))
513 })?;
514
515 let exec_output = async {
516 match self.docker.start_exec(&exec.id, None).await {
517 Ok(StartExecResults::Attached { mut output, .. }) => {
518 let mut stdout = String::new();
519 let mut stderr = String::new();
520
521 while let Some(chunk) = output.next().await {
522 match chunk {
523 Ok(bollard::container::LogOutput::StdOut { message }) => {
524 stdout.push_str(&String::from_utf8_lossy(&message));
525 }
526 Ok(bollard::container::LogOutput::StdErr { message }) => {
527 stderr.push_str(&String::from_utf8_lossy(&message));
528 }
529 Ok(_) => {}
530 Err(e) => {
531 return Err(ExecutionError::ExecutionFailed(format!(
532 "exec stream error: {e}"
533 )));
534 }
535 }
536 }
537
538 let inspect = self.docker.inspect_exec(&exec.id).await.map_err(|e| {
540 ExecutionError::ExecutionFailed(format!("failed to inspect exec: {e}"))
541 })?;
542 let exit_code = inspect.exit_code;
543
544 Ok((stdout, stderr, exit_code))
545 }
546 Ok(StartExecResults::Detached) => Ok((String::new(), String::new(), None)),
547 Err(e) => {
548 Err(ExecutionError::ExecutionFailed(format!("failed to start exec: {e}")))
549 }
550 }
551 };
552
553 if let Some(dur) = timeout {
554 match tokio::time::timeout(dur, exec_output).await {
555 Ok(result) => result,
556 Err(_) => Err(ExecutionError::Timeout(dur.as_millis() as u64)),
557 }
558 } else {
559 exec_output.await
560 }
561 }
562 }
563
564 #[async_trait]
565 impl CodeExecutor for DockerExecutor {
566 fn name(&self) -> &str {
567 "docker"
568 }
569
570 fn capabilities(&self) -> BackendCapabilities {
571 BackendCapabilities {
572 isolation: ExecutionIsolation::ContainerPersistent,
573 enforce_network_policy: true,
574 enforce_filesystem_policy: true,
575 enforce_environment_policy: true,
576 enforce_timeout: true,
577 supports_structured_output: true,
578 supports_process_execution: true,
579 supports_persistent_workspace: true,
580 supports_interactive_sessions: false,
581 }
582 }
583
584 fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
585 matches!(
586 lang,
587 ExecutionLanguage::Python
588 | ExecutionLanguage::JavaScript
589 | ExecutionLanguage::Command
590 )
591 }
592
593 async fn start(&self) -> Result<(), ExecutionError> {
594 let mut state = self.state.write().await;
595 if state.as_ref().is_some_and(|s| s.running) {
596 return Ok(());
597 }
598
599 let name = self.container_name();
600 info!(image = %self.config.image, container = %name, "creating container");
601
602 let mut host_config = bollard::models::HostConfig::default();
604
605 if self.config.network_disabled {
606 host_config.network_mode = Some("none".to_string());
607 }
608
609 if !self.config.bind_mounts.is_empty() {
610 host_config.binds = Some(self.config.bind_mounts.clone());
611 }
612
613 let env = if self.config.environment.is_empty() {
614 None
615 } else {
616 Some(self.config.environment.clone())
617 };
618
619 let container_config = Config {
620 image: Some(self.config.image.clone()),
621 working_dir: Some(self.config.work_dir.clone()),
622 env,
623 host_config: Some(host_config),
624 cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]),
626 tty: Some(false),
627 ..Default::default()
628 };
629
630 let create_opts = CreateContainerOptions { name: name.clone(), ..Default::default() };
631
632 let response =
633 self.docker.create_container(Some(create_opts), container_config).await.map_err(
634 |e| ExecutionError::ExecutionFailed(format!("failed to create container: {e}")),
635 )?;
636
637 let container_id = response.id;
638 debug!(container_id = %container_id, "container created");
639
640 self.docker
642 .start_container(&container_id, None::<StartContainerOptions<String>>)
643 .await
644 .map_err(|e| {
645 ExecutionError::ExecutionFailed(format!("failed to start container: {e}"))
646 })?;
647
648 info!(container_id = %container_id, "container started");
649
650 let mkdir_cmd =
652 vec!["mkdir".to_string(), "-p".to_string(), self.config.work_dir.clone()];
653 let _ = self.exec_in_container(&container_id, &mkdir_cmd, None, None).await;
654
655 for setup_cmd in &self.config.setup_commands {
657 info!(cmd = %setup_cmd, "running setup command");
658 let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd.clone()];
659 let (_stdout, stderr, exit_code) =
660 self.exec_in_container(&container_id, &cmd, None, None).await?;
661
662 if exit_code != Some(0) {
663 warn!(
664 exit_code = ?exit_code,
665 stderr = %stderr,
666 "setup command failed"
667 );
668 let _ = self
670 .docker
671 .remove_container(
672 &container_id,
673 Some(RemoveContainerOptions { force: true, ..Default::default() }),
674 )
675 .await;
676 return Err(ExecutionError::ExecutionFailed(format!(
677 "setup command failed: {setup_cmd}\nstderr: {stderr}"
678 )));
679 }
680 }
681
682 *state = Some(ContainerState { id: container_id, running: true, file_counter: 0 });
683
684 Ok(())
685 }
686
687 async fn stop(&self) -> Result<(), ExecutionError> {
688 let mut state = self.state.write().await;
689 if let Some(s) = state.take() {
690 info!(container_id = %s.id, "stopping container");
691 let _ = self
692 .docker
693 .remove_container(
694 &s.id,
695 Some(RemoveContainerOptions { force: true, ..Default::default() }),
696 )
697 .await;
698 }
699 Ok(())
700 }
701
702 async fn is_running(&self) -> bool {
703 self.state.read().await.as_ref().is_some_and(|s| s.running)
704 }
705
706 async fn execute(
707 &self,
708 request: ExecutionRequest,
709 ) -> Result<ExecutionResult, ExecutionError> {
710 let supported = [
711 ExecutionLanguage::Python,
712 ExecutionLanguage::JavaScript,
713 ExecutionLanguage::Command,
714 ];
715 validate_request(&self.capabilities(), &supported, &request)?;
716 self.config.validate_sandbox_policy(&request.sandbox)?;
717
718 let code = match &request.payload {
719 ExecutionPayload::Source { code } if code.trim().is_empty() => {
720 return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
721 }
722 ExecutionPayload::Source { code } => code.clone(),
723 ExecutionPayload::GuestModule { .. } => {
724 return Err(ExecutionError::InvalidRequest(
725 "DockerExecutor does not support guest modules".to_string(),
726 ));
727 }
728 };
729
730 if self.config.auto_start && !self.is_running().await {
732 self.start().await?;
733 }
734
735 let (container_id, filename) = {
737 let mut state = self.state.write().await;
738 let s = state.as_mut().ok_or_else(|| {
739 ExecutionError::ExecutionFailed(
740 "container not started — call start() first".to_string(),
741 )
742 })?;
743 s.file_counter += 1;
744 let ext = Self::file_extension(&request.language);
745 let filename = format!("{}/code_{}.{ext}", self.config.work_dir, s.file_counter);
746 (s.id.clone(), filename)
747 };
748
749 let start = Instant::now();
750
751 self.write_file(&container_id, &filename, &code).await?;
753
754 if let Some(ref input) = request.input {
756 let input_json = serde_json::to_string(input).unwrap_or_default();
757 let input_path = format!("{}/input.json", self.config.work_dir);
758 self.write_file(&container_id, &input_path, &input_json).await?;
759 }
760
761 let exec_cmd = Self::exec_command(&request.language, &filename);
763 if exec_cmd.is_empty() {
764 return Err(ExecutionError::UnsupportedLanguage(format!("{}", request.language)));
765 }
766
767 debug!(
768 container_id = %container_id,
769 language = %request.language,
770 filename = %filename,
771 "executing code in container"
772 );
773
774 let exec_env = host_process::allowlisted_host_env(&request.sandbox.environment)
776 .into_iter()
777 .map(|(name, value)| format!("{name}={}", value.to_string_lossy()))
778 .collect::<Vec<_>>();
779
780 let (stdout, stderr, exit_code) = self
782 .exec_in_container(
783 &container_id,
784 &exec_cmd,
785 Some(exec_env),
786 Some(request.sandbox.timeout),
787 )
788 .await?;
789
790 let duration_ms = start.elapsed().as_millis() as u64;
791
792 let (stdout, stdout_truncated) =
793 truncate_output(stdout, request.sandbox.max_stdout_bytes);
794 let (stderr, stderr_truncated) =
795 truncate_output(stderr, request.sandbox.max_stderr_bytes);
796
797 let (structured_output, display_stdout) = extract_structured_output(&stdout);
798
799 let status = match exit_code {
800 Some(0) => ExecutionStatus::Success,
801 _ => ExecutionStatus::Failed,
802 };
803
804 info!(
805 exit_code = ?exit_code,
806 duration_ms,
807 has_structured_output = structured_output.is_some(),
808 "container execution completed"
809 );
810
811 Ok(ExecutionResult {
812 status,
813 stdout: display_stdout,
814 stderr,
815 output: structured_output,
816 exit_code: exit_code.map(|c| c as i32),
817 stdout_truncated,
818 stderr_truncated,
819 duration_ms,
820 metadata: None,
821 })
822 }
823 }
824
825 impl Drop for DockerExecutor {
826 fn drop(&mut self) {
827 if self.config.auto_remove {
828 if let Some(state) = self.state.get_mut().take() {
831 let docker = self.docker.clone();
832 let container_id = state.id;
833 match tokio::runtime::Handle::try_current() {
834 Ok(handle) => {
835 handle.spawn(async move {
836 let _ = docker
837 .remove_container(
838 &container_id,
839 Some(RemoveContainerOptions {
840 force: true,
841 ..Default::default()
842 }),
843 )
844 .await;
845 });
846 }
847 Err(_) => {
848 tracing::warn!(
849 container_id = %container_id,
850 "no tokio runtime available during DockerExecutor drop, \
851 container may leak. Call cleanup() explicitly before dropping."
852 );
853 }
854 }
855 }
856 }
857 }
858 }
859
860 fn base64_encode(data: &[u8]) -> String {
862 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
863 let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
864 for chunk in data.chunks(3) {
865 let b0 = chunk[0] as u32;
866 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
867 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
868 let triple = (b0 << 16) | (b1 << 8) | b2;
869 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
870 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
871 if chunk.len() > 1 {
872 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
873 } else {
874 result.push('=');
875 }
876 if chunk.len() > 2 {
877 result.push(CHARS[(triple & 0x3F) as usize] as char);
878 } else {
879 result.push('=');
880 }
881 }
882 result
883 }
884}
885
886#[cfg(feature = "docker")]
887pub use docker_impl::DockerExecutor;
888
889#[derive(Debug, Clone)]
906pub struct ContainerConfig {
907 pub runtime: String,
909 pub default_image: String,
911 pub extra_flags: Vec<String>,
913 pub auto_remove: bool,
915}
916
917impl Default for ContainerConfig {
918 fn default() -> Self {
919 Self {
920 runtime: "docker".to_string(),
921 default_image: "python:3.12-slim".to_string(),
922 extra_flags: vec![],
923 auto_remove: true,
924 }
925 }
926}
927
928#[derive(Debug, Clone)]
946pub struct ContainerCommandExecutor {
947 config: ContainerConfig,
948}
949
950impl ContainerCommandExecutor {
951 pub fn new(config: ContainerConfig) -> Self {
953 Self { config }
954 }
955
956 fn build_run_args(&self, request: &ExecutionRequest) -> Vec<String> {
958 let mut args = vec!["run".to_string()];
959
960 if self.config.auto_remove {
961 args.push("--rm".to_string());
962 }
963
964 args.push("-i".to_string());
965
966 match request.sandbox.network {
967 NetworkPolicy::Disabled => {
968 args.push("--network=none".to_string());
969 }
970 NetworkPolicy::Enabled => {}
971 }
972
973 match &request.sandbox.filesystem {
974 FilesystemPolicy::None => {}
975 FilesystemPolicy::WorkspaceReadOnly { root } => {
976 args.push("-v".to_string());
977 args.push(format!("{}:/workspace:ro", root.display()));
978 }
979 FilesystemPolicy::WorkspaceReadWrite { root } => {
980 args.push("-v".to_string());
981 args.push(format!("{}:/workspace:rw", root.display()));
982 }
983 FilesystemPolicy::Paths { read_only, read_write } => {
984 for path in read_only {
985 args.push("-v".to_string());
986 args.push(format!("{}:{}:ro", path.display(), path.display()));
987 }
988 for path in read_write {
989 args.push("-v".to_string());
990 args.push(format!("{}:{}:rw", path.display(), path.display()));
991 }
992 }
993 }
994
995 if let EnvironmentPolicy::AllowList(vars) = &request.sandbox.environment {
996 for var in vars {
997 args.push("--env".to_string());
998 args.push(var.clone());
999 }
1000 }
1001
1002 if let Some(ref wd) = request.sandbox.working_directory {
1003 args.push("-w".to_string());
1004 args.push(wd.display().to_string());
1005 }
1006
1007 args.extend(self.config.extra_flags.clone());
1008 args.push(self.config.default_image.clone());
1009
1010 let code = match &request.payload {
1011 ExecutionPayload::Source { code } => code.clone(),
1012 ExecutionPayload::GuestModule { .. } => String::new(),
1013 };
1014
1015 match request.language {
1016 ExecutionLanguage::Python => {
1017 args.push("python3".to_string());
1018 args.push("-c".to_string());
1019 args.push(code);
1020 }
1021 ExecutionLanguage::JavaScript => {
1022 args.push("node".to_string());
1023 args.push("-e".to_string());
1024 args.push(code);
1025 }
1026 ExecutionLanguage::Command => {
1027 args.push("sh".to_string());
1028 args.push("-c".to_string());
1029 args.push(code);
1030 }
1031 _ => {}
1032 }
1033
1034 args.extend(request.argv.clone());
1035 args
1036 }
1037}
1038
1039impl Default for ContainerCommandExecutor {
1040 fn default() -> Self {
1041 Self::new(ContainerConfig::default())
1042 }
1043}
1044
1045#[async_trait]
1046impl CodeExecutor for ContainerCommandExecutor {
1047 fn name(&self) -> &str {
1048 "container-command"
1049 }
1050
1051 fn capabilities(&self) -> BackendCapabilities {
1052 BackendCapabilities {
1053 isolation: ExecutionIsolation::ContainerEphemeral,
1054 enforce_network_policy: true,
1055 enforce_filesystem_policy: true,
1056 enforce_environment_policy: true,
1057 enforce_timeout: true,
1058 supports_structured_output: true,
1059 supports_process_execution: true,
1060 supports_persistent_workspace: false,
1061 supports_interactive_sessions: false,
1062 }
1063 }
1064
1065 fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
1066 matches!(
1067 lang,
1068 ExecutionLanguage::Python | ExecutionLanguage::JavaScript | ExecutionLanguage::Command
1069 )
1070 }
1071
1072 async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
1073 let supported =
1074 [ExecutionLanguage::Python, ExecutionLanguage::JavaScript, ExecutionLanguage::Command];
1075 validate_request(&self.capabilities(), &supported, &request)?;
1076
1077 match &request.payload {
1078 ExecutionPayload::Source { code } if code.trim().is_empty() => {
1079 return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
1080 }
1081 ExecutionPayload::Source { .. } => {}
1082 ExecutionPayload::GuestModule { .. } => {
1083 return Err(ExecutionError::InvalidRequest(
1084 "ContainerCommandExecutor does not support guest modules".to_string(),
1085 ));
1086 }
1087 }
1088
1089 let start = Instant::now();
1090 let run_args = self.build_run_args(&request);
1091
1092 debug!(
1093 runtime = %self.config.runtime,
1094 image = %self.config.default_image,
1095 language = %request.language,
1096 "starting container execution"
1097 );
1098
1099 let stdin = match (&request.input, &request.stdin) {
1101 (Some(input), _) => Some(serde_json::to_vec(input).unwrap_or_default()),
1102 (None, Some(raw)) => Some(raw.clone()),
1103 (None, None) => None,
1104 };
1105
1106 let mut cmd = tokio::process::Command::new(&self.config.runtime);
1107 cmd.args(&run_args)
1108 .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
1109 .stdout(Stdio::piped())
1110 .stderr(Stdio::piped())
1111 .kill_on_drop(true);
1112
1113 let child = cmd.spawn().map_err(|e| {
1114 ExecutionError::ExecutionFailed(format!(
1115 "failed to spawn container runtime '{}': {e}",
1116 self.config.runtime
1117 ))
1118 })?;
1119
1120 let completed = host_process::run_to_completion(
1121 child,
1122 stdin,
1123 request.sandbox.timeout,
1124 request.sandbox.max_stdout_bytes,
1125 request.sandbox.max_stderr_bytes,
1126 )
1127 .await
1128 .map_err(|e| {
1129 ExecutionError::ExecutionFailed(format!("failed to wait for container: {e}"))
1130 })?;
1131
1132 let Some(output) = completed else {
1133 warn!("container execution timed out");
1134 return Ok(ExecutionResult {
1135 status: ExecutionStatus::Timeout,
1136 stdout: String::new(),
1137 stderr: String::new(),
1138 output: None,
1139 exit_code: None,
1140 stdout_truncated: false,
1141 stderr_truncated: false,
1142 duration_ms: start.elapsed().as_millis() as u64,
1143 metadata: None,
1144 });
1145 };
1146
1147 let duration_ms = start.elapsed().as_millis() as u64;
1148
1149 let raw_stdout = String::from_utf8_lossy(&output.stdout.bytes).into_owned();
1150 let raw_stderr = String::from_utf8_lossy(&output.stderr.bytes).into_owned();
1151
1152 let (stdout, stdout_truncated) =
1153 truncate_output(raw_stdout, request.sandbox.max_stdout_bytes);
1154 let (stderr, stderr_truncated) =
1155 truncate_output(raw_stderr, request.sandbox.max_stderr_bytes);
1156 let stdout_truncated = stdout_truncated || output.stdout.truncated;
1157 let stderr_truncated = stderr_truncated || output.stderr.truncated;
1158
1159 let (structured_output, display_stdout) = extract_structured_output(&stdout);
1160
1161 let status = if output.status.success() {
1162 ExecutionStatus::Success
1163 } else {
1164 ExecutionStatus::Failed
1165 };
1166
1167 info!(
1168 exit_code = output.status.code(),
1169 duration_ms,
1170 has_structured_output = structured_output.is_some(),
1171 "container execution completed"
1172 );
1173
1174 Ok(ExecutionResult {
1175 status,
1176 stdout: display_stdout,
1177 stderr,
1178 output: structured_output,
1179 exit_code: output.status.code(),
1180 stdout_truncated,
1181 stderr_truncated,
1182 duration_ms,
1183 metadata: None,
1184 })
1185 }
1186}
1187
1188fn truncate_output(output: String, max_bytes: usize) -> (String, bool) {
1192 if output.len() <= max_bytes {
1193 (output, false)
1194 } else {
1195 let truncated = output
1196 .char_indices()
1197 .take_while(|(i, _)| *i < max_bytes)
1198 .map(|(_, c)| c)
1199 .collect::<String>();
1200 (truncated, true)
1201 }
1202}
1203
1204fn extract_structured_output(stdout: &str) -> (Option<serde_json::Value>, String) {
1206 let trimmed = stdout.trim_end();
1207 if trimmed.is_empty() {
1208 return (None, String::new());
1209 }
1210
1211 if let Some(last_newline_pos) = trimmed.rfind('\n') {
1212 let last_line = &trimmed[last_newline_pos + 1..];
1213 let before = &trimmed[..last_newline_pos];
1214
1215 if let Ok(value) = serde_json::from_str::<serde_json::Value>(last_line) {
1216 return (Some(value), before.to_string());
1217 }
1218 } else if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1219 return (Some(value), String::new());
1220 }
1221
1222 (None, stdout.to_string())
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 #[test]
1230 fn capabilities_are_container_ephemeral() {
1231 let executor = ContainerCommandExecutor::default();
1232 let caps = executor.capabilities();
1233 assert_eq!(caps.isolation, ExecutionIsolation::ContainerEphemeral);
1234 assert!(caps.enforce_network_policy);
1235 assert!(caps.enforce_filesystem_policy);
1236 assert!(caps.enforce_environment_policy);
1237 assert!(caps.enforce_timeout);
1238 assert!(caps.supports_structured_output);
1239 assert!(caps.supports_process_execution);
1240 assert!(!caps.supports_persistent_workspace);
1241 assert!(!caps.supports_interactive_sessions);
1242 }
1243
1244 #[test]
1245 fn supports_python_js_command() {
1246 let executor = ContainerCommandExecutor::default();
1247 assert!(executor.supports_language(&ExecutionLanguage::Python));
1248 assert!(executor.supports_language(&ExecutionLanguage::JavaScript));
1249 assert!(executor.supports_language(&ExecutionLanguage::Command));
1250 assert!(!executor.supports_language(&ExecutionLanguage::Rust));
1251 assert!(!executor.supports_language(&ExecutionLanguage::Wasm));
1252 }
1253
1254 #[test]
1255 fn default_config() {
1256 let config = ContainerConfig::default();
1257 assert_eq!(config.runtime, "docker");
1258 assert_eq!(config.default_image, "python:3.12-slim");
1259 assert!(config.extra_flags.is_empty());
1260 assert!(config.auto_remove);
1261 }
1262
1263 #[test]
1264 fn build_run_args_basic_python() {
1265 let executor = ContainerCommandExecutor::default();
1266 let request = ExecutionRequest {
1267 language: ExecutionLanguage::Python,
1268 payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1269 argv: vec![],
1270 stdin: None,
1271 input: None,
1272 sandbox: crate::SandboxPolicy::strict_rust(),
1273 identity: None,
1274 };
1275
1276 let args = executor.build_run_args(&request);
1277 assert!(args.contains(&"run".to_string()));
1278 assert!(args.contains(&"--rm".to_string()));
1279 assert!(args.contains(&"-i".to_string()));
1280 assert!(args.contains(&"--network=none".to_string()));
1281 assert!(args.contains(&"python3".to_string()));
1282 assert!(args.contains(&"-c".to_string()));
1283 assert!(args.contains(&"print('hello')".to_string()));
1284 }
1285
1286 #[test]
1287 fn build_run_args_with_network_enabled() {
1288 let executor = ContainerCommandExecutor::default();
1289 let mut sandbox = crate::SandboxPolicy::strict_rust();
1290 sandbox.network = NetworkPolicy::Enabled;
1291
1292 let request = ExecutionRequest {
1293 language: ExecutionLanguage::Python,
1294 payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1295 argv: vec![],
1296 stdin: None,
1297 input: None,
1298 sandbox,
1299 identity: None,
1300 };
1301
1302 let args = executor.build_run_args(&request);
1303 assert!(!args.contains(&"--network=none".to_string()));
1304 }
1305
1306 #[test]
1307 fn docker_config_presets() {
1308 let py = DockerConfig::python();
1309 assert_eq!(py.image, "python:3.12-slim");
1310 assert!(py.network_disabled);
1311
1312 let node = DockerConfig::node();
1313 assert_eq!(node.image, "node:20-slim");
1314
1315 let custom = DockerConfig::custom("ubuntu:24.04");
1316 assert_eq!(custom.image, "ubuntu:24.04");
1317 }
1318
1319 fn policy(
1320 network: NetworkPolicy,
1321 filesystem: FilesystemPolicy,
1322 environment: EnvironmentPolicy,
1323 ) -> SandboxPolicy {
1324 SandboxPolicy { network, filesystem, environment, ..SandboxPolicy::strict_rust() }
1325 }
1326
1327 fn assert_rejected(config: &DockerConfig, policy: &SandboxPolicy, mentions: &str) {
1328 match config.validate_sandbox_policy(policy) {
1329 Err(ExecutionError::UnsupportedPolicy(message)) => {
1330 assert!(message.contains(mentions), "{message}");
1331 }
1332 other => panic!("expected UnsupportedPolicy mentioning `{mentions}`, got {other:?}"),
1333 }
1334 }
1335
1336 #[test]
1337 fn docker_policy_default_config_satisfies_strict_policy() {
1338 assert_eq!(
1339 DockerConfig::python().validate_sandbox_policy(&SandboxPolicy::strict_rust()),
1340 Ok(())
1341 );
1342 }
1343
1344 #[test]
1345 fn docker_policy_rejects_disabled_network_on_networked_container() {
1346 let config = DockerConfig::python().with_network();
1347 assert_rejected(&config, &SandboxPolicy::strict_rust(), "network");
1348
1349 let allows_network =
1350 policy(NetworkPolicy::Enabled, FilesystemPolicy::None, EnvironmentPolicy::None);
1351 assert_eq!(config.validate_sandbox_policy(&allows_network), Ok(()));
1352 }
1353
1354 #[test]
1355 fn docker_policy_allows_enabled_network_on_isolated_container() {
1356 let allows_network =
1357 policy(NetworkPolicy::Enabled, FilesystemPolicy::None, EnvironmentPolicy::None);
1358 assert_eq!(DockerConfig::python().validate_sandbox_policy(&allows_network), Ok(()));
1359 }
1360
1361 #[test]
1362 fn docker_policy_rejects_any_mount_without_filesystem_access() {
1363 let config = DockerConfig::python().bind_mount("/srv/data:/data:ro");
1364 assert_rejected(&config, &SandboxPolicy::strict_rust(), "/srv/data:/data:ro");
1365 }
1366
1367 #[test]
1368 fn docker_policy_read_only_workspace_requires_read_only_mounts_under_root() {
1369 let read_only = policy(
1370 NetworkPolicy::Disabled,
1371 FilesystemPolicy::WorkspaceReadOnly { root: PathBuf::from("/srv") },
1372 EnvironmentPolicy::None,
1373 );
1374
1375 let ro_under_root = DockerConfig::python().bind_mount("/srv/data:/data:ro");
1376 assert_eq!(ro_under_root.validate_sandbox_policy(&read_only), Ok(()));
1377
1378 let rw_under_root = DockerConfig::python().bind_mount("/srv/data:/data");
1379 assert_rejected(&rw_under_root, &read_only, "/srv/data:/data");
1380
1381 let ro_outside_root = DockerConfig::python().bind_mount("/home/user/.aws:/aws:ro");
1382 assert_rejected(&ro_outside_root, &read_only, "/home/user/.aws");
1383
1384 let escapes_root = DockerConfig::python().bind_mount("/srv/../etc:/etc-host:ro");
1385 assert_rejected(&escapes_root, &read_only, "/srv/../etc");
1386
1387 let sibling_prefix = DockerConfig::python().bind_mount("/srv-secrets:/s:ro");
1388 assert_rejected(&sibling_prefix, &read_only, "/srv-secrets");
1389
1390 let named_volume = DockerConfig::python().bind_mount("cache:/cache:ro");
1391 assert_rejected(&named_volume, &read_only, "cache:/cache:ro");
1392 }
1393
1394 #[test]
1395 fn docker_policy_read_write_workspace_accepts_any_mode_under_root() {
1396 let read_write = policy(
1397 NetworkPolicy::Disabled,
1398 FilesystemPolicy::WorkspaceReadWrite { root: PathBuf::from("/srv") },
1399 EnvironmentPolicy::None,
1400 );
1401 let config = DockerConfig::python()
1402 .bind_mount("/srv/data:/data")
1403 .bind_mount("/srv/models:/models:ro");
1404 assert_eq!(config.validate_sandbox_policy(&read_write), Ok(()));
1405 }
1406
1407 #[test]
1408 fn docker_policy_explicit_paths_match_mode() {
1409 let paths = policy(
1410 NetworkPolicy::Disabled,
1411 FilesystemPolicy::Paths {
1412 read_only: vec![PathBuf::from("/srv/models")],
1413 read_write: vec![PathBuf::from("/srv/out")],
1414 },
1415 EnvironmentPolicy::None,
1416 );
1417
1418 let config = DockerConfig::python()
1419 .bind_mount("/srv/models:/models:ro,z")
1420 .bind_mount("/srv/out:/out:rw");
1421 assert_eq!(config.validate_sandbox_policy(&paths), Ok(()));
1422
1423 let writable_read_only_path = DockerConfig::python().bind_mount("/srv/models:/models");
1424 assert_rejected(&writable_read_only_path, &paths, "/srv/models:/models");
1425 }
1426
1427 #[test]
1428 fn docker_policy_container_environment_must_be_allowlisted() {
1429 let config = DockerConfig::python().env("API_TOKEN=secret").env("MODE");
1430 assert_rejected(&config, &SandboxPolicy::strict_rust(), "API_TOKEN");
1431
1432 let only_mode = policy(
1433 NetworkPolicy::Disabled,
1434 FilesystemPolicy::None,
1435 EnvironmentPolicy::AllowList(vec!["MODE".to_string()]),
1436 );
1437 assert_rejected(&config, &only_mode, "API_TOKEN");
1438
1439 let both = policy(
1440 NetworkPolicy::Disabled,
1441 FilesystemPolicy::None,
1442 EnvironmentPolicy::AllowList(vec!["API_TOKEN".to_string(), "MODE".to_string()]),
1443 );
1444 assert_eq!(config.validate_sandbox_policy(&both), Ok(()));
1445 }
1446
1447 #[test]
1448 fn docker_config_builder_methods() {
1449 let config = DockerConfig::python()
1450 .pip_install(&["numpy", "pandas"])
1451 .with_network()
1452 .env("MY_VAR=hello")
1453 .bind_mount("/host/data:/data:ro");
1454
1455 assert!(!config.network_disabled);
1456 assert_eq!(config.setup_commands.len(), 1);
1457 assert!(config.setup_commands[0].contains("numpy"));
1458 assert_eq!(config.environment, vec!["MY_VAR=hello"]);
1459 assert_eq!(config.bind_mounts, vec!["/host/data:/data:ro"]);
1460 }
1461}