Skip to main content

codex_ws/
manifest.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use thiserror::Error;
6
7use crate::runtime::{
8    CODEX_WS_APT_PACKAGES_ENV, CODEX_WS_SETUP_COMMANDS_ENV, RuntimeEnvironmentVariable,
9    RuntimeSpecError, validate_apt_packages, validate_setup_commands,
10};
11
12/// Workspace manifest describing folders and sandbox options.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WorkspaceManifest {
15    name: String,
16    folders: Vec<PathBuf>,
17    sandbox: SandboxConfig,
18    runtime: RuntimeConfig,
19}
20
21impl WorkspaceManifest {
22    /// Create a workspace manifest.
23    ///
24    /// # Arguments
25    ///
26    /// * `name` - Stable workspace name used for session routing.
27    /// * `folders` - One or more project folders included in the workspace.
28    /// * `sandbox` - Runtime options applied when launching the sandbox.
29    ///
30    /// # Returns
31    ///
32    /// A validated workspace manifest.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`ManifestError::EmptyName`] when `name` is blank.
37    /// Returns [`ManifestError::NoFolders`] when no folders are provided.
38    pub fn new(
39        name: String,
40        folders: Vec<PathBuf>,
41        sandbox: SandboxConfig,
42    ) -> Result<Self, ManifestError> {
43        Self::with_runtime(name, folders, sandbox, RuntimeConfig::default())
44    }
45
46    /// Create a workspace manifest with runtime settings.
47    ///
48    /// # Arguments
49    ///
50    /// * `name` - Stable workspace name used for session routing.
51    /// * `folders` - One or more project folders included in the workspace.
52    /// * `sandbox` - Runtime sandbox options applied when launching the sandbox.
53    /// * `runtime` - Container runtime image settings for this workspace.
54    ///
55    /// # Returns
56    ///
57    /// A validated workspace manifest.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`ManifestError::EmptyName`] when `name` is blank.
62    /// Returns [`ManifestError::NoFolders`] when no folders are provided.
63    /// Returns [`ManifestError::EmptyRuntimeImage`] when `runtime.image` is blank.
64    pub fn with_runtime(
65        name: String,
66        folders: Vec<PathBuf>,
67        sandbox: SandboxConfig,
68        runtime: RuntimeConfig,
69    ) -> Result<Self, ManifestError> {
70        if name.trim().is_empty() {
71            return Err(ManifestError::EmptyName);
72        }
73
74        if folders.is_empty() {
75            return Err(ManifestError::NoFolders);
76        }
77
78        if runtime.image().is_some_and(|image| image.trim().is_empty()) {
79            return Err(ManifestError::EmptyRuntimeImage);
80        }
81
82        Ok(Self {
83            name,
84            folders,
85            sandbox,
86            runtime,
87        })
88    }
89
90    /// Return the workspace name.
91    ///
92    /// # Returns
93    ///
94    /// The workspace name as a borrowed string slice.
95    #[must_use]
96    pub fn name(&self) -> &str {
97        &self.name
98    }
99
100    /// Return workspace folders.
101    ///
102    /// # Returns
103    ///
104    /// A slice of folder paths included in this workspace.
105    #[must_use]
106    pub fn folders(&self) -> &[PathBuf] {
107        &self.folders
108    }
109
110    /// Return sandbox runtime options.
111    ///
112    /// # Returns
113    ///
114    /// The sandbox configuration for this workspace.
115    #[must_use]
116    pub fn sandbox(&self) -> &SandboxConfig {
117        &self.sandbox
118    }
119
120    /// Return container runtime options.
121    ///
122    /// # Returns
123    ///
124    /// The runtime configuration for this workspace.
125    #[must_use]
126    pub fn runtime(&self) -> &RuntimeConfig {
127        &self.runtime
128    }
129}
130
131/// Sandbox options loaded from a workspace manifest.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct SandboxConfig {
134    network: bool,
135}
136
137impl Default for SandboxConfig {
138    fn default() -> Self {
139        Self { network: true }
140    }
141}
142
143impl SandboxConfig {
144    /// Create a sandbox configuration.
145    ///
146    /// # Arguments
147    ///
148    /// * `network` - Whether the sandbox should allow network access.
149    ///
150    /// # Returns
151    ///
152    /// A sandbox configuration value.
153    #[must_use]
154    pub const fn new(network: bool) -> Self {
155        Self { network }
156    }
157
158    /// Return whether sandbox network access is enabled.
159    ///
160    /// # Returns
161    ///
162    /// `true` when network access is enabled.
163    #[must_use]
164    pub const fn network(&self) -> bool {
165        self.network
166    }
167}
168
169/// Container runtime options loaded from a workspace manifest.
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct RuntimeConfig {
172    image: Option<String>,
173    apt_packages: Vec<String>,
174    setup_commands: Vec<String>,
175}
176
177impl RuntimeConfig {
178    /// Create a runtime configuration.
179    ///
180    /// # Arguments
181    ///
182    /// * `image` - Optional Docker image used for this workspace.
183    ///
184    /// # Returns
185    ///
186    /// A runtime configuration value.
187    #[must_use]
188    pub fn new(image: Option<String>) -> Self {
189        Self {
190            image,
191            apt_packages: Vec::new(),
192            setup_commands: Vec::new(),
193        }
194    }
195
196    /// Create a runtime configuration with startup setup.
197    ///
198    /// # Arguments
199    ///
200    /// * `image` - Optional Docker image used for this workspace.
201    /// * `apt_packages` - Apt packages installed by the entrypoint before Codex starts.
202    /// * `setup_commands` - Shell commands run by the entrypoint before Codex starts.
203    ///
204    /// # Returns
205    ///
206    /// A runtime configuration value.
207    #[must_use]
208    pub fn with_setup(
209        image: Option<String>,
210        apt_packages: Vec<String>,
211        setup_commands: Vec<String>,
212    ) -> Self {
213        Self {
214            image,
215            apt_packages,
216            setup_commands,
217        }
218    }
219
220    /// Return the workspace-specific Docker image.
221    ///
222    /// # Returns
223    ///
224    /// `Some(image)` when the manifest selects a runtime image, otherwise `None`.
225    #[must_use]
226    pub fn image(&self) -> Option<&str> {
227        self.image.as_deref()
228    }
229
230    /// Return apt packages installed before Codex starts.
231    ///
232    /// # Returns
233    ///
234    /// Apt package names requested by this workspace.
235    #[must_use]
236    pub fn apt_packages(&self) -> &[String] {
237        &self.apt_packages
238    }
239
240    /// Return setup commands run before Codex starts.
241    ///
242    /// # Returns
243    ///
244    /// Shell commands requested by this workspace.
245    #[must_use]
246    pub fn setup_commands(&self) -> &[String] {
247        &self.setup_commands
248    }
249
250    /// Return Docker environment variables for runtime setup.
251    ///
252    /// # Returns
253    ///
254    /// Entrypoint variables generated from configured apt packages and setup commands.
255    #[must_use]
256    pub fn environment_variables(&self) -> Vec<RuntimeEnvironmentVariable> {
257        let mut variables = Vec::with_capacity(2);
258
259        if !self.apt_packages.is_empty() {
260            variables.push(RuntimeEnvironmentVariable::new(
261                CODEX_WS_APT_PACKAGES_ENV,
262                self.apt_packages.join(" "),
263            ));
264        }
265
266        if !self.setup_commands.is_empty() {
267            variables.push(RuntimeEnvironmentVariable::new(
268                CODEX_WS_SETUP_COMMANDS_ENV,
269                self.setup_commands.join("\n"),
270            ));
271        }
272
273        variables
274    }
275}
276
277/// Errors returned while loading or validating workspace manifests.
278#[derive(Debug, Error)]
279pub enum ManifestError {
280    /// The manifest file could not be read.
281    #[error("failed to read workspace manifest '{path}': {source}")]
282    Read {
283        /// Manifest path that failed to read.
284        path: PathBuf,
285        /// Underlying I/O error.
286        source: std::io::Error,
287    },
288
289    /// The manifest YAML could not be parsed.
290    #[error("invalid workspace manifest YAML: {0}")]
291    Yaml(#[from] serde_yaml::Error),
292
293    /// The workspace name was empty or only whitespace.
294    #[error("workspace manifest name cannot be empty")]
295    EmptyName,
296
297    /// The workspace did not include any folders.
298    #[error("workspace manifest must include at least one folder")]
299    NoFolders,
300
301    /// The workspace runtime image was empty or only whitespace.
302    #[error("workspace manifest runtime image cannot be empty")]
303    EmptyRuntimeImage,
304
305    /// The workspace runtime language selection was invalid.
306    #[error("invalid workspace runtime: {0}")]
307    RuntimeSpec(#[from] RuntimeSpecError),
308
309    /// A workspace folder path does not exist.
310    #[error("workspace folder '{path}' does not exist")]
311    FolderMissing {
312        /// Missing workspace folder path.
313        path: PathBuf,
314    },
315
316    /// A workspace folder path exists but is not a directory.
317    #[error("workspace folder '{path}' is not a directory")]
318    FolderNotDirectory {
319        /// Non-directory workspace folder path.
320        path: PathBuf,
321    },
322}
323
324#[derive(Debug, Deserialize)]
325struct RawWorkspaceManifest {
326    name: String,
327    folders: Vec<PathBuf>,
328    #[serde(default)]
329    sandbox: RawSandboxConfig,
330    #[serde(default)]
331    runtime: Option<RawRuntimeConfig>,
332}
333
334#[derive(Debug, Deserialize)]
335struct RawSandboxConfig {
336    #[serde(default = "default_sandbox_network")]
337    network: bool,
338}
339
340impl Default for RawSandboxConfig {
341    fn default() -> Self {
342        Self {
343            network: default_sandbox_network(),
344        }
345    }
346}
347
348const fn default_sandbox_network() -> bool {
349    true
350}
351
352#[derive(Debug, Default, Deserialize)]
353struct RawRuntimeConfig {
354    image: Option<String>,
355    #[serde(default)]
356    apt: Vec<String>,
357    #[serde(default)]
358    setup: Vec<String>,
359}
360
361impl TryFrom<RawWorkspaceManifest> for WorkspaceManifest {
362    type Error = ManifestError;
363
364    fn try_from(raw: RawWorkspaceManifest) -> Result<Self, Self::Error> {
365        let runtime = raw.runtime.unwrap_or_default().try_into()?;
366        Self::with_runtime(
367            raw.name,
368            raw.folders,
369            SandboxConfig::new(raw.sandbox.network),
370            runtime,
371        )
372    }
373}
374
375impl TryFrom<RawRuntimeConfig> for RuntimeConfig {
376    type Error = ManifestError;
377
378    fn try_from(raw: RawRuntimeConfig) -> Result<Self, Self::Error> {
379        runtime_from_parts(raw.image, raw.apt, raw.setup)
380    }
381}
382
383fn runtime_from_parts(
384    image: Option<String>,
385    apt_packages: Vec<String>,
386    setup_commands: Vec<String>,
387) -> Result<RuntimeConfig, ManifestError> {
388    let image = image.map(|runtime_image| runtime_image.trim().to_owned());
389    let apt_packages = validate_apt_packages(apt_packages)?;
390    let setup_commands = validate_setup_commands(setup_commands)?;
391
392    Ok(RuntimeConfig::with_setup(
393        image,
394        apt_packages,
395        setup_commands,
396    ))
397}
398
399/// Load a workspace manifest from a YAML file.
400///
401/// # Arguments
402///
403/// * `manifest_path` - Path to the YAML workspace manifest.
404///
405/// # Returns
406///
407/// A validated workspace manifest.
408///
409/// # Errors
410///
411/// Returns [`ManifestError::Read`] when the file cannot be read.
412/// Returns [`ManifestError::Yaml`] when YAML parsing fails.
413/// Returns validation errors when required fields are missing or invalid.
414pub fn load_workspace_manifest(manifest_path: &Path) -> Result<WorkspaceManifest, ManifestError> {
415    let manifest_yaml =
416        fs::read_to_string(manifest_path).map_err(|source| ManifestError::Read {
417            path: manifest_path.to_path_buf(),
418            source,
419        })?;
420    parse_workspace_manifest(&manifest_yaml)
421}
422
423/// Parse a workspace manifest from YAML.
424///
425/// # Arguments
426///
427/// * `manifest_yaml` - YAML text containing workspace manifest fields.
428///
429/// # Returns
430///
431/// A validated workspace manifest.
432///
433/// # Errors
434///
435/// Returns [`ManifestError::Yaml`] when YAML parsing fails.
436/// Returns validation errors when required fields are missing or invalid.
437pub fn parse_workspace_manifest(manifest_yaml: &str) -> Result<WorkspaceManifest, ManifestError> {
438    let raw_manifest = serde_yaml::from_str::<RawWorkspaceManifest>(manifest_yaml)?;
439    raw_manifest.try_into()
440}
441
442/// Validate that every workspace folder exists and is a directory.
443///
444/// # Arguments
445///
446/// * `manifest` - Workspace manifest whose folders should be checked.
447///
448/// # Returns
449///
450/// `Ok(())` when all workspace folders exist and are directories.
451///
452/// # Errors
453///
454/// Returns [`ManifestError::FolderMissing`] when a folder path does not exist.
455/// Returns [`ManifestError::FolderNotDirectory`] when a folder path is not a directory.
456pub fn validate_workspace_folders(manifest: &WorkspaceManifest) -> Result<(), ManifestError> {
457    for folder in manifest.folders() {
458        if !folder.exists() {
459            return Err(ManifestError::FolderMissing {
460                path: folder.clone(),
461            });
462        }
463
464        if !folder.is_dir() {
465            return Err(ManifestError::FolderNotDirectory {
466                path: folder.clone(),
467            });
468        }
469    }
470
471    Ok(())
472}
473
474#[cfg(test)]
475mod tests {
476    use std::sync::atomic::{AtomicUsize, Ordering};
477    use std::time::{SystemTime, UNIX_EPOCH};
478
479    use super::*;
480
481    static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
482
483    #[derive(Debug)]
484    struct TestTempDir {
485        path: PathBuf,
486    }
487
488    impl TestTempDir {
489        fn create() -> Self {
490            let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
491            let timestamp = SystemTime::now()
492                .duration_since(UNIX_EPOCH)
493                .expect("system clock should be after Unix epoch")
494                .as_nanos();
495            let path = std::env::temp_dir().join(format!(
496                "codex-ws-test-{}-{timestamp}-{counter}",
497                std::process::id()
498            ));
499            fs::create_dir(&path).expect("temporary test directory should be created");
500            Self { path }
501        }
502
503        fn path(&self) -> &Path {
504            &self.path
505        }
506    }
507
508    impl Drop for TestTempDir {
509        fn drop(&mut self) {
510            let _ = fs::remove_dir_all(&self.path);
511        }
512    }
513
514    #[test]
515    fn parse_workspace_manifest_supports_multiple_folders_and_network() {
516        let manifest = parse_workspace_manifest(
517            r#"
518name: workspace-name
519folders:
520  - /projects/backend
521  - /projects/frontend
522sandbox:
523  network: true
524"#,
525        )
526        .expect("manifest should parse");
527
528        assert_eq!(manifest.name(), "workspace-name");
529        assert_eq!(
530            manifest.folders(),
531            &[
532                PathBuf::from("/projects/backend"),
533                PathBuf::from("/projects/frontend")
534            ]
535        );
536        assert!(manifest.sandbox().network());
537        assert_eq!(manifest.runtime().image(), None);
538    }
539
540    #[test]
541    fn parse_workspace_manifest_supports_single_folder() {
542        let manifest = parse_workspace_manifest(
543            r#"
544name: single-project
545folders:
546  - /projects/backend
547"#,
548        )
549        .expect("manifest should parse");
550
551        assert_eq!(manifest.name(), "single-project");
552        assert_eq!(manifest.folders(), &[PathBuf::from("/projects/backend")]);
553        assert!(manifest.sandbox().network());
554        assert_eq!(manifest.runtime().image(), None);
555    }
556
557    #[test]
558    fn parse_workspace_manifest_supports_runtime_image() {
559        let manifest = parse_workspace_manifest(
560            r#"
561name: rust-project
562folders:
563  - /projects/rust-project
564runtime:
565  image: rust-codex-ws:latest
566"#,
567        )
568        .expect("manifest should parse");
569
570        assert_eq!(manifest.runtime().image(), Some("rust-codex-ws:latest"));
571    }
572
573    #[test]
574    fn parse_workspace_manifest_supports_runtime_apt_packages() {
575        let manifest = parse_workspace_manifest(
576            r#"
577name: python-project
578folders:
579  - /projects/python-project
580runtime:
581  apt:
582    - python3
583    - python3-pip
584"#,
585        )
586        .expect("manifest should parse");
587
588        assert_eq!(
589            manifest.runtime().environment_variables()[0].docker_assignment(),
590            "CODEX_WS_APT_PACKAGES=python3 python3-pip"
591        );
592    }
593
594    #[test]
595    fn parse_workspace_manifest_supports_runtime_setup_commands() {
596        let manifest = parse_workspace_manifest(
597            r#"
598name: rust-project
599folders:
600  - /projects/rust-project
601runtime:
602  setup:
603    - curl -fsSL https://sh.rustup.rs | sh -s -- -y
604    - . "$HOME/.cargo/env"
605"#,
606        )
607        .expect("manifest should parse");
608
609        let variables = manifest.runtime().environment_variables();
610        assert_eq!(
611            variables
612                .iter()
613                .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
614                .collect::<Vec<_>>(),
615            vec!["CODEX_WS_SETUP_COMMANDS=curl -fsSL https://sh.rustup.rs | sh -s -- -y\n. \"$HOME/.cargo/env\"".to_owned()]
616        );
617    }
618
619    #[test]
620    fn parse_workspace_manifest_supports_runtime_apt_and_setup() {
621        let manifest = parse_workspace_manifest(
622            r#"
623name: mixed-project
624folders:
625  - /projects/mixed-project
626runtime:
627  apt:
628    - build-essential
629  setup:
630    - echo ready
631"#,
632        )
633        .expect("manifest should parse");
634
635        let variables = manifest.runtime().environment_variables();
636        assert_eq!(
637            variables
638                .iter()
639                .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
640                .collect::<Vec<_>>(),
641            vec![
642                "CODEX_WS_APT_PACKAGES=build-essential".to_owned(),
643                "CODEX_WS_SETUP_COMMANDS=echo ready".to_owned()
644            ]
645        );
646    }
647
648    #[test]
649    fn parse_workspace_manifest_rejects_empty_name() {
650        let error = parse_workspace_manifest(
651            r#"
652name: " "
653folders:
654  - /projects/backend
655"#,
656        )
657        .expect_err("blank name should fail");
658
659        assert!(matches!(error, ManifestError::EmptyName));
660    }
661
662    #[test]
663    fn parse_workspace_manifest_rejects_empty_folders() {
664        let error = parse_workspace_manifest(
665            r#"
666name: empty-workspace
667folders: []
668"#,
669        )
670        .expect_err("empty folders should fail");
671
672        assert!(matches!(error, ManifestError::NoFolders));
673    }
674
675    #[test]
676    fn parse_workspace_manifest_rejects_empty_runtime_image() {
677        let error = parse_workspace_manifest(
678            r#"
679name: workspace
680folders:
681  - /projects/backend
682runtime:
683  image: " "
684"#,
685        )
686        .expect_err("blank runtime image should fail");
687
688        assert!(matches!(error, ManifestError::EmptyRuntimeImage));
689    }
690
691    #[test]
692    fn parse_workspace_manifest_rejects_invalid_apt_package() {
693        let error = parse_workspace_manifest(
694            r#"
695name: workspace
696folders:
697  - /projects/backend
698runtime:
699  apt:
700    - python3;curl
701"#,
702        )
703        .expect_err("invalid apt package should fail");
704
705        assert!(matches!(
706            error,
707            ManifestError::RuntimeSpec(crate::runtime::RuntimeSpecError::InvalidAptPackage {
708                package
709            }) if package == "python3;curl"
710        ));
711    }
712
713    #[test]
714    fn validate_workspace_folders_accepts_existing_directories() {
715        let temp_dir = TestTempDir::create();
716        let folder = temp_dir.path().join("project");
717        fs::create_dir(&folder).expect("workspace folder should be created");
718        let manifest = WorkspaceManifest::new(
719            "workspace".to_owned(),
720            vec![folder],
721            SandboxConfig::default(),
722        )
723        .expect("manifest should be valid");
724
725        validate_workspace_folders(&manifest).expect("folder validation should pass");
726    }
727
728    #[test]
729    fn validate_workspace_folders_rejects_missing_paths() {
730        let temp_dir = TestTempDir::create();
731        let missing_folder = temp_dir.path().join("missing");
732        let manifest = WorkspaceManifest::new(
733            "workspace".to_owned(),
734            vec![missing_folder.clone()],
735            SandboxConfig::default(),
736        )
737        .expect("manifest should be valid");
738
739        let error = validate_workspace_folders(&manifest).expect_err("missing folder should fail");
740
741        assert!(matches!(
742            error,
743            ManifestError::FolderMissing { path } if path == missing_folder
744        ));
745    }
746
747    #[test]
748    fn validate_workspace_folders_rejects_files() {
749        let temp_dir = TestTempDir::create();
750        let file_path = temp_dir.path().join("file.txt");
751        fs::write(&file_path, "not a directory").expect("file should be written");
752        let manifest = WorkspaceManifest::new(
753            "workspace".to_owned(),
754            vec![file_path.clone()],
755            SandboxConfig::default(),
756        )
757        .expect("manifest should be valid");
758
759        let error = validate_workspace_folders(&manifest).expect_err("file path should fail");
760
761        assert!(matches!(
762            error,
763            ManifestError::FolderNotDirectory { path } if path == file_path
764        ));
765    }
766}