Skip to main content

codex_ws/
app.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode, ExitStatus};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result, anyhow};
7
8use crate::cli::RunArgs;
9use crate::docker::{
10    DEFAULT_CODEX_IMAGE, DEFAULT_CODEX_IMAGE_VERSION, DockerLaunchConfig, ProviderConfigFiles,
11    build_docker_run_command,
12};
13use crate::manifest::{WorkspaceManifest, load_workspace_manifest, validate_workspace_folders};
14use crate::provider::{CodexProvider, load_codex_providers};
15use crate::workspace::{expand_home_path, resolve_workspace_path};
16
17/// Run configuration derived from CLI arguments.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RunConfig {
20    provider_name: String,
21    workspace_path: PathBuf,
22    provider_database_path: PathBuf,
23    image_override: Option<String>,
24    docker_launch_config: DockerLaunchConfig,
25}
26
27impl RunConfig {
28    /// Create run configuration.
29    ///
30    /// # Arguments
31    ///
32    /// * `provider_name` - Provider name selected by the user.
33    /// * `workspace_path` - Path to the workspace manifest YAML file.
34    /// * `provider_database_path` - Path to the local provider configuration database.
35    /// * `image_override` - Optional CLI-selected image for this launch.
36    /// * `docker_launch_config` - Docker image and sessions-root settings.
37    ///
38    /// # Returns
39    ///
40    /// A run configuration value.
41    #[must_use]
42    pub fn new(
43        provider_name: String,
44        workspace_path: PathBuf,
45        provider_database_path: PathBuf,
46        image_override: Option<String>,
47        docker_launch_config: DockerLaunchConfig,
48    ) -> Self {
49        Self {
50            provider_name,
51            workspace_path,
52            provider_database_path,
53            image_override,
54            docker_launch_config,
55        }
56    }
57
58    /// Build run configuration from parsed CLI arguments.
59    ///
60    /// # Arguments
61    ///
62    /// * `args` - Parsed `run` command arguments.
63    ///
64    /// # Returns
65    ///
66    /// A run configuration with shell-style home-directory paths expanded.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error when a workspace name cannot be resolved.
71    pub fn from_args(args: RunArgs) -> Result<Self> {
72        let sessions_root = expand_home_path(args.sessions_root);
73        let workspace_path = resolve_workspace_path(args.workspace, &sessions_root)?;
74
75        Ok(Self::new(
76            args.provider,
77            workspace_path,
78            expand_home_path(args.config_db),
79            args.image,
80            DockerLaunchConfig::new(DEFAULT_CODEX_IMAGE.to_owned(), sessions_root),
81        ))
82    }
83
84    /// Return the selected provider name.
85    ///
86    /// # Returns
87    ///
88    /// Provider name requested by the user.
89    #[must_use]
90    pub fn provider_name(&self) -> &str {
91        &self.provider_name
92    }
93
94    /// Return the workspace manifest path.
95    ///
96    /// # Returns
97    ///
98    /// Path to the workspace manifest YAML file.
99    #[must_use]
100    pub fn workspace_path(&self) -> &Path {
101        &self.workspace_path
102    }
103
104    /// Return the provider database path.
105    ///
106    /// # Returns
107    ///
108    /// Path to the local provider configuration database.
109    #[must_use]
110    pub fn provider_database_path(&self) -> &Path {
111        &self.provider_database_path
112    }
113
114    /// Return Docker launch settings.
115    ///
116    /// # Returns
117    ///
118    /// Docker image and session-root settings.
119    #[must_use]
120    pub fn docker_launch_config(&self) -> &DockerLaunchConfig {
121        &self.docker_launch_config
122    }
123
124    fn effective_docker_launch_config(&self, manifest: &WorkspaceManifest) -> DockerLaunchConfig {
125        if let Some(image) = &self.image_override {
126            return self.docker_launch_config.with_image(image.clone());
127        }
128
129        if let Some(image) = manifest.runtime().image() {
130            return self.docker_launch_config.with_image(image.to_owned());
131        }
132
133        self.docker_launch_config.clone()
134    }
135}
136
137/// Execute the configured workspace launch.
138///
139/// # Arguments
140///
141/// * `config` - Run configuration derived from CLI arguments.
142///
143/// # Returns
144///
145/// The process exit code that should be returned by the CLI.
146///
147/// # Errors
148///
149/// Returns an error when provider loading, manifest loading, folder validation, session directory
150/// creation, Docker command construction, or Docker execution fails.
151pub fn run_workspace(config: &RunConfig) -> Result<ExitCode> {
152    let providers = load_codex_providers(config.provider_database_path()).with_context(|| {
153        format!(
154            "failed to load providers from '{}'",
155            config.provider_database_path().display()
156        )
157    })?;
158    let provider = select_provider(providers, config.provider_name())?;
159    let manifest = load_workspace_manifest(config.workspace_path()).with_context(|| {
160        format!(
161            "failed to load workspace manifest '{}'",
162            config.workspace_path().display()
163        )
164    })?;
165
166    validate_workspace_folders(&manifest).context("workspace folder validation failed")?;
167    let docker_launch_config = config.effective_docker_launch_config(&manifest);
168
169    let sessions_path = docker_launch_config.workspace_sessions_path(manifest.name());
170    create_host_directory(&sessions_path, "workspace sessions")?;
171    ensure_default_image(docker_launch_config.image())?;
172
173    let provider_config = write_provider_config_files(
174        &provider,
175        &manifest,
176        &docker_launch_config
177            .sessions_root()
178            .join(manifest.name())
179            .join("provider-config"),
180    )?;
181    let mut command =
182        build_docker_run_command(provider_config.files(), &manifest, &docker_launch_config)
183            .context("failed to build Docker launch command")?;
184    let status = command.status().context("failed to execute Docker")?;
185
186    Ok(exit_code_from_status(status))
187}
188
189fn write_provider_config_files(
190    provider: &CodexProvider,
191    manifest: &WorkspaceManifest,
192    provider_config_root: &Path,
193) -> Result<RunScopedProviderConfig> {
194    let config_dir = create_run_scoped_directory(provider_config_root, "codex-ws-provider")?;
195
196    let auth_path = config_dir.path().join("auth.json");
197    let config_path = config_dir.path().join("config.toml");
198    fs::write(&auth_path, provider.auth_json()).with_context(|| {
199        format!(
200            "failed to write provider auth file '{}'",
201            auth_path.display()
202        )
203    })?;
204    let config_toml = trusted_workspace_config(provider.config_toml(), manifest);
205    fs::write(&config_path, config_toml).with_context(|| {
206        format!(
207            "failed to write provider config file '{}'",
208            config_path.display()
209        )
210    })?;
211
212    Ok(RunScopedProviderConfig::new(
213        config_dir,
214        ProviderConfigFiles::new(auth_path, config_path),
215    ))
216}
217
218fn trusted_workspace_config(provider_config_toml: &str, manifest: &WorkspaceManifest) -> String {
219    let mut config =
220        String::with_capacity(provider_config_toml.len() + manifest.folders().len() * 64);
221    config.push_str(provider_config_toml.trim_end());
222    config.push_str("\n\n");
223
224    for index in 0..manifest.folders().len() {
225        config.push_str(&format!(
226            "[projects.\"/workspace/{}\"]\ntrust_level = \"trusted\"\n\n",
227            index + 1
228        ));
229    }
230
231    config
232}
233
234fn create_host_directory(path: &Path, label: &str) -> Result<()> {
235    fs::create_dir_all(path)
236        .with_context(|| format!("failed to create {label} directory '{}'", path.display()))
237}
238
239fn create_run_scoped_directory(root: &Path, prefix: &str) -> Result<RunScopedDirectory> {
240    fs::create_dir_all(root).with_context(|| {
241        format!(
242            "failed to create run-scoped root directory '{}'",
243            root.display()
244        )
245    })?;
246    let timestamp = SystemTime::now()
247        .duration_since(UNIX_EPOCH)
248        .context("system clock is before the Unix epoch")?
249        .as_nanos();
250    let path = root.join(format!("{prefix}-{}-{timestamp}", std::process::id()));
251    fs::create_dir(&path)
252        .with_context(|| format!("failed to create run-scoped directory '{}'", path.display()))?;
253    Ok(RunScopedDirectory::new(path))
254}
255
256fn ensure_default_image(image: &str) -> Result<()> {
257    if image != DEFAULT_CODEX_IMAGE {
258        return Ok(());
259    }
260
261    let inspect_output = Command::new("docker")
262        .args([
263            "image",
264            "inspect",
265            image,
266            "--format",
267            "{{ index .Config.Labels \"org.openai.codex-ws.image-version\" }}",
268        ])
269        .output()
270        .context("failed to inspect Docker image")?;
271    let image_version = String::from_utf8_lossy(&inspect_output.stdout);
272    if inspect_output.status.success() && image_version.trim() == DEFAULT_CODEX_IMAGE_VERSION {
273        return Ok(());
274    }
275
276    let pull_status = Command::new("docker")
277        .args(["pull", image])
278        .status()
279        .context("failed to pull Codex workspace Docker image")?;
280    if pull_status.success() {
281        return Ok(());
282    }
283
284    Err(anyhow!("failed to pull Docker image '{image}'"))
285}
286
287fn select_provider(providers: Vec<CodexProvider>, provider_name: &str) -> Result<CodexProvider> {
288    providers
289        .into_iter()
290        .find(|provider| provider.name() == provider_name)
291        .ok_or_else(|| anyhow!("Codex provider '{provider_name}' was not found"))
292}
293
294fn exit_code_from_status(status: ExitStatus) -> ExitCode {
295    match status.code() {
296        Some(0) => ExitCode::SUCCESS,
297        Some(_) | None => ExitCode::FAILURE,
298    }
299}
300
301#[derive(Debug)]
302struct RunScopedProviderConfig {
303    _directory: RunScopedDirectory,
304    files: ProviderConfigFiles,
305}
306
307impl RunScopedProviderConfig {
308    fn new(directory: RunScopedDirectory, files: ProviderConfigFiles) -> Self {
309        Self {
310            _directory: directory,
311            files,
312        }
313    }
314
315    fn files(&self) -> &ProviderConfigFiles {
316        &self.files
317    }
318}
319
320#[derive(Debug)]
321struct RunScopedDirectory {
322    path: PathBuf,
323}
324
325impl RunScopedDirectory {
326    fn new(path: PathBuf) -> Self {
327        Self { path }
328    }
329
330    fn path(&self) -> &Path {
331        &self.path
332    }
333}
334
335impl Drop for RunScopedDirectory {
336    fn drop(&mut self) {
337        let _ = fs::remove_dir_all(&self.path);
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use std::sync::atomic::{AtomicUsize, Ordering};
344
345    use super::*;
346
347    static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
348
349    #[test]
350    fn select_provider_returns_matching_provider() {
351        let provider = CodexProvider::new(
352            "primary".to_owned(),
353            "auth.json".to_owned(),
354            "config.toml".to_owned(),
355        );
356
357        let selected = select_provider(vec![provider.clone()], "primary")
358            .expect("provider should be selected");
359
360        assert_eq!(selected, provider);
361    }
362
363    #[test]
364    fn select_provider_rejects_missing_provider() {
365        let error = select_provider(Vec::new(), "missing")
366            .expect_err("missing provider should fail")
367            .to_string();
368
369        assert_eq!(error, "Codex provider 'missing' was not found");
370    }
371
372    #[test]
373    fn write_provider_config_files_writes_auth_json_and_config_toml() {
374        let temp_dir = TestTempDir::create();
375        let provider = CodexProvider::new(
376            "primary".to_owned(),
377            "{\n  \"OPENAI_API_KEY\": \"test-key\"\n}".to_owned(),
378            "model = \"gpt-5.5\"\n".to_owned(),
379        );
380        let manifest = WorkspaceManifest::new(
381            "workspace".to_owned(),
382            vec![PathBuf::from("/host/project")],
383            crate::manifest::SandboxConfig::default(),
384        )
385        .expect("manifest should be valid");
386
387        let provider_config =
388            write_provider_config_files(&provider, &manifest, &temp_dir.path().join("config"))
389                .expect("provider config files should be written");
390
391        assert_eq!(
392            fs::read_to_string(provider_config.files().auth_path())
393                .expect("auth file should be readable"),
394            "{\n  \"OPENAI_API_KEY\": \"test-key\"\n}"
395        );
396        assert_eq!(
397            fs::read_to_string(provider_config.files().config_path())
398                .expect("config file should be readable"),
399            "model = \"gpt-5.5\"\n\n[projects.\"/workspace/1\"]\ntrust_level = \"trusted\"\n\n"
400        );
401    }
402
403    #[test]
404    fn effective_docker_launch_config_uses_manifest_runtime_image() {
405        let config = RunConfig::new(
406            "primary".to_owned(),
407            PathBuf::from("/tmp/workspace.yaml"),
408            PathBuf::from("/tmp/cc-switch.db"),
409            None,
410            DockerLaunchConfig::new(
411                DEFAULT_CODEX_IMAGE.to_owned(),
412                PathBuf::from("/host/.codex-ws"),
413            ),
414        );
415        let manifest = WorkspaceManifest::with_runtime(
416            "workspace".to_owned(),
417            vec![PathBuf::from("/host/project")],
418            crate::manifest::SandboxConfig::default(),
419            crate::manifest::RuntimeConfig::new(Some("rust-codex-ws:latest".to_owned())),
420        )
421        .expect("manifest should be valid");
422
423        let launch_config = config.effective_docker_launch_config(&manifest);
424
425        assert_eq!(launch_config.image(), "rust-codex-ws:latest");
426    }
427
428    #[test]
429    fn effective_docker_launch_config_prefers_cli_image_override() {
430        let config = RunConfig::new(
431            "primary".to_owned(),
432            PathBuf::from("/tmp/workspace.yaml"),
433            PathBuf::from("/tmp/cc-switch.db"),
434            Some("cli-codex-ws:latest".to_owned()),
435            DockerLaunchConfig::new(
436                DEFAULT_CODEX_IMAGE.to_owned(),
437                PathBuf::from("/host/.codex-ws"),
438            ),
439        );
440        let manifest = WorkspaceManifest::with_runtime(
441            "workspace".to_owned(),
442            vec![PathBuf::from("/host/project")],
443            crate::manifest::SandboxConfig::default(),
444            crate::manifest::RuntimeConfig::new(Some("manifest-codex-ws:latest".to_owned())),
445        )
446        .expect("manifest should be valid");
447
448        let launch_config = config.effective_docker_launch_config(&manifest);
449
450        assert_eq!(launch_config.image(), "cli-codex-ws:latest");
451    }
452
453    #[test]
454    fn trusted_workspace_config_trusts_every_container_workspace_path() {
455        let manifest = WorkspaceManifest::new(
456            "workspace".to_owned(),
457            vec![
458                PathBuf::from("/host/backend"),
459                PathBuf::from("/host/frontend"),
460            ],
461            crate::manifest::SandboxConfig::default(),
462        )
463        .expect("manifest should be valid");
464
465        let config = trusted_workspace_config("model = \"gpt-5.5\"\n", &manifest);
466
467        assert!(config.contains("[projects.\"/workspace/1\"]\ntrust_level = \"trusted\""));
468        assert!(config.contains("[projects.\"/workspace/2\"]\ntrust_level = \"trusted\""));
469    }
470
471    #[derive(Debug)]
472    struct TestTempDir {
473        path: PathBuf,
474    }
475
476    impl TestTempDir {
477        fn create() -> Self {
478            let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
479            let timestamp = SystemTime::now()
480                .duration_since(UNIX_EPOCH)
481                .expect("system clock should be after Unix epoch")
482                .as_nanos();
483            let path = std::env::temp_dir().join(format!(
484                "codex-ws-app-test-{}-{timestamp}-{counter}",
485                std::process::id()
486            ));
487            fs::create_dir(&path).expect("temporary test directory should be created");
488            Self { path }
489        }
490
491        fn path(&self) -> &Path {
492            &self.path
493        }
494    }
495
496    impl Drop for TestTempDir {
497        fn drop(&mut self) {
498            let _ = fs::remove_dir_all(&self.path);
499        }
500    }
501}