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#[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 #[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 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 #[must_use]
94 pub fn provider_name(&self) -> &str {
95 &self.provider_name
96 }
97
98 #[must_use]
104 pub fn workspace_path(&self) -> &Path {
105 &self.workspace_path
106 }
107
108 #[must_use]
114 pub fn provider_database_path(&self) -> &Path {
115 &self.provider_database_path
116 }
117
118 #[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
141pub 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
176pub 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}