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
13pub const DEFAULT_CODEX_IMAGE: &str = "ghcr.io/honahec/codex-multi-workspace:latest";
15
16pub const DEFAULT_CODEX_IMAGE_VERSION: &str = "5";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DockerLaunchConfig {
22 image: String,
23 sessions_root: PathBuf,
24 skills_path: PathBuf,
25}
26
27impl DockerLaunchConfig {
28 #[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 #[must_use]
54 pub fn image(&self) -> &str {
55 &self.image
56 }
57
58 #[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 #[must_use]
82 pub fn sessions_root(&self) -> &Path {
83 &self.sessions_root
84 }
85
86 #[must_use]
92 pub fn skills_path(&self) -> &Path {
93 &self.skills_path
94 }
95
96 #[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 #[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#[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_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}