Skip to main content

codex_ws/
docker.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use directories::BaseDirs;
5use thiserror::Error;
6
7use crate::config::default_state_root;
8use crate::manifest::WorkspaceManifest;
9
10const CONTAINER_CODEX_DIR: &str = "/root/.codex";
11const CONTAINER_SESSIONS_DIR: &str = "/root/.codex/sessions";
12const CONTAINER_SKILLS_DIR: &str = "/root/.codex/skills";
13const CONTAINER_WORKSPACE_ROOT: &str = "/workspace";
14
15/// Default Codex CLI Docker image used for sandbox launches.
16pub const DEFAULT_CODEX_IMAGE: &str = "ghcr.io/honahec/codex-multi-workspace:latest";
17
18/// Version label expected on the locally built Codex workspace image.
19pub const DEFAULT_CODEX_IMAGE_VERSION: &str = "5";
20
21/// Runtime paths and image settings used to construct a Docker sandbox command.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DockerLaunchConfig {
24    image: String,
25    sessions_root: PathBuf,
26    skills_path: PathBuf,
27}
28
29impl DockerLaunchConfig {
30    /// Create Docker launch configuration.
31    ///
32    /// # Arguments
33    ///
34    /// * `image` - Docker image containing the Codex CLI.
35    /// * `sessions_root` - Host directory where per-workspace sessions are stored.
36    ///
37    /// # Returns
38    ///
39    /// A Docker launch configuration.
40    #[must_use]
41    pub fn new(image: String, sessions_root: PathBuf) -> Self {
42        Self {
43            image,
44            sessions_root,
45            skills_path: default_skills_path_from_home()
46                .unwrap_or_else(|| PathBuf::from(".agents/skills")),
47        }
48    }
49
50    /// Return the Docker image.
51    ///
52    /// # Returns
53    ///
54    /// Docker image name used for the sandbox.
55    #[must_use]
56    pub fn image(&self) -> &str {
57        &self.image
58    }
59
60    /// Return a copy of this configuration with a different Docker image.
61    ///
62    /// # Arguments
63    ///
64    /// * `image` - Docker image that should replace the current image.
65    ///
66    /// # Returns
67    ///
68    /// A Docker launch configuration with the same host paths and a new image.
69    #[must_use]
70    pub fn with_image(&self, image: String) -> Self {
71        Self {
72            image,
73            sessions_root: self.sessions_root.clone(),
74            skills_path: self.skills_path.clone(),
75        }
76    }
77
78    /// Return the sessions root directory.
79    ///
80    /// # Returns
81    ///
82    /// Host directory containing per-workspace session directories.
83    #[must_use]
84    pub fn sessions_root(&self) -> &Path {
85        &self.sessions_root
86    }
87
88    /// Return the host skills directory.
89    ///
90    /// # Returns
91    ///
92    /// Host directory mounted read-only as `/root/.codex/skills`.
93    #[must_use]
94    pub fn skills_path(&self) -> &Path {
95        &self.skills_path
96    }
97
98    /// Return a copy of this configuration with a different host skills directory.
99    ///
100    /// # Arguments
101    ///
102    /// * `skills_path` - Host directory containing Codex skills.
103    ///
104    /// # Returns
105    ///
106    /// A Docker launch configuration with the same image and sessions root.
107    #[must_use]
108    pub fn with_skills_path(&self, skills_path: PathBuf) -> Self {
109        Self {
110            image: self.image.clone(),
111            sessions_root: self.sessions_root.clone(),
112            skills_path,
113        }
114    }
115
116    /// Return the host sessions path for one workspace.
117    ///
118    /// # Arguments
119    ///
120    /// * `workspace_name` - Workspace name used as the host session directory key.
121    ///
122    /// # Returns
123    ///
124    /// Host path mounted as `/root/.codex/sessions` inside the sandbox.
125    #[must_use]
126    pub fn workspace_sessions_path(&self, workspace_name: &str) -> PathBuf {
127        self.sessions_root().join(workspace_name).join("sessions")
128    }
129}
130
131impl Default for DockerLaunchConfig {
132    fn default() -> Self {
133        let sessions_root = default_state_root().unwrap_or_else(|_| PathBuf::from(".codex-ws"));
134        Self::new(DEFAULT_CODEX_IMAGE.to_owned(), sessions_root)
135    }
136}
137
138/// Errors returned while constructing Docker launch commands.
139#[derive(Debug, Error)]
140pub enum DockerError {
141    /// The workspace manifest did not contain any folders.
142    #[error("workspace '{workspace_name}' does not contain any folders")]
143    NoWorkspaceFolders {
144        /// Workspace name from the manifest.
145        workspace_name: String,
146    },
147}
148
149/// Provider configuration files written on the host before launching Docker.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ProviderConfigFiles {
152    auth_path: PathBuf,
153    config_path: PathBuf,
154}
155
156impl ProviderConfigFiles {
157    /// Create provider configuration file paths.
158    ///
159    /// # Arguments
160    ///
161    /// * `auth_path` - Host path to the generated Codex auth JSON file.
162    /// * `config_path` - Host path to the generated Codex config TOML file.
163    ///
164    /// # Returns
165    ///
166    /// Provider configuration file paths used for Docker.
167    #[must_use]
168    pub fn new(auth_path: PathBuf, config_path: PathBuf) -> Self {
169        Self {
170            auth_path,
171            config_path,
172        }
173    }
174
175    /// Return the host auth JSON path.
176    ///
177    /// # Returns
178    ///
179    /// Host path to the generated Codex auth JSON file.
180    #[must_use]
181    pub fn auth_path(&self) -> &Path {
182        &self.auth_path
183    }
184
185    /// Return the host config TOML path.
186    ///
187    /// # Returns
188    ///
189    /// Host path to the generated Codex config TOML file.
190    #[must_use]
191    pub fn config_path(&self) -> &Path {
192        &self.config_path
193    }
194}
195
196/// Build a Docker command for launching a Codex workspace sandbox.
197///
198/// # Arguments
199///
200/// * `provider_files` - Generated provider configuration files mounted into the sandbox.
201/// * `manifest` - Validated workspace manifest.
202/// * `launch_config` - Docker image and host path settings.
203///
204/// # Returns
205///
206/// A `docker run` command with provider, workspace, and session mounts.
207///
208/// # Errors
209///
210/// Returns [`DockerError::NoWorkspaceFolders`] when the manifest has no folders.
211pub fn build_docker_run_command(
212    provider_files: &ProviderConfigFiles,
213    manifest: &WorkspaceManifest,
214    launch_config: &DockerLaunchConfig,
215) -> Result<Command, DockerError> {
216    let args = docker_run_args(provider_files, manifest, launch_config)?;
217    let mut command = Command::new("docker");
218    command.args(args);
219    Ok(command)
220}
221
222fn docker_run_args(
223    provider_files: &ProviderConfigFiles,
224    manifest: &WorkspaceManifest,
225    launch_config: &DockerLaunchConfig,
226) -> Result<Vec<String>, DockerError> {
227    if manifest.folders().is_empty() {
228        return Err(DockerError::NoWorkspaceFolders {
229            workspace_name: manifest.name().to_owned(),
230        });
231    }
232
233    let mut args = vec![
234        "run".to_owned(),
235        "--rm".to_owned(),
236        "-it".to_owned(),
237        "--name".to_owned(),
238        container_name(manifest.name()),
239    ];
240
241    if !manifest.sandbox().network() {
242        args.extend(["--network".to_owned(), "none".to_owned()]);
243    }
244
245    for variable in manifest.runtime().environment_variables() {
246        args.extend(["-e".to_owned(), variable.docker_assignment()]);
247    }
248
249    args.extend(volume_args(
250        provider_files.auth_path(),
251        &format!("{CONTAINER_CODEX_DIR}/auth.json"),
252        true,
253    ));
254    args.extend(volume_args(
255        provider_files.config_path(),
256        &format!("{CONTAINER_CODEX_DIR}/config.toml"),
257        false,
258    ));
259    let sessions_path = launch_config.workspace_sessions_path(manifest.name());
260    args.extend(volume_args(&sessions_path, CONTAINER_SESSIONS_DIR, false));
261    if launch_config.skills_path().is_dir() {
262        args.extend(volume_args(
263            launch_config.skills_path(),
264            CONTAINER_SKILLS_DIR,
265            true,
266        ));
267    }
268
269    for (index, folder) in manifest.folders().iter().enumerate() {
270        let target = format!("{CONTAINER_WORKSPACE_ROOT}/{}", index + 1);
271        args.extend(volume_args(folder, &target, false));
272    }
273
274    args.push("--workdir".to_owned());
275    args.push(format!("{CONTAINER_WORKSPACE_ROOT}/1"));
276    args.push(launch_config.image().to_owned());
277
278    Ok(args)
279}
280
281fn volume_args(source: &Path, target: &str, read_only: bool) -> [String; 2] {
282    let mode = if read_only { ":ro" } else { "" };
283    [
284        "-v".to_owned(),
285        format!("{}:{target}{mode}", source.display()),
286    ]
287}
288
289fn container_name(workspace_name: &str) -> String {
290    let mut name = String::with_capacity("codex-ws-".len() + workspace_name.len());
291    name.push_str("codex-ws-");
292    for character in workspace_name.chars() {
293        if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
294            name.push(character);
295        } else {
296            name.push('-');
297        }
298    }
299    name
300}
301
302fn default_skills_path_from_home() -> Option<PathBuf> {
303    BaseDirs::new().map(|dirs| dirs.home_dir().join(".agents").join("skills"))
304}
305
306#[cfg(test)]
307mod tests {
308    use std::fs;
309    use std::sync::atomic::{AtomicUsize, Ordering};
310    use std::time::{SystemTime, UNIX_EPOCH};
311
312    use super::*;
313    use crate::manifest::{RuntimeConfig, SandboxConfig};
314    use crate::runtime::RuntimeLanguageVersion;
315
316    static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
317
318    fn test_provider_files() -> ProviderConfigFiles {
319        ProviderConfigFiles::new(
320            PathBuf::from("/tmp/codex-ws-provider/auth.json"),
321            PathBuf::from("/tmp/codex-ws-provider/config.toml"),
322        )
323    }
324
325    fn test_manifest(network: bool) -> WorkspaceManifest {
326        WorkspaceManifest::new(
327            "workspace-name".to_owned(),
328            vec![
329                PathBuf::from("/projects/backend"),
330                PathBuf::from("/projects/frontend"),
331            ],
332            SandboxConfig::new(network),
333        )
334        .expect("manifest should be valid")
335    }
336
337    fn test_launch_config(skills_path: PathBuf) -> DockerLaunchConfig {
338        DockerLaunchConfig::new("codex-ws:test".to_owned(), PathBuf::from("/host/.codex-ws"))
339            .with_skills_path(skills_path)
340    }
341
342    #[test]
343    fn docker_run_args_mounts_provider_workspace_and_sessions() {
344        let temp_dir = TestTempDir::create();
345        let skills_path = temp_dir.path().join("skills");
346        fs::create_dir(&skills_path).expect("skills directory should be created");
347        let args = docker_run_args(
348            &test_provider_files(),
349            &test_manifest(false),
350            &test_launch_config(skills_path.clone()),
351        )
352        .expect("docker args should build");
353        let skills_mount = format!("{}:/root/.codex/skills:ro", skills_path.display());
354
355        assert_eq!(
356            args,
357            vec![
358                "run",
359                "--rm",
360                "-it",
361                "--name",
362                "codex-ws-workspace-name",
363                "--network",
364                "none",
365                "-v",
366                "/tmp/codex-ws-provider/auth.json:/root/.codex/auth.json:ro",
367                "-v",
368                "/tmp/codex-ws-provider/config.toml:/root/.codex/config.toml",
369                "-v",
370                "/host/.codex-ws/workspace-name/sessions:/root/.codex/sessions",
371                "-v",
372                &skills_mount,
373                "-v",
374                "/projects/backend:/workspace/1",
375                "-v",
376                "/projects/frontend:/workspace/2",
377                "--workdir",
378                "/workspace/1",
379                "codex-ws:test",
380            ]
381        );
382    }
383
384    #[test]
385    fn docker_run_args_omits_network_none_when_network_is_enabled() {
386        let args = docker_run_args(
387            &test_provider_files(),
388            &test_manifest(true),
389            &test_launch_config(PathBuf::from("/missing/skills")),
390        )
391        .expect("docker args should build");
392
393        assert!(!args.iter().any(|arg| arg == "--network"));
394        assert!(!args.iter().any(|arg| arg == "none"));
395    }
396
397    #[test]
398    fn docker_run_args_passes_runtime_environment_variables() {
399        let runtime =
400            RuntimeLanguageVersion::parse("golang:1.25.1").expect("runtime spec should parse");
401        let manifest = WorkspaceManifest::with_runtime(
402            "workspace-name".to_owned(),
403            vec![PathBuf::from("/projects/backend")],
404            SandboxConfig::default(),
405            RuntimeConfig::with_language_versions(None, vec![runtime]),
406        )
407        .expect("manifest should be valid");
408
409        let args = docker_run_args(
410            &test_provider_files(),
411            &manifest,
412            &test_launch_config(PathBuf::from("/missing/skills")),
413        )
414        .expect("docker args should build");
415
416        assert!(
417            args.windows(2)
418                .any(|window| window == ["-e", "CODEX_ENV_GO_VERSION=1.25.1"])
419        );
420    }
421
422    #[test]
423    fn docker_run_args_skips_missing_skills_directory() {
424        let args = docker_run_args(
425            &test_provider_files(),
426            &test_manifest(false),
427            &test_launch_config(PathBuf::from("/missing/skills")),
428        )
429        .expect("docker args should build");
430
431        assert!(!args.iter().any(|arg| arg.contains("/root/.codex/skills")));
432    }
433
434    #[test]
435    fn container_name_replaces_unsupported_characters() {
436        assert_eq!(
437            container_name("my workspace/main"),
438            "codex-ws-my-workspace-main"
439        );
440    }
441
442    #[derive(Debug)]
443    struct TestTempDir {
444        path: PathBuf,
445    }
446
447    impl TestTempDir {
448        fn create() -> Self {
449            let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
450            let timestamp = SystemTime::now()
451                .duration_since(UNIX_EPOCH)
452                .expect("system clock should be after Unix epoch")
453                .as_nanos();
454            let path = std::env::temp_dir().join(format!(
455                "codex-ws-docker-test-{}-{timestamp}-{counter}",
456                std::process::id()
457            ));
458            fs::create_dir(&path).expect("temporary test directory should be created");
459            Self { path }
460        }
461
462        fn path(&self) -> &Path {
463            &self.path
464        }
465    }
466
467    impl Drop for TestTempDir {
468        fn drop(&mut self) {
469            let _ = fs::remove_dir_all(&self.path);
470        }
471    }
472}