Skip to main content

codex_ws/
docker.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use thiserror::Error;
5
6use crate::manifest::WorkspaceManifest;
7
8const CONTAINER_CODEX_DIR: &str = "/root/.codex";
9const CONTAINER_SESSIONS_DIR: &str = "/root/.codex/sessions";
10const CONTAINER_SKILLS_DIR: &str = "/root/.codex/skills";
11const CONTAINER_WORKSPACE_ROOT: &str = "/workspace";
12
13/// Default Codex CLI Docker image used for sandbox launches.
14pub const DEFAULT_CODEX_IMAGE: &str = "ghcr.io/honahec/codex-multi-workspace:latest";
15
16/// Version label expected on the locally built Codex workspace image.
17pub const DEFAULT_CODEX_IMAGE_VERSION: &str = "5";
18
19/// Runtime paths and image settings used to construct a Docker sandbox command.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DockerLaunchConfig {
22    image: String,
23    sessions_root: PathBuf,
24    skills_path: PathBuf,
25}
26
27impl DockerLaunchConfig {
28    /// Create Docker launch configuration.
29    ///
30    /// # Arguments
31    ///
32    /// * `image` - Docker image containing the Codex CLI.
33    /// * `sessions_root` - Host directory where per-workspace sessions are stored.
34    ///
35    /// # Returns
36    ///
37    /// A Docker launch configuration.
38    #[must_use]
39    pub fn new(image: String, sessions_root: PathBuf) -> Self {
40        Self {
41            image,
42            sessions_root,
43            skills_path: default_skills_path_from_home()
44                .unwrap_or_else(|| PathBuf::from(".agents/skills")),
45        }
46    }
47
48    /// Return the Docker image.
49    ///
50    /// # Returns
51    ///
52    /// Docker image name used for the sandbox.
53    #[must_use]
54    pub fn image(&self) -> &str {
55        &self.image
56    }
57
58    /// Return a copy of this configuration with a different Docker image.
59    ///
60    /// # Arguments
61    ///
62    /// * `image` - Docker image that should replace the current image.
63    ///
64    /// # Returns
65    ///
66    /// A Docker launch configuration with the same host paths and a new image.
67    #[must_use]
68    pub fn with_image(&self, image: String) -> Self {
69        Self {
70            image,
71            sessions_root: self.sessions_root.clone(),
72            skills_path: self.skills_path.clone(),
73        }
74    }
75
76    /// Return the sessions root directory.
77    ///
78    /// # Returns
79    ///
80    /// Host directory containing per-workspace session directories.
81    #[must_use]
82    pub fn sessions_root(&self) -> &Path {
83        &self.sessions_root
84    }
85
86    /// Return the host skills directory.
87    ///
88    /// # Returns
89    ///
90    /// Host directory mounted read-only as `/root/.codex/skills`.
91    #[must_use]
92    pub fn skills_path(&self) -> &Path {
93        &self.skills_path
94    }
95
96    /// Return a copy of this configuration with a different host skills directory.
97    ///
98    /// # Arguments
99    ///
100    /// * `skills_path` - Host directory containing Codex skills.
101    ///
102    /// # Returns
103    ///
104    /// A Docker launch configuration with the same image and sessions root.
105    #[must_use]
106    pub fn with_skills_path(&self, skills_path: PathBuf) -> Self {
107        Self {
108            image: self.image.clone(),
109            sessions_root: self.sessions_root.clone(),
110            skills_path,
111        }
112    }
113
114    /// Return the host sessions path for one workspace.
115    ///
116    /// # Arguments
117    ///
118    /// * `workspace_name` - Workspace name used as the host session directory key.
119    ///
120    /// # Returns
121    ///
122    /// Host path mounted as `/root/.codex/sessions` inside the sandbox.
123    #[must_use]
124    pub fn workspace_sessions_path(&self, workspace_name: &str) -> PathBuf {
125        self.sessions_root().join(workspace_name).join("sessions")
126    }
127}
128
129impl Default for DockerLaunchConfig {
130    fn default() -> Self {
131        Self::new(
132            DEFAULT_CODEX_IMAGE.to_owned(),
133            default_sessions_root_from_home().unwrap_or_else(|| PathBuf::from(".codex-ws")),
134        )
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_sessions_root_from_home() -> Option<PathBuf> {
303    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex-ws"))
304}
305
306fn default_skills_path_from_home() -> Option<PathBuf> {
307    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".agents").join("skills"))
308}
309
310#[cfg(test)]
311mod tests {
312    use std::fs;
313    use std::sync::atomic::{AtomicUsize, Ordering};
314    use std::time::{SystemTime, UNIX_EPOCH};
315
316    use super::*;
317    use crate::manifest::{RuntimeConfig, SandboxConfig};
318    use crate::runtime::RuntimeLanguageVersion;
319
320    static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
321
322    fn test_provider_files() -> ProviderConfigFiles {
323        ProviderConfigFiles::new(
324            PathBuf::from("/tmp/codex-ws-provider/auth.json"),
325            PathBuf::from("/tmp/codex-ws-provider/config.toml"),
326        )
327    }
328
329    fn test_manifest(network: bool) -> WorkspaceManifest {
330        WorkspaceManifest::new(
331            "workspace-name".to_owned(),
332            vec![
333                PathBuf::from("/projects/backend"),
334                PathBuf::from("/projects/frontend"),
335            ],
336            SandboxConfig::new(network),
337        )
338        .expect("manifest should be valid")
339    }
340
341    fn test_launch_config(skills_path: PathBuf) -> DockerLaunchConfig {
342        DockerLaunchConfig::new("codex-ws:test".to_owned(), PathBuf::from("/host/.codex-ws"))
343            .with_skills_path(skills_path)
344    }
345
346    #[test]
347    fn docker_run_args_mounts_provider_workspace_and_sessions() {
348        let temp_dir = TestTempDir::create();
349        let skills_path = temp_dir.path().join("skills");
350        fs::create_dir(&skills_path).expect("skills directory should be created");
351        let args = docker_run_args(
352            &test_provider_files(),
353            &test_manifest(false),
354            &test_launch_config(skills_path.clone()),
355        )
356        .expect("docker args should build");
357        let skills_mount = format!("{}:/root/.codex/skills:ro", skills_path.display());
358
359        assert_eq!(
360            args,
361            vec![
362                "run",
363                "--rm",
364                "-it",
365                "--name",
366                "codex-ws-workspace-name",
367                "--network",
368                "none",
369                "-v",
370                "/tmp/codex-ws-provider/auth.json:/root/.codex/auth.json:ro",
371                "-v",
372                "/tmp/codex-ws-provider/config.toml:/root/.codex/config.toml",
373                "-v",
374                "/host/.codex-ws/workspace-name/sessions:/root/.codex/sessions",
375                "-v",
376                &skills_mount,
377                "-v",
378                "/projects/backend:/workspace/1",
379                "-v",
380                "/projects/frontend:/workspace/2",
381                "--workdir",
382                "/workspace/1",
383                "codex-ws:test",
384            ]
385        );
386    }
387
388    #[test]
389    fn docker_run_args_omits_network_none_when_network_is_enabled() {
390        let args = docker_run_args(
391            &test_provider_files(),
392            &test_manifest(true),
393            &test_launch_config(PathBuf::from("/missing/skills")),
394        )
395        .expect("docker args should build");
396
397        assert!(!args.iter().any(|arg| arg == "--network"));
398        assert!(!args.iter().any(|arg| arg == "none"));
399    }
400
401    #[test]
402    fn docker_run_args_passes_runtime_environment_variables() {
403        let runtime =
404            RuntimeLanguageVersion::parse("golang:1.25.1").expect("runtime spec should parse");
405        let manifest = WorkspaceManifest::with_runtime(
406            "workspace-name".to_owned(),
407            vec![PathBuf::from("/projects/backend")],
408            SandboxConfig::default(),
409            RuntimeConfig::with_language_versions(None, vec![runtime]),
410        )
411        .expect("manifest should be valid");
412
413        let args = docker_run_args(
414            &test_provider_files(),
415            &manifest,
416            &test_launch_config(PathBuf::from("/missing/skills")),
417        )
418        .expect("docker args should build");
419
420        assert!(
421            args.windows(2)
422                .any(|window| window == ["-e", "CODEX_ENV_GO_VERSION=1.25.1"])
423        );
424    }
425
426    #[test]
427    fn docker_run_args_skips_missing_skills_directory() {
428        let args = docker_run_args(
429            &test_provider_files(),
430            &test_manifest(false),
431            &test_launch_config(PathBuf::from("/missing/skills")),
432        )
433        .expect("docker args should build");
434
435        assert!(!args.iter().any(|arg| arg.contains("/root/.codex/skills")));
436    }
437
438    #[test]
439    fn container_name_replaces_unsupported_characters() {
440        assert_eq!(
441            container_name("my workspace/main"),
442            "codex-ws-my-workspace-main"
443        );
444    }
445
446    #[derive(Debug)]
447    struct TestTempDir {
448        path: PathBuf,
449    }
450
451    impl TestTempDir {
452        fn create() -> Self {
453            let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
454            let timestamp = SystemTime::now()
455                .duration_since(UNIX_EPOCH)
456                .expect("system clock should be after Unix epoch")
457                .as_nanos();
458            let path = std::env::temp_dir().join(format!(
459                "codex-ws-docker-test-{}-{timestamp}-{counter}",
460                std::process::id()
461            ));
462            fs::create_dir(&path).expect("temporary test directory should be created");
463            Self { path }
464        }
465
466        fn path(&self) -> &Path {
467            &self.path
468        }
469    }
470
471    impl Drop for TestTempDir {
472        fn drop(&mut self) {
473            let _ = fs::remove_dir_all(&self.path);
474        }
475    }
476}