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 = "6";
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
388 static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
389
390 fn test_provider_files() -> ProviderConfigFiles {
391 ProviderConfigFiles::new(
392 PathBuf::from("/tmp/codex-ws-provider/auth.json"),
393 PathBuf::from("/tmp/codex-ws-provider/config.toml"),
394 )
395 }
396
397 fn test_manifest(network: bool) -> WorkspaceManifest {
398 WorkspaceManifest::new(
399 "workspace-name".to_owned(),
400 vec![
401 PathBuf::from("/projects/backend"),
402 PathBuf::from("/projects/frontend"),
403 ],
404 SandboxConfig::new(network),
405 )
406 .expect("manifest should be valid")
407 }
408
409 fn test_launch_config(skills_path: PathBuf) -> DockerLaunchConfig {
410 DockerLaunchConfig::new("codex-ws:test".to_owned(), PathBuf::from("/host/.codex-ws"))
411 .with_skills_path(skills_path)
412 }
413
414 #[test]
415 fn docker_run_args_mounts_provider_workspace_and_sessions() {
416 let temp_dir = TestTempDir::create();
417 let skills_path = temp_dir.path().join("skills");
418 fs::create_dir(&skills_path).expect("skills directory should be created");
419 let args = docker_run_args(
420 &test_provider_files(),
421 &test_manifest(false),
422 &test_launch_config(skills_path.clone()),
423 )
424 .expect("docker args should build");
425 let skills_mount = format!("{}:/root/.codex/skills:ro", skills_path.display());
426
427 assert_eq!(
428 args,
429 vec![
430 "run",
431 "--rm",
432 "-it",
433 "--name",
434 "codex-ws-workspace-name",
435 "--network",
436 "none",
437 "-v",
438 "/tmp/codex-ws-provider/auth.json:/root/.codex/auth.json:ro",
439 "-v",
440 "/tmp/codex-ws-provider/config.toml:/root/.codex/config.toml",
441 "-v",
442 "/host/.codex-ws/workspace-name/sessions:/root/.codex/sessions",
443 "-v",
444 &skills_mount,
445 "-v",
446 "/projects/backend:/workspace/backend",
447 "-v",
448 "/projects/frontend:/workspace/frontend",
449 "--workdir",
450 "/workspace",
451 "codex-ws:test",
452 "--sandbox",
453 "danger-full-access",
454 ]
455 );
456 }
457
458 #[test]
459 fn docker_run_args_uses_single_workspace_folder_as_workdir() {
460 let manifest = WorkspaceManifest::new(
461 "workspace-name".to_owned(),
462 vec![PathBuf::from("/projects/backend")],
463 SandboxConfig::default(),
464 )
465 .expect("manifest should be valid");
466
467 let args = docker_run_args(
468 &test_provider_files(),
469 &manifest,
470 &test_launch_config(PathBuf::from("/missing/skills")),
471 )
472 .expect("docker args should build");
473
474 assert!(
475 args.windows(2)
476 .any(|window| window == ["--workdir", "/workspace/backend"])
477 );
478 }
479
480 #[test]
481 fn docker_run_args_rejects_duplicate_workspace_folder_names() {
482 let manifest = WorkspaceManifest::new(
483 "workspace-name".to_owned(),
484 vec![
485 PathBuf::from("/projects/backend"),
486 PathBuf::from("/other/backend"),
487 ],
488 SandboxConfig::default(),
489 )
490 .expect("manifest should be valid");
491
492 let error = docker_run_args(
493 &test_provider_files(),
494 &manifest,
495 &test_launch_config(PathBuf::from("/missing/skills")),
496 )
497 .expect_err("duplicate workspace folder names should fail")
498 .to_string();
499
500 assert_eq!(error, "multiple workspace folders are named 'backend'");
501 }
502
503 #[test]
504 fn docker_run_args_omits_network_none_when_network_is_enabled() {
505 let args = docker_run_args(
506 &test_provider_files(),
507 &test_manifest(true),
508 &test_launch_config(PathBuf::from("/missing/skills")),
509 )
510 .expect("docker args should build");
511
512 assert!(!args.iter().any(|arg| arg == "--network"));
513 assert!(!args.iter().any(|arg| arg == "none"));
514 }
515
516 #[test]
517 fn docker_run_args_passes_runtime_environment_variables() {
518 let manifest = WorkspaceManifest::with_runtime(
519 "workspace-name".to_owned(),
520 vec![PathBuf::from("/projects/backend")],
521 SandboxConfig::default(),
522 RuntimeConfig::with_setup(
523 None,
524 vec!["python3".to_owned(), "python3-pip".to_owned()],
525 vec!["python3 -m pip install maturin".to_owned()],
526 ),
527 )
528 .expect("manifest should be valid");
529
530 let args = docker_run_args(
531 &test_provider_files(),
532 &manifest,
533 &test_launch_config(PathBuf::from("/missing/skills")),
534 )
535 .expect("docker args should build");
536
537 assert!(
538 args.windows(2)
539 .any(|window| window == ["-e", "CODEX_WS_APT_PACKAGES=python3 python3-pip"])
540 );
541 assert!(args.windows(2).any(|window| {
542 window
543 == [
544 "-e",
545 "CODEX_WS_SETUP_COMMANDS=python3 -m pip install maturin",
546 ]
547 }));
548 }
549
550 #[test]
551 fn docker_run_args_skips_missing_skills_directory() {
552 let args = docker_run_args(
553 &test_provider_files(),
554 &test_manifest(false),
555 &test_launch_config(PathBuf::from("/missing/skills")),
556 )
557 .expect("docker args should build");
558
559 assert!(!args.iter().any(|arg| arg.contains("/root/.codex/skills")));
560 }
561
562 #[test]
563 fn container_name_replaces_unsupported_characters() {
564 assert_eq!(
565 container_name("my workspace/main"),
566 "codex-ws-my-workspace-main"
567 );
568 }
569
570 #[derive(Debug)]
571 struct TestTempDir {
572 path: PathBuf,
573 }
574
575 impl TestTempDir {
576 fn create() -> Self {
577 let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
578 let timestamp = SystemTime::now()
579 .duration_since(UNIX_EPOCH)
580 .expect("system clock should be after Unix epoch")
581 .as_nanos();
582 let path = std::env::temp_dir().join(format!(
583 "codex-ws-docker-test-{}-{timestamp}-{counter}",
584 std::process::id()
585 ));
586 fs::create_dir(&path).expect("temporary test directory should be created");
587 Self { path }
588 }
589
590 fn path(&self) -> &Path {
591 &self.path
592 }
593 }
594
595 impl Drop for TestTempDir {
596 fn drop(&mut self) {
597 let _ = fs::remove_dir_all(&self.path);
598 }
599 }
600}