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#[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 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}