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#[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 #[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 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 #[must_use]
90 pub fn provider_name(&self) -> &str {
91 &self.provider_name
92 }
93
94 #[must_use]
100 pub fn workspace_path(&self) -> &Path {
101 &self.workspace_path
102 }
103
104 #[must_use]
110 pub fn provider_database_path(&self) -> &Path {
111 &self.provider_database_path
112 }
113
114 #[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
137pub 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}