1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use directories::BaseDirs;
6use thiserror::Error;
7
8use crate::config::default_state_root;
9use crate::manifest::WorkspaceManifest;
10
11const CONTAINER_CODEX_DIR: &str = "/root/.codex";
12const CONTAINER_SESSIONS_DIR: &str = "/root/.codex/sessions";
13const CONTAINER_SKILLS_DIR: &str = "/root/.codex/skills";
14pub const CONTAINER_WORKSPACE_ROOT: &str = "/workspace";
16
17const CODEX_SANDBOX_MODE: &str = "danger-full-access";
18
19pub const DEFAULT_CODEX_IMAGE: &str = "ghcr.io/honahec/codex-multi-workspace:latest";
21
22pub const DEFAULT_CODEX_IMAGE_VERSION: &str = "8";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DockerLaunchConfig {
28 image: String,
29 sessions_root: PathBuf,
30 skills_path: PathBuf,
31}
32
33impl DockerLaunchConfig {
34 #[must_use]
45 pub fn new(image: String, sessions_root: PathBuf) -> Self {
46 Self {
47 image,
48 sessions_root,
49 skills_path: default_skills_path_from_home()
50 .unwrap_or_else(|| PathBuf::from(".agents/skills")),
51 }
52 }
53
54 #[must_use]
60 pub fn image(&self) -> &str {
61 &self.image
62 }
63
64 #[must_use]
74 pub fn with_image(&self, image: String) -> Self {
75 Self {
76 image,
77 sessions_root: self.sessions_root.clone(),
78 skills_path: self.skills_path.clone(),
79 }
80 }
81
82 #[must_use]
88 pub fn sessions_root(&self) -> &Path {
89 &self.sessions_root
90 }
91
92 #[must_use]
98 pub fn skills_path(&self) -> &Path {
99 &self.skills_path
100 }
101
102 #[must_use]
112 pub fn with_skills_path(&self, skills_path: PathBuf) -> Self {
113 Self {
114 image: self.image.clone(),
115 sessions_root: self.sessions_root.clone(),
116 skills_path,
117 }
118 }
119
120 #[must_use]
130 pub fn workspace_sessions_path(&self, workspace_name: &str) -> PathBuf {
131 self.sessions_root().join(workspace_name).join("sessions")
132 }
133}
134
135impl Default for DockerLaunchConfig {
136 fn default() -> Self {
137 let sessions_root = default_state_root().unwrap_or_else(|_| PathBuf::from(".codex-ws"));
138 Self::new(DEFAULT_CODEX_IMAGE.to_owned(), sessions_root)
139 }
140}
141
142#[derive(Debug, Error)]
144pub enum DockerError {
145 #[error("workspace '{workspace_name}' does not contain any folders")]
147 NoWorkspaceFolders {
148 workspace_name: String,
150 },
151
152 #[error("workspace folder '{path}' does not have a usable directory name")]
154 InvalidWorkspaceFolderName {
155 path: PathBuf,
157 },
158
159 #[error("multiple workspace folders are named '{folder_name}'")]
161 DuplicateWorkspaceFolderName {
162 folder_name: String,
164 },
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct ProviderConfigFiles {
170 auth_path: PathBuf,
171 config_path: PathBuf,
172}
173
174impl ProviderConfigFiles {
175 #[must_use]
186 pub fn new(auth_path: PathBuf, config_path: PathBuf) -> Self {
187 Self {
188 auth_path,
189 config_path,
190 }
191 }
192
193 #[must_use]
199 pub fn auth_path(&self) -> &Path {
200 &self.auth_path
201 }
202
203 #[must_use]
209 pub fn config_path(&self) -> &Path {
210 &self.config_path
211 }
212}
213
214pub fn build_docker_run_command(
230 provider_files: &ProviderConfigFiles,
231 manifest: &WorkspaceManifest,
232 launch_config: &DockerLaunchConfig,
233) -> Result<Command, DockerError> {
234 let args = docker_run_args(provider_files, manifest, launch_config)?;
235 let mut command = Command::new("docker");
236 command.args(args);
237 Ok(command)
238}
239
240pub fn workspace_mount_targets(manifest: &WorkspaceManifest) -> Result<Vec<String>, DockerError> {
255 let mut seen_names = HashSet::with_capacity(manifest.folders().len());
256 let mut targets = Vec::with_capacity(manifest.folders().len());
257
258 for folder in manifest.folders() {
259 let folder_name = workspace_folder_name(folder)?;
260 if !seen_names.insert(folder_name.to_owned()) {
261 return Err(DockerError::DuplicateWorkspaceFolderName {
262 folder_name: folder_name.to_owned(),
263 });
264 }
265 targets.push(format!("{CONTAINER_WORKSPACE_ROOT}/{folder_name}"));
266 }
267
268 Ok(targets)
269}
270
271fn docker_run_args(
272 provider_files: &ProviderConfigFiles,
273 manifest: &WorkspaceManifest,
274 launch_config: &DockerLaunchConfig,
275) -> Result<Vec<String>, DockerError> {
276 if manifest.folders().is_empty() {
277 return Err(DockerError::NoWorkspaceFolders {
278 workspace_name: manifest.name().to_owned(),
279 });
280 }
281 let mount_targets = workspace_mount_targets(manifest)?;
282
283 let mut args = vec![
284 "run".to_owned(),
285 "--rm".to_owned(),
286 "-it".to_owned(),
287 "--name".to_owned(),
288 container_name(manifest.name()),
289 ];
290
291 if !manifest.sandbox().network() {
292 args.extend(["--network".to_owned(), "none".to_owned()]);
293 }
294
295 for variable in manifest.runtime().environment_variables() {
296 args.extend(["-e".to_owned(), variable.docker_assignment()]);
297 }
298
299 args.extend(volume_args(
300 provider_files.auth_path(),
301 &format!("{CONTAINER_CODEX_DIR}/auth.json"),
302 true,
303 ));
304 args.extend(volume_args(
305 provider_files.config_path(),
306 &format!("{CONTAINER_CODEX_DIR}/config.toml"),
307 false,
308 ));
309 let sessions_path = launch_config.workspace_sessions_path(manifest.name());
310 args.extend(volume_args(&sessions_path, CONTAINER_SESSIONS_DIR, false));
311 if launch_config.skills_path().is_dir() {
312 args.extend(volume_args(
313 launch_config.skills_path(),
314 CONTAINER_SKILLS_DIR,
315 true,
316 ));
317 }
318
319 for (folder, target) in manifest.folders().iter().zip(&mount_targets) {
320 args.extend(volume_args(folder, target, false));
321 }
322
323 args.push("--workdir".to_owned());
324 args.push(workdir_for_mount_targets(&mount_targets).to_owned());
325 args.push(launch_config.image().to_owned());
326 args.extend(["--sandbox".to_owned(), CODEX_SANDBOX_MODE.to_owned()]);
327
328 Ok(args)
329}
330
331fn workspace_folder_name(folder: &Path) -> Result<&str, DockerError> {
332 let Some(name) = folder.file_name().and_then(|name| name.to_str()) else {
333 return Err(DockerError::InvalidWorkspaceFolderName {
334 path: folder.to_path_buf(),
335 });
336 };
337 if name.is_empty() || name == "." || name == ".." {
338 return Err(DockerError::InvalidWorkspaceFolderName {
339 path: folder.to_path_buf(),
340 });
341 }
342
343 Ok(name)
344}
345
346fn workdir_for_mount_targets(mount_targets: &[String]) -> &str {
347 if let [target] = mount_targets {
348 return target;
349 }
350
351 CONTAINER_WORKSPACE_ROOT
352}
353
354fn volume_args(source: &Path, target: &str, read_only: bool) -> [String; 2] {
355 let mode = if read_only { ":ro" } else { "" };
356 [
357 "-v".to_owned(),
358 format!("{}:{target}{mode}", source.display()),
359 ]
360}
361
362fn container_name(workspace_name: &str) -> String {
363 let mut name = String::with_capacity("codex-ws-".len() + workspace_name.len());
364 name.push_str("codex-ws-");
365 for character in workspace_name.chars() {
366 if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
367 name.push(character);
368 } else {
369 name.push('-');
370 }
371 }
372 name
373}
374
375fn default_skills_path_from_home() -> Option<PathBuf> {
376 BaseDirs::new().map(|dirs| dirs.home_dir().join(".agents").join("skills"))
377}
378
379#[cfg(test)]
380mod tests {
381 use std::fs;
382 use std::sync::atomic::{AtomicUsize, Ordering};
383 use std::time::{SystemTime, UNIX_EPOCH};
384
385 use super::*;
386 use crate::manifest::{RuntimeConfig, SandboxConfig};
387 use crate::runtime::{RuntimeTool, RuntimeToolVersion};
388
389 static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
390
391 fn test_provider_files() -> ProviderConfigFiles {
392 ProviderConfigFiles::new(
393 PathBuf::from("/tmp/codex-ws-provider/auth.json"),
394 PathBuf::from("/tmp/codex-ws-provider/config.toml"),
395 )
396 }
397
398 fn test_manifest(network: bool) -> WorkspaceManifest {
399 WorkspaceManifest::new(
400 "workspace-name".to_owned(),
401 vec![
402 PathBuf::from("/projects/backend"),
403 PathBuf::from("/projects/frontend"),
404 ],
405 SandboxConfig::new(network),
406 )
407 .expect("manifest should be valid")
408 }
409
410 fn test_launch_config(skills_path: PathBuf) -> DockerLaunchConfig {
411 DockerLaunchConfig::new("codex-ws:test".to_owned(), PathBuf::from("/host/.codex-ws"))
412 .with_skills_path(skills_path)
413 }
414
415 #[test]
416 fn docker_run_args_mounts_provider_workspace_and_sessions() {
417 let temp_dir = TestTempDir::create();
418 let skills_path = temp_dir.path().join("skills");
419 fs::create_dir(&skills_path).expect("skills directory should be created");
420 let args = docker_run_args(
421 &test_provider_files(),
422 &test_manifest(false),
423 &test_launch_config(skills_path.clone()),
424 )
425 .expect("docker args should build");
426 let skills_mount = format!("{}:/root/.codex/skills:ro", skills_path.display());
427
428 assert_eq!(
429 args,
430 vec![
431 "run",
432 "--rm",
433 "-it",
434 "--name",
435 "codex-ws-workspace-name",
436 "--network",
437 "none",
438 "-v",
439 "/tmp/codex-ws-provider/auth.json:/root/.codex/auth.json:ro",
440 "-v",
441 "/tmp/codex-ws-provider/config.toml:/root/.codex/config.toml",
442 "-v",
443 "/host/.codex-ws/workspace-name/sessions:/root/.codex/sessions",
444 "-v",
445 &skills_mount,
446 "-v",
447 "/projects/backend:/workspace/backend",
448 "-v",
449 "/projects/frontend:/workspace/frontend",
450 "--workdir",
451 "/workspace",
452 "codex-ws:test",
453 "--sandbox",
454 "danger-full-access",
455 ]
456 );
457 }
458
459 #[test]
460 fn docker_run_args_uses_single_workspace_folder_as_workdir() {
461 let manifest = WorkspaceManifest::new(
462 "workspace-name".to_owned(),
463 vec![PathBuf::from("/projects/backend")],
464 SandboxConfig::default(),
465 )
466 .expect("manifest should be valid");
467
468 let args = docker_run_args(
469 &test_provider_files(),
470 &manifest,
471 &test_launch_config(PathBuf::from("/missing/skills")),
472 )
473 .expect("docker args should build");
474
475 assert!(
476 args.windows(2)
477 .any(|window| window == ["--workdir", "/workspace/backend"])
478 );
479 }
480
481 #[test]
482 fn docker_run_args_rejects_duplicate_workspace_folder_names() {
483 let manifest = WorkspaceManifest::new(
484 "workspace-name".to_owned(),
485 vec![
486 PathBuf::from("/projects/backend"),
487 PathBuf::from("/other/backend"),
488 ],
489 SandboxConfig::default(),
490 )
491 .expect("manifest should be valid");
492
493 let error = docker_run_args(
494 &test_provider_files(),
495 &manifest,
496 &test_launch_config(PathBuf::from("/missing/skills")),
497 )
498 .expect_err("duplicate workspace folder names should fail")
499 .to_string();
500
501 assert_eq!(error, "multiple workspace folders are named 'backend'");
502 }
503
504 #[test]
505 fn docker_run_args_omits_network_none_when_network_is_enabled() {
506 let args = docker_run_args(
507 &test_provider_files(),
508 &test_manifest(true),
509 &test_launch_config(PathBuf::from("/missing/skills")),
510 )
511 .expect("docker args should build");
512
513 assert!(!args.iter().any(|arg| arg == "--network"));
514 assert!(!args.iter().any(|arg| arg == "none"));
515 }
516
517 #[test]
518 fn docker_run_args_passes_runtime_environment_variables() {
519 let manifest = WorkspaceManifest::with_runtime(
520 "workspace-name".to_owned(),
521 vec![PathBuf::from("/projects/backend")],
522 SandboxConfig::default(),
523 RuntimeConfig::with_setup(
524 None,
525 vec![RuntimeToolVersion::new(
526 RuntimeTool::Python,
527 "3.13".to_owned(),
528 )],
529 vec!["python3".to_owned(), "python3-pip".to_owned()],
530 vec!["python3 -m pip install maturin".to_owned()],
531 ),
532 )
533 .expect("manifest should be valid");
534
535 let args = docker_run_args(
536 &test_provider_files(),
537 &manifest,
538 &test_launch_config(PathBuf::from("/missing/skills")),
539 )
540 .expect("docker args should build");
541
542 assert!(
543 args.windows(2)
544 .any(|window| window == ["-e", "CODEX_WS_PYTHON_VERSION=3.13"])
545 );
546 assert!(
547 args.windows(2)
548 .any(|window| window == ["-e", "CODEX_WS_APT_PACKAGES=python3 python3-pip"])
549 );
550 assert!(args.windows(2).any(|window| {
551 window
552 == [
553 "-e",
554 "CODEX_WS_SETUP_COMMANDS=python3 -m pip install maturin",
555 ]
556 }));
557 }
558
559 #[test]
560 fn docker_run_args_skips_missing_skills_directory() {
561 let args = docker_run_args(
562 &test_provider_files(),
563 &test_manifest(false),
564 &test_launch_config(PathBuf::from("/missing/skills")),
565 )
566 .expect("docker args should build");
567
568 assert!(!args.iter().any(|arg| arg.contains("/root/.codex/skills")));
569 }
570
571 #[test]
572 fn container_name_replaces_unsupported_characters() {
573 assert_eq!(
574 container_name("my workspace/main"),
575 "codex-ws-my-workspace-main"
576 );
577 }
578
579 #[derive(Debug)]
580 struct TestTempDir {
581 path: PathBuf,
582 }
583
584 impl TestTempDir {
585 fn create() -> Self {
586 let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
587 let timestamp = SystemTime::now()
588 .duration_since(UNIX_EPOCH)
589 .expect("system clock should be after Unix epoch")
590 .as_nanos();
591 let path = std::env::temp_dir().join(format!(
592 "codex-ws-docker-test-{}-{timestamp}-{counter}",
593 std::process::id()
594 ));
595 fs::create_dir(&path).expect("temporary test directory should be created");
596 Self { path }
597 }
598
599 fn path(&self) -> &Path {
600 &self.path
601 }
602 }
603
604 impl Drop for TestTempDir {
605 fn drop(&mut self) {
606 let _ = fs::remove_dir_all(&self.path);
607 }
608 }
609}