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    CONTAINER_WORKSPACE_ROOT, DEFAULT_CODEX_IMAGE, DEFAULT_CODEX_IMAGE_VERSION, DockerLaunchConfig,
14    ProviderConfigFiles, build_docker_run_command, workspace_mount_targets,
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        .context("failed to build trusted workspace configuration")?;
245    fs::write(&config_path, config_toml).with_context(|| {
246        format!(
247            "failed to write provider config file '{}'",
248            config_path.display()
249        )
250    })?;
251
252    Ok(RunScopedProviderConfig::new(
253        config_dir,
254        ProviderConfigFiles::new(auth_path, config_path),
255    ))
256}
257
258fn trusted_workspace_config(
259    provider_config_toml: &str,
260    manifest: &WorkspaceManifest,
261) -> Result<String> {
262    let trusted_paths = trusted_workspace_paths(manifest)?;
263    let mut config = String::with_capacity(provider_config_toml.len() + trusted_paths.len() * 64);
264    config.push_str(provider_config_toml.trim_end());
265    config.push_str("\n\n");
266
267    for path in trusted_paths {
268        config.push_str(&format!(
269            "[projects.\"{path}\"]\ntrust_level = \"trusted\"\n\n"
270        ));
271    }
272
273    Ok(config)
274}
275
276fn trusted_workspace_paths(manifest: &WorkspaceManifest) -> Result<Vec<String>> {
277    let mount_targets =
278        workspace_mount_targets(manifest).context("failed to resolve workspace mount targets")?;
279    if mount_targets.len() == 1 {
280        return Ok(mount_targets);
281    }
282
283    let mut paths = Vec::with_capacity(mount_targets.len() + 1);
284    paths.push(CONTAINER_WORKSPACE_ROOT.to_owned());
285    paths.extend(mount_targets);
286    Ok(paths)
287}
288
289fn create_host_directory(path: &Path, label: &str) -> Result<()> {
290    fs::create_dir_all(path)
291        .with_context(|| format!("failed to create {label} directory '{}'", path.display()))
292}
293
294fn create_run_scoped_directory(root: &Path, prefix: &str) -> Result<RunScopedDirectory> {
295    fs::create_dir_all(root).with_context(|| {
296        format!(
297            "failed to create run-scoped root directory '{}'",
298            root.display()
299        )
300    })?;
301    let timestamp = SystemTime::now()
302        .duration_since(UNIX_EPOCH)
303        .context("system clock is before the Unix epoch")?
304        .as_nanos();
305    let path = root.join(format!("{prefix}-{}-{timestamp}", std::process::id()));
306    fs::create_dir(&path)
307        .with_context(|| format!("failed to create run-scoped directory '{}'", path.display()))?;
308    Ok(RunScopedDirectory::new(path))
309}
310
311fn ensure_default_image(image: &str) -> Result<()> {
312    if image != DEFAULT_CODEX_IMAGE {
313        return Ok(());
314    }
315
316    let inspect_output = Command::new("docker")
317        .args([
318            "image",
319            "inspect",
320            image,
321            "--format",
322            "{{ index .Config.Labels \"org.openai.codex-ws.image-version\" }}",
323        ])
324        .output()
325        .context("failed to inspect Docker image")?;
326    let image_version = String::from_utf8_lossy(&inspect_output.stdout);
327    if inspect_output.status.success() && image_version.trim() == DEFAULT_CODEX_IMAGE_VERSION {
328        return Ok(());
329    }
330
331    let pull_status = Command::new("docker")
332        .args(["pull", image])
333        .status()
334        .context("failed to pull Codex workspace Docker image")?;
335    if pull_status.success() {
336        return Ok(());
337    }
338
339    Err(anyhow!("failed to pull Docker image '{image}'"))
340}
341
342fn select_provider(providers: Vec<CodexProvider>, provider_name: &str) -> Result<CodexProvider> {
343    providers
344        .into_iter()
345        .find(|provider| provider.name() == provider_name)
346        .ok_or_else(|| anyhow!("Codex provider '{provider_name}' was not found"))
347}
348
349fn exit_code_from_status(status: ExitStatus) -> ExitCode {
350    match status.code() {
351        Some(0) => ExitCode::SUCCESS,
352        Some(_) | None => ExitCode::FAILURE,
353    }
354}
355
356#[derive(Debug)]
357struct RunScopedProviderConfig {
358    _directory: RunScopedDirectory,
359    files: ProviderConfigFiles,
360}
361
362impl RunScopedProviderConfig {
363    fn new(directory: RunScopedDirectory, files: ProviderConfigFiles) -> Self {
364        Self {
365            _directory: directory,
366            files,
367        }
368    }
369
370    fn files(&self) -> &ProviderConfigFiles {
371        &self.files
372    }
373}
374
375#[derive(Debug)]
376struct RunScopedDirectory {
377    path: PathBuf,
378}
379
380impl RunScopedDirectory {
381    fn new(path: PathBuf) -> Self {
382        Self { path }
383    }
384
385    fn path(&self) -> &Path {
386        &self.path
387    }
388}
389
390impl Drop for RunScopedDirectory {
391    fn drop(&mut self) {
392        let _ = fs::remove_dir_all(&self.path);
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use std::sync::atomic::{AtomicUsize, Ordering};
399
400    use super::*;
401
402    static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
403
404    #[test]
405    fn select_provider_returns_matching_provider() {
406        let provider = CodexProvider::new(
407            "primary".to_owned(),
408            "auth.json".to_owned(),
409            "config.toml".to_owned(),
410        );
411
412        let selected = select_provider(vec![provider.clone()], "primary")
413            .expect("provider should be selected");
414
415        assert_eq!(selected, provider);
416    }
417
418    #[test]
419    fn select_provider_rejects_missing_provider() {
420        let error = select_provider(Vec::new(), "missing")
421            .expect_err("missing provider should fail")
422            .to_string();
423
424        assert_eq!(error, "Codex provider 'missing' was not found");
425    }
426
427    #[test]
428    fn write_provider_config_files_writes_auth_json_and_config_toml() {
429        let temp_dir = TestTempDir::create();
430        let provider = CodexProvider::new(
431            "primary".to_owned(),
432            "{\n  \"OPENAI_API_KEY\": \"test-key\"\n}".to_owned(),
433            "model = \"gpt-5.5\"\n".to_owned(),
434        );
435        let manifest = WorkspaceManifest::new(
436            "workspace".to_owned(),
437            vec![PathBuf::from("/host/project")],
438            crate::manifest::SandboxConfig::default(),
439        )
440        .expect("manifest should be valid");
441
442        let provider_config =
443            write_provider_config_files(&provider, &manifest, &temp_dir.path().join("config"))
444                .expect("provider config files should be written");
445
446        assert_eq!(
447            fs::read_to_string(provider_config.files().auth_path())
448                .expect("auth file should be readable"),
449            "{\n  \"OPENAI_API_KEY\": \"test-key\"\n}"
450        );
451        assert_eq!(
452            fs::read_to_string(provider_config.files().config_path())
453                .expect("config file should be readable"),
454            "model = \"gpt-5.5\"\n\n[projects.\"/workspace/project\"]\ntrust_level = \"trusted\"\n\n"
455        );
456    }
457
458    #[test]
459    fn effective_docker_launch_config_uses_manifest_runtime_image() {
460        let config = RunConfig::new(
461            "primary".to_owned(),
462            PathBuf::from("/tmp/workspace.yaml"),
463            PathBuf::from("/tmp/cc-switch.db"),
464            None,
465            DockerLaunchConfig::new(
466                DEFAULT_CODEX_IMAGE.to_owned(),
467                PathBuf::from("/host/.codex-ws"),
468            ),
469        );
470        let manifest = WorkspaceManifest::with_runtime(
471            "workspace".to_owned(),
472            vec![PathBuf::from("/host/project")],
473            crate::manifest::SandboxConfig::default(),
474            crate::manifest::RuntimeConfig::new(Some("rust-codex-ws:latest".to_owned())),
475        )
476        .expect("manifest should be valid");
477
478        let launch_config = config.effective_docker_launch_config(&manifest);
479
480        assert_eq!(launch_config.image(), "rust-codex-ws:latest");
481    }
482
483    #[test]
484    fn effective_docker_launch_config_prefers_cli_image_override() {
485        let config = RunConfig::new(
486            "primary".to_owned(),
487            PathBuf::from("/tmp/workspace.yaml"),
488            PathBuf::from("/tmp/cc-switch.db"),
489            Some("cli-codex-ws:latest".to_owned()),
490            DockerLaunchConfig::new(
491                DEFAULT_CODEX_IMAGE.to_owned(),
492                PathBuf::from("/host/.codex-ws"),
493            ),
494        );
495        let manifest = WorkspaceManifest::with_runtime(
496            "workspace".to_owned(),
497            vec![PathBuf::from("/host/project")],
498            crate::manifest::SandboxConfig::default(),
499            crate::manifest::RuntimeConfig::new(Some("manifest-codex-ws:latest".to_owned())),
500        )
501        .expect("manifest should be valid");
502
503        let launch_config = config.effective_docker_launch_config(&manifest);
504
505        assert_eq!(launch_config.image(), "cli-codex-ws:latest");
506    }
507
508    #[test]
509    fn trusted_workspace_config_trusts_every_container_workspace_path() {
510        let manifest = WorkspaceManifest::new(
511            "workspace".to_owned(),
512            vec![
513                PathBuf::from("/host/backend"),
514                PathBuf::from("/host/frontend"),
515            ],
516            crate::manifest::SandboxConfig::default(),
517        )
518        .expect("manifest should be valid");
519
520        let config = trusted_workspace_config("model = \"gpt-5.5\"\n", &manifest)
521            .expect("trusted workspace config should build");
522
523        assert!(config.contains("[projects.\"/workspace\"]\ntrust_level = \"trusted\""));
524        assert!(config.contains("[projects.\"/workspace/backend\"]\ntrust_level = \"trusted\""));
525        assert!(config.contains("[projects.\"/workspace/frontend\"]\ntrust_level = \"trusted\""));
526    }
527
528    #[derive(Debug)]
529    struct TestTempDir {
530        path: PathBuf,
531    }
532
533    impl TestTempDir {
534        fn create() -> Self {
535            let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
536            let timestamp = SystemTime::now()
537                .duration_since(UNIX_EPOCH)
538                .expect("system clock should be after Unix epoch")
539                .as_nanos();
540            let path = std::env::temp_dir().join(format!(
541                "codex-ws-app-test-{}-{timestamp}-{counter}",
542                std::process::id()
543            ));
544            fs::create_dir(&path).expect("temporary test directory should be created");
545            Self { path }
546        }
547
548        fn path(&self) -> &Path {
549            &self.path
550        }
551    }
552
553    impl Drop for TestTempDir {
554        fn drop(&mut self) {
555            let _ = fs::remove_dir_all(&self.path);
556        }
557    }
558}