Skip to main content

codex_ws/
docker.rs

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";
14/// Container directory under which workspace folders are mounted.
15pub const CONTAINER_WORKSPACE_ROOT: &str = "/workspace";
16
17const CODEX_SANDBOX_MODE: &str = "danger-full-access";
18
19/// Default Codex CLI Docker image used for sandbox launches.
20pub const DEFAULT_CODEX_IMAGE: &str = "ghcr.io/honahec/codex-multi-workspace:latest";
21
22/// Version label expected on the locally built Codex workspace image.
23pub const DEFAULT_CODEX_IMAGE_VERSION: &str = "8";
24
25/// Runtime paths and image settings used to construct a Docker sandbox command.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DockerLaunchConfig {
28    image: String,
29    sessions_root: PathBuf,
30    skills_path: PathBuf,
31}
32
33impl DockerLaunchConfig {
34    /// Create Docker launch configuration.
35    ///
36    /// # Arguments
37    ///
38    /// * `image` - Docker image containing the Codex CLI.
39    /// * `sessions_root` - Host directory where per-workspace sessions are stored.
40    ///
41    /// # Returns
42    ///
43    /// A Docker launch configuration.
44    #[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    /// Return the Docker image.
55    ///
56    /// # Returns
57    ///
58    /// Docker image name used for the sandbox.
59    #[must_use]
60    pub fn image(&self) -> &str {
61        &self.image
62    }
63
64    /// Return a copy of this configuration with a different Docker image.
65    ///
66    /// # Arguments
67    ///
68    /// * `image` - Docker image that should replace the current image.
69    ///
70    /// # Returns
71    ///
72    /// A Docker launch configuration with the same host paths and a new image.
73    #[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    /// Return the sessions root directory.
83    ///
84    /// # Returns
85    ///
86    /// Host directory containing per-workspace session directories.
87    #[must_use]
88    pub fn sessions_root(&self) -> &Path {
89        &self.sessions_root
90    }
91
92    /// Return the host skills directory.
93    ///
94    /// # Returns
95    ///
96    /// Host directory mounted read-only as `/root/.codex/skills`.
97    #[must_use]
98    pub fn skills_path(&self) -> &Path {
99        &self.skills_path
100    }
101
102    /// Return a copy of this configuration with a different host skills directory.
103    ///
104    /// # Arguments
105    ///
106    /// * `skills_path` - Host directory containing Codex skills.
107    ///
108    /// # Returns
109    ///
110    /// A Docker launch configuration with the same image and sessions root.
111    #[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    /// Return the host sessions path for one workspace.
121    ///
122    /// # Arguments
123    ///
124    /// * `workspace_name` - Workspace name used as the host session directory key.
125    ///
126    /// # Returns
127    ///
128    /// Host path mounted as `/root/.codex/sessions` inside the sandbox.
129    #[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/// Errors returned while constructing Docker launch commands.
143#[derive(Debug, Error)]
144pub enum DockerError {
145    /// The workspace manifest did not contain any folders.
146    #[error("workspace '{workspace_name}' does not contain any folders")]
147    NoWorkspaceFolders {
148        /// Workspace name from the manifest.
149        workspace_name: String,
150    },
151
152    /// A workspace folder path cannot be represented as a stable container directory name.
153    #[error("workspace folder '{path}' does not have a usable directory name")]
154    InvalidWorkspaceFolderName {
155        /// Workspace folder path from the manifest.
156        path: PathBuf,
157    },
158
159    /// Two workspace folders would mount to the same container path.
160    #[error("multiple workspace folders are named '{folder_name}'")]
161    DuplicateWorkspaceFolderName {
162        /// Directory name shared by multiple workspace folders.
163        folder_name: String,
164    },
165}
166
167/// Provider configuration files written on the host before launching Docker.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct ProviderConfigFiles {
170    auth_path: PathBuf,
171    config_path: PathBuf,
172}
173
174impl ProviderConfigFiles {
175    /// Create provider configuration file paths.
176    ///
177    /// # Arguments
178    ///
179    /// * `auth_path` - Host path to the generated Codex auth JSON file.
180    /// * `config_path` - Host path to the generated Codex config TOML file.
181    ///
182    /// # Returns
183    ///
184    /// Provider configuration file paths used for Docker.
185    #[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    /// Return the host auth JSON path.
194    ///
195    /// # Returns
196    ///
197    /// Host path to the generated Codex auth JSON file.
198    #[must_use]
199    pub fn auth_path(&self) -> &Path {
200        &self.auth_path
201    }
202
203    /// Return the host config TOML path.
204    ///
205    /// # Returns
206    ///
207    /// Host path to the generated Codex config TOML file.
208    #[must_use]
209    pub fn config_path(&self) -> &Path {
210        &self.config_path
211    }
212}
213
214/// Build a Docker command for launching a Codex workspace sandbox.
215///
216/// # Arguments
217///
218/// * `provider_files` - Generated provider configuration files mounted into the sandbox.
219/// * `manifest` - Validated workspace manifest.
220/// * `launch_config` - Docker image and host path settings.
221///
222/// # Returns
223///
224/// A `docker run` command with provider, workspace, and session mounts.
225///
226/// # Errors
227///
228/// Returns [`DockerError::NoWorkspaceFolders`] when the manifest has no folders.
229pub 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
240/// Return the container mount target for each workspace folder.
241///
242/// # Arguments
243///
244/// * `manifest` - Workspace manifest whose folders should be mounted.
245///
246/// # Returns
247///
248/// Container paths under [`CONTAINER_WORKSPACE_ROOT`] using each folder's directory name.
249///
250/// # Errors
251///
252/// Returns [`DockerError::InvalidWorkspaceFolderName`] when a folder has no usable final path
253/// component, or [`DockerError::DuplicateWorkspaceFolderName`] when two folders share a name.
254pub 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}