1use 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#[derive(Debug, Clone)]
90pub struct DockerConfig {
91 pub image: String,
93 pub work_dir: String,
95 pub setup_commands: Vec<String>,
97 pub environment: Vec<String>,
99 pub bind_mounts: Vec<String>,
101 pub network_disabled: bool,
103 pub container_name_prefix: String,
105 pub auto_start: bool,
107 pub auto_remove: bool,
109}
110
111impl DockerConfig {
112 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 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 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 pub fn setup_command(mut self, cmd: impl Into<String>) -> Self {
159 self.setup_commands.push(cmd.into());
160 self
161 }
162
163 pub fn pip_install(self, packages: &[&str]) -> Self {
165 self.setup_command(format!("pip install --quiet {}", packages.join(" ")))
166 }
167
168 pub fn npm_install(self, packages: &[&str]) -> Self {
170 self.setup_command(format!("npm install --silent {}", packages.join(" ")))
171 }
172
173 pub fn with_network(mut self) -> Self {
175 self.network_disabled = false;
176 self
177 }
178
179 pub fn bind_mount(mut self, mount: impl Into<String>) -> Self {
181 self.bind_mounts.push(mount.into());
182 self
183 }
184
185 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#[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 #[derive(Debug)]
214 struct ContainerState {
215 id: String,
217 running: bool,
219 file_counter: u64,
221 }
222
223 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 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 pub fn with_docker(config: DockerConfig, docker: Docker) -> Self {
282 Self { config, docker, state: RwLock::new(None) }
283 }
284
285 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 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 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 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 async fn write_file(
342 &self,
343 container_id: &str,
344 path: &str,
345 content: &str,
346 ) -> Result<(), ExecutionError> {
347 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 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 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 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 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 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 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 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 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 if self.config.auto_start && !self.is_running().await {
599 self.start().await?;
600 }
601
602 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 self.write_file(&container_id, &filename, &code).await?;
620
621 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 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 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 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 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#[derive(Debug, Clone)]
766pub struct ContainerConfig {
767 pub runtime: String,
769 pub default_image: String,
771 pub extra_flags: Vec<String>,
773 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#[derive(Debug, Clone)]
806pub struct ContainerCommandExecutor {
807 config: ContainerConfig,
808}
809
810impl ContainerCommandExecutor {
811 pub fn new(config: ContainerConfig) -> Self {
813 Self { config }
814 }
815
816 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
1057fn 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
1073fn 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}