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
15pub const DEFAULT_CODEX_IMAGE: &str = "ghcr.io/honahec/codex-multi-workspace:latest";
17
18pub const DEFAULT_CODEX_IMAGE_VERSION: &str = "5";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DockerLaunchConfig {
24 image: String,
25 sessions_root: PathBuf,
26 skills_path: PathBuf,
27}
28
29impl DockerLaunchConfig {
30 #[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 #[must_use]
56 pub fn image(&self) -> &str {
57 &self.image
58 }
59
60 #[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 #[must_use]
84 pub fn sessions_root(&self) -> &Path {
85 &self.sessions_root
86 }
87
88 #[must_use]
94 pub fn skills_path(&self) -> &Path {
95 &self.skills_path
96 }
97
98 #[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 #[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#[derive(Debug, Error)]
140pub enum DockerError {
141 #[error("workspace '{workspace_name}' does not contain any folders")]
143 NoWorkspaceFolders {
144 workspace_name: String,
146 },
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ProviderConfigFiles {
152 auth_path: PathBuf,
153 config_path: PathBuf,
154}
155
156impl ProviderConfigFiles {
157 #[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 #[must_use]
181 pub fn auth_path(&self) -> &Path {
182 &self.auth_path
183 }
184
185 #[must_use]
191 pub fn config_path(&self) -> &Path {
192 &self.config_path
193 }
194}
195
196pub 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}