1use std::env;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use crate::types::RunnerError;
13use tokio::fs;
14use tokio::process::Command;
15use tracing::{debug, warn};
16
17use crate::process::{run_cli_command, CliOutput};
18
19const ENV_CONTAINER_IMAGE: &str = "CLI_LLM_CONTAINER_IMAGE";
21const ENV_CONTAINER_MEMORY: &str = "CLI_LLM_CONTAINER_MEMORY";
23const ENV_CONTAINER_PIDS_LIMIT: &str = "CLI_LLM_CONTAINER_PIDS_LIMIT";
25const ENV_CONTAINER_NETWORK: &str = "CLI_LLM_CONTAINER_NETWORK";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum NetworkMode {
31 None,
33 Host,
35 Custom(String),
37}
38
39impl fmt::Display for NetworkMode {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::None => write!(f, "none"),
43 Self::Host => write!(f, "host"),
44 Self::Custom(name) => write!(f, "{name}"),
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
51pub struct Mount {
52 pub source: PathBuf,
54 pub target: PathBuf,
56 pub read_only: bool,
58}
59
60#[derive(Debug, Clone)]
62pub struct ContainerConfig {
63 pub image: String,
65 pub memory_limit: Option<String>,
67 pub pids_limit: Option<u32>,
69 pub network_mode: NetworkMode,
71 pub extra_mounts: Vec<Mount>,
73 pub env_vars: Vec<(String, String)>,
75}
76
77impl ContainerConfig {
78 pub fn from_env() -> Result<Self, RunnerError> {
91 let image = env::var(ENV_CONTAINER_IMAGE).map_err(|_| {
92 RunnerError::internal(format!(
93 "{ENV_CONTAINER_IMAGE} environment variable is required"
94 ))
95 })?;
96
97 let memory_limit = env::var(ENV_CONTAINER_MEMORY).ok();
98
99 let pids_limit = match env::var(ENV_CONTAINER_PIDS_LIMIT) {
100 Ok(val) => {
101 let parsed = val.trim().parse::<u32>().map_err(|e| {
102 RunnerError::internal(format!(
103 "{ENV_CONTAINER_PIDS_LIMIT} is not a valid u32: {e}"
104 ))
105 })?;
106 Some(parsed)
107 }
108 Err(_) => Option::None,
109 };
110
111 let network_mode =
112 env::var(ENV_CONTAINER_NETWORK).map_or(NetworkMode::None, |val| {
113 match val.trim().to_lowercase().as_str() {
114 "none" => NetworkMode::None,
115 "host" => NetworkMode::Host,
116 other => NetworkMode::Custom(other.to_owned()),
117 }
118 });
119
120 Ok(Self {
121 image,
122 memory_limit,
123 pids_limit,
124 network_mode,
125 extra_mounts: Vec::new(),
126 env_vars: Vec::new(),
127 })
128 }
129}
130
131#[derive(Debug, Clone)]
138pub struct ContainerExecutor {
139 config: ContainerConfig,
141}
142
143impl ContainerExecutor {
144 #[must_use]
146 pub const fn new(config: ContainerConfig) -> Self {
147 Self { config }
148 }
149
150 pub async fn execute(
169 &self,
170 binary_name: &str,
171 args: &[&str],
172 input: Option<&str>,
173 timeout: Duration,
174 max_output_bytes: usize,
175 ) -> Result<CliOutput, RunnerError> {
176 let scratch_dir = tempfile::tempdir().map_err(|e| {
177 RunnerError::internal(format!("Failed to create scratch directory: {e}"))
178 })?;
179
180 let scratch_path = scratch_dir.path();
181
182 let stdin_file_container_path = if let Some(content) = input {
184 let stdin_path = scratch_path.join("stdin.txt");
185 fs::write(&stdin_path, content)
186 .await
187 .map_err(|e| RunnerError::internal(format!("Failed to write stdin file: {e}")))?;
188 Some("/scratch/stdin.txt".to_owned())
189 } else {
190 Option::None
191 };
192
193 let docker_args = build_docker_args(
194 &self.config,
195 scratch_path,
196 binary_name,
197 args,
198 stdin_file_container_path.as_deref(),
199 );
200
201 debug!(
202 image = %self.config.image,
203 binary = binary_name,
204 scratch = %scratch_path.display(),
205 "Launching container"
206 );
207
208 let mut cmd = Command::new("docker");
209 cmd.args(&docker_args);
210
211 let result = run_cli_command(&mut cmd, timeout, max_output_bytes).await;
212
213 if let Err(e) = scratch_dir.close() {
215 warn!("Failed to clean up scratch directory: {e}");
216 }
217
218 result
219 }
220}
221
222fn build_docker_args(
224 config: &ContainerConfig,
225 scratch_path: &Path,
226 binary_name: &str,
227 args: &[&str],
228 stdin_file_container_path: Option<&str>,
229) -> Vec<String> {
230 let mut docker_args: Vec<String> = vec![
231 "run".to_owned(),
232 "--rm".to_owned(),
233 "--read-only".to_owned(),
234 "--cap-drop=ALL".to_owned(),
235 "--security-opt=no-new-privileges".to_owned(),
236 ];
237
238 if let Some(ref mem) = config.memory_limit {
240 docker_args.push(format!("--memory={mem}"));
241 }
242
243 if let Some(pids) = config.pids_limit {
245 docker_args.push(format!("--pids-limit={pids}"));
246 }
247
248 docker_args.push(format!("--network={}", config.network_mode));
250
251 docker_args.push("-v".to_owned());
253 docker_args.push(format!("{}:/scratch", scratch_path.display()));
254
255 for mount in &config.extra_mounts {
257 docker_args.push("-v".to_owned());
258 let ro_suffix = if mount.read_only { ":ro" } else { "" };
259 docker_args.push(format!(
260 "{}:{}{}",
261 mount.source.display(),
262 mount.target.display(),
263 ro_suffix
264 ));
265 }
266
267 for (key, value) in &config.env_vars {
269 docker_args.push("-e".to_owned());
270 docker_args.push(format!("{key}={value}"));
271 }
272
273 if let Some(stdin_path) = stdin_file_container_path {
275 docker_args.push("-i".to_owned());
276 docker_args.push(config.image.clone());
277 docker_args.push("sh".to_owned());
278 docker_args.push("-c".to_owned());
279
280 let escaped_args: Vec<String> = args.iter().map(|a| shell_escape(a)).collect();
281 let cmd_str = format!("{binary_name} {} < {stdin_path}", escaped_args.join(" "));
282 docker_args.push(cmd_str);
283 } else {
284 docker_args.push(config.image.clone());
285 docker_args.push(binary_name.to_owned());
286 docker_args.extend(args.iter().map(|a| (*a).to_owned()));
287 }
288
289 docker_args
290}
291
292fn shell_escape(arg: &str) -> String {
295 if arg.is_empty() {
296 return "''".to_owned();
297 }
298 format!("'{}'", arg.replace('\'', "'\\''"))
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn base_config() -> ContainerConfig {
307 ContainerConfig {
308 image: "ghcr.io/test/runner:latest".to_owned(),
309 memory_limit: None,
310 pids_limit: None,
311 network_mode: NetworkMode::None,
312 extra_mounts: Vec::new(),
313 env_vars: Vec::new(),
314 }
315 }
316
317 #[test]
318 fn test_docker_args_security_hardening() {
319 let config = base_config();
320 let args = build_docker_args(
321 &config,
322 Path::new("/tmp/scratch"),
323 "claude",
324 &["-p", "hi"],
325 None,
326 );
327
328 assert!(args.contains(&"--rm".to_owned()));
329 assert!(args.contains(&"--read-only".to_owned()));
330 assert!(args.contains(&"--cap-drop=ALL".to_owned()));
331 assert!(args.contains(&"--security-opt=no-new-privileges".to_owned()));
332 }
333
334 #[test]
335 fn test_docker_args_memory_and_pids_limits() {
336 let mut config = base_config();
337 config.memory_limit = Some("512m".to_owned());
338 config.pids_limit = Some(100);
339
340 let args = build_docker_args(&config, Path::new("/tmp/scratch"), "claude", &[], None);
341
342 assert!(args.contains(&"--memory=512m".to_owned()));
343 assert!(args.contains(&"--pids-limit=100".to_owned()));
344 }
345
346 #[test]
347 fn test_docker_args_network_modes() {
348 let mut config = base_config();
349
350 config.network_mode = NetworkMode::None;
351 let args = build_docker_args(&config, Path::new("/tmp/s"), "claude", &[], None);
352 assert!(args.contains(&"--network=none".to_owned()));
353
354 config.network_mode = NetworkMode::Host;
355 let args = build_docker_args(&config, Path::new("/tmp/s"), "claude", &[], None);
356 assert!(args.contains(&"--network=host".to_owned()));
357
358 config.network_mode = NetworkMode::Custom("my-net".to_owned());
359 let args = build_docker_args(&config, Path::new("/tmp/s"), "claude", &[], None);
360 assert!(args.contains(&"--network=my-net".to_owned()));
361 }
362
363 #[test]
364 fn test_docker_args_extra_mounts() {
365 let mut config = base_config();
366 config.extra_mounts = vec![
367 Mount {
368 source: PathBuf::from("/host/data"),
369 target: PathBuf::from("/container/data"),
370 read_only: true,
371 },
372 Mount {
373 source: PathBuf::from("/host/work"),
374 target: PathBuf::from("/container/work"),
375 read_only: false,
376 },
377 ];
378
379 let args = build_docker_args(&config, Path::new("/tmp/scratch"), "claude", &[], None);
380
381 assert!(args.contains(&"/host/data:/container/data:ro".to_owned()));
382 assert!(args.contains(&"/host/work:/container/work".to_owned()));
383 }
384
385 #[test]
386 fn test_docker_args_env_vars() {
387 let mut config = base_config();
388 config.env_vars = vec![("API_KEY".to_owned(), "secret123".to_owned())];
389
390 let args = build_docker_args(&config, Path::new("/tmp/scratch"), "claude", &[], None);
391
392 assert!(args.contains(&"-e".to_owned()));
393 assert!(args.contains(&"API_KEY=secret123".to_owned()));
394 }
395
396 #[test]
397 fn test_docker_args_with_stdin_redirect() {
398 let config = base_config();
399 let args = build_docker_args(
400 &config,
401 Path::new("/tmp/scratch"),
402 "claude",
403 &["-p", "hello"],
404 Some("/scratch/stdin.txt"),
405 );
406
407 assert!(args.contains(&"-i".to_owned()));
408 assert!(args.contains(&"sh".to_owned()));
409 assert!(args.contains(&"-c".to_owned()));
410 let last = args.last().unwrap(); assert!(last.contains("< /scratch/stdin.txt"));
413 assert!(last.contains("claude"));
414 }
415
416 #[test]
417 fn test_docker_args_without_stdin() {
418 let config = base_config();
419 let args = build_docker_args(
420 &config,
421 Path::new("/tmp/scratch"),
422 "claude",
423 &["-p", "hello"],
424 None,
425 );
426
427 assert!(args.contains(&"ghcr.io/test/runner:latest".to_owned()));
429 assert!(args.contains(&"claude".to_owned()));
430 assert!(args.contains(&"-p".to_owned()));
431 assert!(args.contains(&"hello".to_owned()));
432 assert!(!args.contains(&"sh".to_owned()));
434 }
435
436 #[test]
437 fn test_shell_escape_empty() {
438 assert_eq!(shell_escape(""), "''");
439 }
440
441 #[test]
442 fn test_shell_escape_simple() {
443 assert_eq!(shell_escape("hello"), "'hello'");
444 }
445
446 #[test]
447 fn test_shell_escape_single_quotes() {
448 assert_eq!(shell_escape("it's"), "'it'\\''s'");
449 }
450
451 #[test]
452 fn test_network_mode_display() {
453 assert_eq!(format!("{}", NetworkMode::None), "none");
454 assert_eq!(format!("{}", NetworkMode::Host), "host");
455 assert_eq!(
456 format!("{}", NetworkMode::Custom("my-net".to_owned())),
457 "my-net"
458 );
459 }
460}