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