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