From 3525845978210579572bd8ec4fe8c8296445fb8f Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Sat, 5 Sep 2026 17:52:04 +0000 Subject: [PATCH] Support managed worktrees for interactive sessions and forks (#43069) ## Why `--worktree` previously supported only `codex exec` and `codex exec fork`. Interactive sessions need the same managed checkout support, with configuration and policy resolved for the destination before starting a turn. ## What changed - Enable `codex --worktree` and `codex fork --worktree ` behind the `worktrees` feature, restricted to local sessions. Interactive forks require an explicit session selector. - Resolve interactive and exec worktree forks from the session's latest saved working directory unless `--cd` is supplied. Keep relative `--add-dir` paths anchored to the invocation directory. - Load interactive destination configuration before telemetry and login policy initialization, and bind checkout ownership before the first turn. - Reject explicitly untrusted sources, including when destination cloud policy or refreshed configuration reveals distrust. Retain unbound interactive checkouts after startup failure and report manual recovery instructions. ## Testing Add CLI integration and TUI tests for startup and named forks, destination instructions and configuration, ownership before the first request, trust enforcement, and retained-checkout recovery. Extend exec coverage for saved fork directories, explicit `--cd`, relative writable roots, and cloud policy rejection. GitOrigin-RevId: 371583f7feca73b218a4ff188b2b85812c8439e6 --- codex-rs/.config/nextest.toml | 5 + codex-rs/Cargo.lock | 1 + codex-rs/cli/src/main.rs | 40 +- ..._tests__unsupported_worktree_commands.snap | 13 + codex-rs/cli/tests/worktree.rs | 691 ++++++++++++++++++ codex-rs/exec/src/lib.rs | 60 +- codex-rs/exec/src/worktree.rs | 82 +++ codex-rs/exec/tests/suite/worktree.rs | 170 ++++- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app/startup.rs | 16 +- codex-rs/tui/src/app/startup_prompts.rs | 12 +- codex-rs/tui/src/lib.rs | 86 ++- codex-rs/tui/src/startup_orchestration.rs | 64 +- codex-rs/tui/src/worktree_startup.rs | 292 ++++++++ codex-rs/tui/src/worktree_startup_tests.rs | 134 ++++ 15 files changed, 1626 insertions(+), 41 deletions(-) create mode 100644 codex-rs/cli/src/snapshots/codex__tests__unsupported_worktree_commands.snap create mode 100644 codex-rs/cli/tests/worktree.rs create mode 100644 codex-rs/exec/src/worktree.rs create mode 100644 codex-rs/tui/src/worktree_startup.rs create mode 100644 codex-rs/tui/src/worktree_startup_tests.rs diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index f3f3ca2607..1204de58d7 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -36,6 +36,11 @@ max-threads = 2 filter = 'test(rmcp_client) | test(humanlike_typing_1000_chars_appears_live_no_placeholder)' slow-timeout = { period = "1m", terminate-after = 4 } +[[profile.default.overrides]] +# This end-to-end case launches several CLI subprocesses and checks cloud policy. +filter = 'package(codex-exec) & test(worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume)' +slow-timeout = { period = "30s", terminate-after = 4 } + [[profile.default.overrides]] filter = 'test(approval_matrix_covers_all_modes)' slow-timeout = { period = "30s", terminate-after = 2 } diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 31955db0c9..d8ee36ddc1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4621,6 +4621,7 @@ dependencies = [ "codex-utils-sleep-inhibitor", "codex-utils-string", "codex-windows-sandbox", + "codex-worktree", "color-eyre", "core_test_support", "crossterm", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index abcfb720ab..50aa97352b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2486,6 +2486,11 @@ fn reject_unsupported_worktree_for_subcommand( } match subcommand { + None => Ok(()), + Some(Subcommand::Fork(command)) if command.session_id.is_some() && !command.last => Ok(()), + Some(Subcommand::Fork(_)) => { + anyhow::bail!("`codex fork --worktree` requires an explicit session ID") + } Some(Subcommand::Exec(command)) => match &command.command { None | Some(ExecCommand::Fork(_)) => Ok(()), Some(ExecCommand::Resume(_)) => anyhow::bail!( @@ -2496,7 +2501,9 @@ fn reject_unsupported_worktree_for_subcommand( } }, _ => { - anyhow::bail!("`--worktree` currently supports only `codex exec` and `codex exec fork`") + anyhow::bail!( + "`--worktree` supports new interactive sessions, `codex fork`, `codex exec`, and `codex exec fork`" + ) } } } @@ -3317,10 +3324,18 @@ mod tests { } #[test] - fn exec_worktree_flag_supports_root_local_and_nested_fork_positions() { + fn worktree_flag_supports_interactive_exec_and_explicit_fork_positions() { let arguments = [ + vec!["codex", "--worktree"], + vec!["codex", "--worktree", "hello"], vec!["codex", "--worktree", "exec", "hello"], vec!["codex", "exec", "--worktree", "hello"], + vec![ + "codex", + "fork", + "--worktree", + "019f1234-5678-7000-8000-000000000001", + ], vec![ "codex", "exec", @@ -3338,7 +3353,7 @@ mod tests { &cli.subcommand, ) .is_ok(), - "headless worktree command should be accepted: {arguments:?}", + "supported worktree command should be accepted: {arguments:?}", ); } } @@ -3346,8 +3361,9 @@ mod tests { #[test] fn worktree_flag_rejects_unsupported_session_and_management_commands() { let arguments = [ - vec!["codex", "--worktree"], vec!["codex", "--worktree", "login"], + vec!["codex", "fork", "--worktree"], + vec!["codex", "fork", "--worktree", "--last"], vec!["codex", "exec", "resume", "--worktree", "session"], vec!["codex", "exec", "review", "--worktree"], vec!["codex", "resume", "--worktree", "session"], @@ -3363,17 +3379,17 @@ mod tests { ], ]; + let mut errors = Vec::new(); for arguments in arguments { let cli = MultitoolCli::try_parse_from(&arguments).expect("parse shared worktree flag"); - assert!( - reject_unsupported_worktree_for_subcommand( - cli.interactive.shared.worktree, - &cli.subcommand, - ) - .is_err(), - "unsupported command must be rejected: {arguments:?}", - ); + let error = reject_unsupported_worktree_for_subcommand( + cli.interactive.shared.worktree, + &cli.subcommand, + ) + .expect_err("unsupported worktree command"); + errors.push(format!("{}: {error}", arguments.join(" "))); } + insta::assert_snapshot!("unsupported_worktree_commands", errors.join("\n")); } #[test] diff --git a/codex-rs/cli/src/snapshots/codex__tests__unsupported_worktree_commands.snap b/codex-rs/cli/src/snapshots/codex__tests__unsupported_worktree_commands.snap new file mode 100644 index 0000000000..860338e9f6 --- /dev/null +++ b/codex-rs/cli/src/snapshots/codex__tests__unsupported_worktree_commands.snap @@ -0,0 +1,13 @@ +--- +source: cli/src/main.rs +assertion_line: 3324 +expression: "errors.join(\"\\n\")" +--- +codex --worktree login: `--worktree` supports new interactive sessions, `codex fork`, `codex exec`, and `codex exec fork` +codex fork --worktree: `codex fork --worktree` requires an explicit session ID +codex fork --worktree --last: `codex fork --worktree` requires an explicit session ID +codex exec resume --worktree session: `--worktree` cannot resume an existing session; use `codex exec fork --worktree` +codex exec review --worktree: `--worktree` is not supported for code review +codex resume --worktree session: `--worktree` supports new interactive sessions, `codex fork`, `codex exec`, and `codex exec fork` +codex archive session --worktree: `--worktree` supports new interactive sessions, `codex fork`, `codex exec`, and `codex exec fork` +codex queue --thread session --message hi --worktree: `--worktree` supports new interactive sessions, `codex fork`, `codex exec`, and `codex exec fork` diff --git a/codex-rs/cli/tests/worktree.rs b/codex-rs/cli/tests/worktree.rs new file mode 100644 index 0000000000..12058440fe --- /dev/null +++ b/codex-rs/cli/tests/worktree.rs @@ -0,0 +1,691 @@ +//! Exercises interactive destination policy, startup and fork, and ownership before the first turn. + +use anyhow::Context as _; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::io::Write as _; +use std::path::Path; +use std::process::Command; +use std::time::Duration; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; + +fn git(cwd: &Path, args: &[&str]) -> anyhow::Result { + let output = Command::new("git") + .current_dir(cwd) + .args([ + "-c", + "user.name=Worktree Test", + "-c", + "user.email=test@example.invalid", + ]) + .args(args) + .output()?; + anyhow::ensure!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +// Retain only the unfinished terminal query, never the full output stream. +#[derive(Default)] +struct TerminalQueries(Vec); + +impl TerminalQueries { + fn feed(&mut self, bytes: &[u8]) -> Vec { + let mut replies = Vec::new(); + for &byte in bytes { + self.0.push(byte); + if self.0 == b"\x1b[6n" { + replies.extend_from_slice(b"\x1b[1;1R"); + self.0.clear(); + } else if self.0 == b"\x1b[c" { + replies.extend_from_slice(b"\x1b[?1;2c"); + self.0.clear(); + } else { + while !b"\x1b[6n".starts_with(&self.0) && !b"\x1b[c".starts_with(&self.0) { + self.0.remove(0); + } + } + } + replies + } +} + +#[test] +fn terminal_queries_reply_once_across_every_split() { + let input = b"noise\x1b[6n\x1b[c\x1b[6n"; + let expected = b"\x1b[1;1R\x1b[?1;2c\x1b[1;1R"; + for split in 0..=input.len() { + let mut queries = TerminalQueries::default(); + let mut replies = queries.feed(&input[..split]); + replies.extend(queries.feed(&input[split..])); + assert_eq!(replies, expected); + assert_eq!(queries.feed(b"more output"), Vec::::new()); + } +} + +async fn rejected_start( + program: &Path, + args: &[String], + cwd: &Path, + env: &HashMap, + expected: &str, +) -> anyhow::Result { + let spawned = codex_utils_pty::spawn_pty_process( + &program.to_string_lossy(), + args, + cwd, + env, + /*arg0*/ &None, + codex_utils_pty::TerminalSize { + rows: 40, + cols: 120, + }, + &[], + ) + .await?; + let mut stdout = spawned.stdout_rx; + let mut exit = spawned.exit_rx; + let mut output = String::new(); + let mut queries = TerminalQueries::default(); + let result = tokio::time::timeout(Duration::from_secs(/*secs*/ 45), async { + loop { + tokio::select! { + status = &mut exit, if output.contains(expected) => return Ok::<_, anyhow::Error>(status?), + bytes = stdout.recv() => { + let Some(bytes) = bytes else { + anyhow::ensure!(output.contains(expected), "TUI exited before rejection: {output}"); + return Ok(exit.await?); + }; + output.push_str(&String::from_utf8_lossy(&bytes)); + let replies = queries.feed(&bytes); + if !replies.is_empty() { spawned.session.writer_sender().send(replies).await?; } + } + } + } + }).await; + if !matches!(&result, Ok(Ok(code)) if *code != 0) { + let _ = writeln!( + std::io::stderr(), + "worktree rejection failed (timeout={}): expected {:?}; output prefix: {:?}", + result.is_err(), + expected.chars().take(/*n*/ 200).collect::(), + output.chars().take(/*n*/ 4000).collect::() + ); + } + if result.is_err() { + spawned.session.terminate(); + } + assert_ne!( + result.with_context(|| format!("rejection timed out: {output}"))??, + 0, + "{output}" + ); + Ok(output) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interactive_worktree_start_and_fork_bind_owner_before_turn() -> anyhow::Result<()> { + let root = TempDir::new()?; + let root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(root.path())? + .canonicalize()? + .into_path_buf(); + let home = root.join("home"); + let source = root.join("source"); + let launcher = root.join("launcher"); + for path in [&home, &source, &launcher, &launcher.join("extra")] { + fs::create_dir(path)?; + } + fs::write( + source.join("AGENTS.md"), + "committed destination instructions", + )?; + let server = MockServer::start().await; + fs::create_dir(source.join(".codex"))?; + fs::write( + source.join(".codex/config.toml"), + "model = \"destination-model\"\nanalytics.enabled = false\ncli_auth_credentials_store = \"file\"\n", + )?; + git(&source, &["init", "--quiet"])?; + git(&source, &["add", "."])?; + git( + &source, + &["commit", "--quiet", "--no-gpg-sign", "-m", "initial"], + )?; + fs::write( + home.join("config.toml"), + format!( + r#" +cli_auth_credentials_store = "file" +chatgpt_base_url = "{}/source/backend-api" +features.worktrees = true +check_for_update_on_startup = false +model_provider = "local" +model = "test-model" +sandbox_mode = "workspace-write" +windows.sandbox = "unelevated" +tui.disable_paste_burst = true +[otel] +metrics_exporter = {{ otlp-http = {{ endpoint = "{}/metrics", protocol = "json" }} }} +[model_providers.local] +name = "local test" +base_url = "{}/v1" +wire_api = "responses" +[projects.{}] +trust_level = "trusted" +[projects.{}] +trust_level = "trusted" +"#, + server.uri(), + server.uri(), + server.uri(), + serde_json::to_string(&source)?, + serde_json::to_string(&launcher)? + ), + )?; + fs::write( + source.join("AGENTS.md"), + "uncommitted launcher instructions", + )?; + fs::write( + source.join(".codex/config.toml"), + "model = \"source-model\"\nanalytics.enabled = true\n", + )?; + app_test_support::write_chatgpt_auth( + &home, + app_test_support::ChatGptAuthFixture::new("test-token") + .account_id("workspace-123") + .chatgpt_account_id("workspace-123") + .chatgpt_user_id("user-123") + .plan_type("enterprise"), + codex_config::types::AuthCredentialsStoreMode::File, + )?; + let program = codex_utils_cargo_bin::cargo_bin("codex")?; + let mut env: HashMap = std::env::vars().collect(); + env.insert("CODEX_HOME".into(), home.display().to_string()); + env.insert("CODEX_SQLITE_HOME".into(), home.display().to_string()); + env.insert("NO_PROXY".into(), "127.0.0.1,localhost".into()); + env.insert("no_proxy".into(), "127.0.0.1,localhost".into()); + env.insert("TERM".into(), "xterm-256color".into()); + env.insert("OTEL_METRIC_EXPORT_INTERVAL".into(), "100".into()); + for key in [ + "CODEX_EXEC_SERVER_URL", + "CODEX_ACCESS_TOKEN", + "OPENAI_API_KEY", + "CODEX_API_KEY", + ] { + env.remove(key); + } + let mut owner = None; + let mut previous: Vec = Vec::new(); + for (fork, explicit_cd, analytics, auth_failure) in [ + (false, false, false, false), + (true, false, false, false), + (true, true, false, false), + (false, false, true, false), + (false, false, false, true), + ] { + if auth_failure { + fs::write( + source.join(".codex/config.toml"), + "forced_login_method = \"api\"\n", + )?; + git( + &source, + &[ + "commit", + "--quiet", + "--no-gpg-sign", + "-am", + "require API login", + ], + )?; + fs::write(source.join(".codex/config.toml"), "")?; + } + let (metric_tx, mut metric_rx) = tokio::sync::mpsc::unbounded_channel(); + Mock::given(wiremock::matchers::path("/metrics")) + .respond_with(move |_: &wiremock::Request| { + let _ = metric_tx.send(()); + ResponseTemplate::new(200).set_body_json(serde_json::json!({})) + }) + .mount(&server) + .await; + Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/source/backend-api/wham/config/bundle", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "config_toml": { "enterprise_managed": [{ "id": "test", "name": "test", + "contents": "developer_instructions = \"managed cloud instructions\"" }] } + }))) + .mount(&server) + .await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let response = app_test_support::create_final_assistant_message_sse_response("done")?; + let observed_source = source.clone(); + let observed_pool = home.join("worktrees"); + let observed_previous = previous.clone(); + Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/responses")) + .respond_with(move |request: &wiremock::Request| { + let observed = (|| { + let checkouts = git(&observed_source, &["worktree", "list", "--porcelain"])?; + let checkout = checkouts + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .find(|path| { + Path::new(path).starts_with(&observed_pool) + && !observed_previous.contains(&path.to_string()) + }) + .context("new managed checkout")? + .to_owned(); + let metadata = git( + Path::new(&checkout), + &["rev-parse", "--git-path", "codex-thread.json"], + )?; + let metadata: Value = + serde_json::from_slice(&fs::read(Path::new(&checkout).join(metadata))?)?; + Ok::<_, anyhow::Error>((request.body.clone(), checkout, metadata)) + })(); + let _ = tx.send(observed); + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(response.clone()) + }) + .mount(&server) + .await; + let mut args = vec!["--worktree".to_owned(), "--no-alt-screen".to_owned()]; + if fork { + args.extend([ + "--add-dir".into(), + "extra".into(), + "fork".into(), + "managed-source".to_owned(), + ]); + } else { + args.extend(["--cd".into(), source.display().to_string()]); + } + if explicit_cd { + args.extend(["--cd".into(), source.join(".codex").display().to_string()]); + } + if analytics { + args.extend(["-c".into(), "analytics.enabled=true".into()]); + } + args.push("describe checkout".into()); + if previous.is_empty() { + let mut untrusted_args = args.clone(); + untrusted_args.extend([ + "-c".into(), + format!( + "projects={{{}={{trust_level=\"untrusted\"}}}}", + serde_json::to_string(&source)? + ), + ]); + rejected_start( + &program, + &untrusted_args, + &launcher, + &env, + "cannot create a checkout from an explicitly untrusted source", + ) + .await?; + assert!(!home.join("worktrees").exists()); + } + let spawned = codex_utils_pty::spawn_pty_process( + &program.to_string_lossy(), + &args, + &launcher, + &env, + /*arg0*/ &None, + codex_utils_pty::TerminalSize { + rows: 40, + cols: 120, + }, + &[], + ) + .await?; + let session = spawned.session; + let mut stdout = spawned.stdout_rx; + let mut output = String::new(); + let mut queries = TerminalQueries::default(); + let observed = tokio::time::timeout(Duration::from_secs(/*secs*/ 45), async { + loop { + tokio::select! { + body = rx.recv() => { + let (body, checkout, metadata) = body.context("model request")??; + let body: Value = serde_json::from_slice(&body)?; + return Ok::<_, anyhow::Error>((body, checkout, metadata)); + } + bytes = stdout.recv() => { + let bytes = bytes.context("TUI exited before model request")?; + let text = String::from_utf8_lossy(&bytes); + output.push_str(&text); + if auth_failure && output.contains("API key login is required") && output.contains("Do not use --force.") { + anyhow::bail!("observed required API login rejection"); + } + let replies = queries.feed(&bytes); + if !replies.is_empty() { session.writer_sender().send(replies).await?; } + } + } + } + }).await; + if auth_failure { + assert!(observed.is_ok_and(|result| result.is_err()), "{output}"); + let exit = + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), spawned.exit_rx).await??; + assert_ne!(exit, 0); + assert!(output.contains("API key login is required"), "{output}"); + assert!(!home.join("auth.json").exists()); + assert!(output.contains("The checkout was kept"), "{output}"); + continue; + } + let renamed = if !fork && matches!(observed, Ok(Ok(_))) { + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async { + session + .writer_sender() + .send(b"/rename managed-source\r".to_vec()) + .await?; + let (_, _, metadata) = observed.as_ref().unwrap().as_ref().unwrap(); + let thread_id = codex_protocol::ThreadId::from_string( + metadata["ownerThreadId"].as_str().context("owner id")?, + )?; + loop { + if codex_rollout::find_thread_name_by_id(&home, &thread_id) + .await? + .as_deref() + == Some("managed-source") + { + return Ok::<_, anyhow::Error>(()); + } + tokio::select! { + bytes = stdout.recv() => { + let bytes = bytes.context("TUI exited before rename confirmation")?; + output.push_str(&String::from_utf8_lossy(&bytes)); + let replies = queries.feed(&bytes); + if !replies.is_empty() { session.writer_sender().send(replies).await?; } + } + _ = tokio::time::sleep(Duration::from_millis(/*millis*/ 25)) => {} + } + } + }) + .await + .context("rename timed out") + .and_then(std::convert::identity) + } else { + Ok(()) + }; + if analytics { + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), metric_rx.recv()) + .await? + .context("metrics export while the TUI is running")?; + } + let startup_result = observed.as_ref().map(|result| result.as_ref().map(|_| ())); + if !matches!(startup_result, Ok(Ok(()))) || renamed.is_err() { + // Bypass libtest capture and report before tearing down the Windows PTY. + let _ = writeln!( + std::io::stderr(), + "worktree failure (fork={fork}, explicit_cd={explicit_cd}): startup={startup_result:?}, rename={renamed:?}\nTUI output: {:?}", + output.chars().take(/*n*/ 4000).collect::() + ); + } + if matches!(startup_result, Ok(Ok(()))) && renamed.is_ok() { + session.writer_sender().send(b"/quit\r".to_vec()).await?; + } else { + session.terminate(); + } + let exit = + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), spawned.exit_rx).await??; + // Include shutdown output: ConPTY can retain the channel after process exit. + let _ = tokio::time::timeout(Duration::from_secs(/*secs*/ 1), async { + while let Some(bytes) = stdout.recv().await { + output.push_str(&String::from_utf8_lossy(&bytes)); + } + }) + .await; + assert_eq!(exit, 0, "{output}"); + assert!(!output.contains("The checkout was kept"), "{output}"); + let metrics = server + .received_requests() + .await + .context("requests")? + .into_iter() + .filter(|request| request.url.path() == "/metrics") + .flat_map(|request| request.body) + .collect::>(); + assert!( + if analytics { + String::from_utf8_lossy(&metrics).contains("codex.tui.start") + } else { + metrics.is_empty() + }, + "analytics={analytics}, metrics_bytes={}", + metrics.len() + ); + let (body, checkout, metadata) = observed + .with_context(|| { + format!( + "startup timed out: {}", + output.chars().take(/*n*/ 4000).collect::() + ) + })? + .with_context(|| { + format!( + "startup output: {}", + output.chars().take(/*n*/ 4000).collect::() + ) + })?; + renamed.with_context(|| { + format!( + "rename output: {}", + output.chars().take(/*n*/ 4000).collect::() + ) + })?; + let next_owner = metadata["ownerThreadId"] + .as_str() + .context("owner bound before request")? + .to_owned(); + assert_ne!(owner.as_ref(), Some(&next_owner)); + assert_eq!( + git(Path::new(&checkout), &["show", "HEAD:AGENTS.md"])?, + "committed destination instructions" + ); + let context = body["input"] + .as_array() + .context("message input")? + .iter() + .filter_map(|item| item["content"].as_array()) + .flatten() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n"); + if explicit_cd { + let expected = format!("{checkout}/.codex").replace('\\', "/"); + assert!(context.replace('\\', "/").contains(&expected), "{context}"); + } + assert!( + context.contains("committed destination instructions"), + "{context}" + ); + assert!( + !context.contains("uncommitted launcher instructions"), + "{context}" + ); + if fork { + assert!( + context.contains(&launcher.join("extra").display().to_string()), + "{context}" + ); + } + assert_eq!(body["model"], "destination-model"); + assert!(context.contains("managed cloud instructions"), "{context}"); + if previous.is_empty() { + // A legacy summary still says launcher A, but persisted turns use checkout B. + let sqlite = codex_state::SqliteConfig::from_sqlite_home( + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&home)?, + ); + let db = sqlite.open_read_write_pool(&sqlite.state_db_path()).await?; + sqlx::query("UPDATE threads SET cwd = ? WHERE id = ?") + .bind(launcher.to_string_lossy().as_ref()) + .bind(&next_owner) + .execute(&db) + .await?; + db.close().await; + // B inherits trust from its primary repository; no exact trusted entry masks cloud distrust. + let home_config = home.join("config.toml"); + let launcher_config = fs::read_to_string(&home_config)?; + fs::write( + &home_config, + launcher_config.replace( + "cli_auth_credentials_store = \"file\"", + "cli_auth_credentials_store = \"ephemeral\"", + ), + )?; + let cache = home.join("cloud-config-bundle-cache.json"); + if cache.exists() { + fs::remove_file(&cache)?; + } + let request_count = server.received_requests().await.context("requests")?.len(); + Mock::given(wiremock::matchers::path("/source/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "config_toml": {"enterprise_managed": [{"id":"distrust", "name":"distrust", + "contents": format!("[projects.{}]\ntrust_level = \"untrusted\"\n", serde_json::to_string(&codex_config::loader::project_trust_key(Path::new(&checkout)))?)}]} + }))).with_priority(/*priority*/ 1).mount(&server).await; + let before = git(&source, &["worktree", "list", "--porcelain"])?; + rejected_start( + &program, + &[ + "--worktree".into(), + "--no-alt-screen".into(), + "fork".into(), + next_owner.clone(), + ], + &launcher, + &env, + "cannot create a checkout from an explicitly untrusted source", + ) + .await?; + assert!( + server + .received_requests() + .await + .context("requests")? + .iter() + .skip(request_count) + .any(|request| request.url.path() == "/source/backend-api/wham/config/bundle") + ); + assert_eq!(git(&source, &["worktree", "list", "--porcelain"])?, before); + server.reset().await; + if cache.exists() { + fs::remove_file(&cache)?; + } + let count = server.received_requests().await.context("requests")?.len(); + Mock::given(wiremock::matchers::path("/source/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "config_toml": {"enterprise_managed": [{"id":"distrust-source", "name":"distrust-source", + "contents": format!("[projects.{}]\ntrust_level = \"untrusted\"\n", serde_json::to_string(&codex_config::loader::project_trust_key(&source.join(".codex")))?)}]} + }))).mount(&server).await; + let output = rejected_start( + &program, + &[ + "--worktree".into(), + "--no-alt-screen".into(), + "--cd".into(), + source.join(".codex").display().to_string(), + ], + &launcher, + &env, + "cannot create a checkout from an explicitly untrusted source", + ) + .await?; + let requests = server.received_requests().await.context("requests")?; + let paths = requests + .iter() + .skip(count) + .map(|r| r.url.path()) + .collect::>(); + assert!(paths.contains(&"/source/backend-api/wham/config/bundle")); + assert!(!paths.contains(&"/v1/responses")); + let after = git(&source, &["worktree", "list", "--porcelain"])?; + let retained = after + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .find(|path| { + !before + .lines() + .any(|line| line.strip_prefix("worktree ") == Some(*path)) + }) + .context("retained untrusted checkout")?; + assert!(output.contains("The checkout was kept"), "{output}"); + let metadata = git( + Path::new(retained), + &["rev-parse", "--git-path", "codex-thread.json"], + )?; + assert!(!Path::new(retained).join(metadata).exists()); + previous.push(retained.to_owned()); + fs::write(&home_config, launcher_config)?; + if cache.exists() { + fs::remove_file(&cache)?; + } + } + previous.push(checkout); + owner = Some(next_owner); + server.reset().await; + } + // Source loads its healthy uncommitted config, but the new checkout loads malformed HEAD. + fs::write(source.join(".codex/config.toml"), "not = [valid toml")?; + git( + &source, + &[ + "commit", + "--quiet", + "--no-gpg-sign", + "-am", + "invalid destination", + ], + )?; + fs::write(source.join(".codex/config.toml"), "")?; + let before = git(&source, &["worktree", "list", "--porcelain"])?; + let output = rejected_start( + &program, + &[ + "--worktree".into(), + "--no-alt-screen".into(), + "--cd".into(), + source.display().to_string(), + ], + &launcher, + &env, + "Do not use --force.", + ) + .await?; + let after = git(&source, &["worktree", "list", "--porcelain"])?; + let retained = after + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .find(|path| { + !before + .lines() + .any(|line| line.strip_prefix("worktree ") == Some(*path)) + }) + .context("retained failed checkout")?; + assert!( + output.contains(&format!( + "{:?}", + Path::new(&retained.replace('/', std::path::MAIN_SEPARATOR_STR)) + )), + "{output}" + ); + assert!(output.contains("The checkout was kept"), "{output}"); + assert!( + output.contains("worktree remove "), + "{output}" + ); + Ok(()) +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d399ac3249..f37349a135 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -9,6 +9,7 @@ mod event_processor; mod event_processor_with_human_output; pub(crate) mod event_processor_with_jsonl_output; pub(crate) mod exec_events; +mod worktree; pub use cli::Cli; pub use cli::Command; @@ -234,6 +235,7 @@ struct ExecRunArgs { struct ManagedExecWorktree { manager: WorktreeManager, checkout: PathBuf, + source_cwd: PathBuf, } fn exec_root_span() -> tracing::Span { @@ -259,7 +261,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } let Cli { - command, + mut command, strict_config, shared, thread_source, @@ -398,7 +400,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result }) .fallback_cwd(Some(config_cwd.to_path_buf())) .strict_config(strict_config) - .cloud_config_bundle(gate_cloud_config) + .cloud_config_bundle(gate_cloud_config.clone()) .build() .await?; if !gate_config.features.enabled(Feature::Worktrees) { @@ -406,9 +408,39 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result "--worktree requires the worktrees feature; enable it with --enable worktrees" ); } + if let Some(ExecCommand::Fork(args)) = command.as_mut() { + let saved_cwd = worktree::fork_source( + args, + &gate_config, + &arg0_paths, + &cli_kv_overrides, + &loader_overrides, + gate_cloud_config.clone(), + strict_config, + ) + .await?; + if resolved_cwd.is_none() { + config_cwd = AbsolutePathBuf::from_absolute_path(saved_cwd)?; + } + } + let source_config = ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .cli_overrides(cli_kv_overrides.clone()) + .loader_overrides(LoaderOverrides { + ignore_project_config: true, + ..loader_overrides.clone() + }) + .fallback_cwd(Some(config_cwd.to_path_buf())) + .strict_config(strict_config) + .cloud_config_bundle(gate_cloud_config) + .build() + .await?; + if source_config.active_project.is_untrusted() { + anyhow::bail!("--worktree requires a source that is not explicitly untrusted"); + } for path in &mut add_dir { if path.is_relative() { - *path = config_cwd.as_path().join(&*path); + *path = std::env::current_dir()?.join(&*path); } } // Allocation belongs to the host, not this session's project, profile, or overrides. @@ -433,6 +465,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result Some(ManagedExecWorktree { manager, checkout: checkout.root, + source_cwd: checkout.source_cwd, }) } else { None @@ -456,6 +489,27 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result /*enable_codex_api_key_env*/ false, ) .await?; + if let Some(worktree) = managed_worktree.as_ref() { + // Destination auth can fetch source policy that the host bootstrap could not. + let source_config = ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .cli_overrides(cli_kv_overrides.clone()) + .loader_overrides(LoaderOverrides { + ignore_project_config: true, + ..loader_overrides.clone() + }) + .fallback_cwd(Some(worktree.source_cwd.clone())) + .strict_config(strict_config) + .cloud_config_bundle(cloud_config_bundle.clone()) + .build() + .await?; + if source_config.active_project.is_untrusted() { + anyhow::bail!( + "--worktree requires a source that is not explicitly untrusted; unused checkout at {} remains. Remove it manually with `git worktree remove` when safe", + worktree.checkout.display() + ); + } + } let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = loader_overrides.clone(); let run_cloud_config_bundle = cloud_config_bundle.clone(); diff --git a/codex-rs/exec/src/worktree.rs b/codex-rs/exec/src/worktree.rs new file mode 100644 index 0000000000..d73e3ad639 --- /dev/null +++ b/codex-rs/exec/src/worktree.rs @@ -0,0 +1,82 @@ +//! Resolves managed fork sources through the existing name/ID lookup before final configuration. +//! The temporary app-server never starts a thread or executes a turn. + +use super::*; + +pub(super) async fn fork_source( + args: &mut crate::cli::ForkArgs, + config: &Config, + arg0_paths: &Arg0DispatchPaths, + cli_overrides: &[(String, codex_config::TomlValue)], + loader_overrides: &LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, + strict_config: bool, +) -> anyhow::Result { + let state_db = codex_core::init_state_db(config).await; + let environment_manager = EnvironmentManager::from_codex_home( + config.codex_home.clone(), + Some(ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?), + config.http_client_factory(), + ) + .await?; + let client = InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths: arg0_paths.clone(), + config: std::sync::Arc::new(config.clone()), + cli_overrides: cli_overrides.to_vec(), + loader_overrides: LoaderOverrides { + ignore_project_config: true, + ..loader_overrides.clone() + }, + strict_config, + cloud_config_bundle, + feedback: CodexFeedback::new(), + log_db: None, + state_db: state_db.clone(), + environment_manager: std::sync::Arc::new(environment_manager), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: true, + client_name: "codex_exec".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + let result = async { + let lookup = crate::cli::ResumeArgs { + session_id: Some(args.session_id.clone()), + last: false, + all: true, + images: Vec::new(), + prompt: None, + }; + let thread_id = resolve_resume_thread_id(&client, config, state_db.as_ref(), &lookup) + .await? + .ok_or_else(|| anyhow::anyhow!("Session not found: {}", args.session_id))?; + let source: ThreadReadResponse = send_request_with_response( + &client, + ClientRequest::ThreadRead { + request_id: RequestId::Integer(1), + params: ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }, + }, + "thread/read", + ) + .await + .map_err(anyhow::Error::msg)?; + args.session_id = thread_id; + Ok::<_, anyhow::Error>(latest_thread_cwd(&source.thread).await) + } + .await; + let shutdown = client.shutdown().await; + let source_cwd = result?; + shutdown?; + Ok(source_cwd) +} diff --git a/codex-rs/exec/tests/suite/worktree.rs b/codex-rs/exec/tests/suite/worktree.rs index 2a924a3764..be472af2c7 100644 --- a/codex-rs/exec/tests/suite/worktree.rs +++ b/codex-rs/exec/tests/suite/worktree.rs @@ -107,7 +107,7 @@ async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> a let seed_mock = responses::mount_sse_once(&server, body.clone()).await; let legacy_id = started_thread( test.cmd_with_server(&server) - .current_dir(&legacy.cwd) + .current_dir(legacy.cwd.join("extra")) .args(["--json", "legacy seed"]) .output()?, )?; @@ -117,6 +117,8 @@ async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> a fs::create_dir(source.join(".codex"))?; fs::write(source.join(".codex/config.toml"), "[invalid source config")?; + let launcher = tempfile::tempdir()?; + fs::create_dir(launcher.path().join("extra"))?; let mut known_roots = Vec::new(); for (fork, overrides) in [ (false, Vec::new()), @@ -133,7 +135,9 @@ async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> a format!("desktop={{git-worktree-root={other_pool}}}"), ], ), + (true, vec!["--cd".to_owned(), extra.display().to_string()]), ] { + let explicit_cd = overrides.first().is_some_and(|arg| arg == "--cd"); let server = responses::start_mock_server().await; let observed_owner = Arc::new(Mutex::>::default()); let captured_owner = Arc::clone(&observed_owner); @@ -163,7 +167,10 @@ async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> a .args(["--json", "--worktree", "--add-dir", "extra"]) .args(overrides); if fork { - command.arg("fork").arg(&legacy_id); + command + .current_dir(launcher.path()) + .arg("fork") + .arg(&legacy_id); } let thread_id = started_thread(command.arg("describe checkout").output()?)?; assert_eq!( @@ -182,13 +189,26 @@ async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> a .chain(request.message_input_texts("developer")) .collect::>() .join("\n"); - let checkout_cwd = AbsolutePathBuf::from_absolute_path(&checkout.cwd)?.canonicalize()?; + let checkout_cwd = AbsolutePathBuf::from_absolute_path(if explicit_cd { + checkout.root.join("extra") + } else { + checkout.cwd.clone() + })? + .canonicalize()?; assert!( context.contains(checkout_cwd.to_string_lossy().as_ref()), "{context}" ); assert!( - context.contains(extra.to_string_lossy().as_ref()), + context.contains( + if fork { + launcher.path().join("extra") + } else { + extra.clone() + } + .to_string_lossy() + .as_ref() + ), "{context}" ); assert!( @@ -206,13 +226,14 @@ async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> a let resume_mock = responses::mount_sse_once(&server, body).await; let resumed_id = started_thread( test.cmd_with_server(&server) - .current_dir(&legacy.cwd) + .current_dir(legacy.cwd.join("extra")) .args(["--json", "resume", &legacy_id, "continue legacy"]) .output()?, )?; assert_eq!(resumed_id, legacy_id); - assert_eq!(legacy_manager.owner(&legacy.root)?, Some(legacy_id)); - let legacy_cwd = AbsolutePathBuf::from_absolute_path(&legacy.cwd)?.canonicalize()?; + assert_eq!(legacy_manager.owner(&legacy.root)?, Some(legacy_id.clone())); + let legacy_cwd = + AbsolutePathBuf::from_absolute_path(legacy.cwd.join("extra"))?.canonicalize()?; assert!( resume_mock .single_request() @@ -220,6 +241,141 @@ async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> a .iter() .any(|text| text.contains(legacy_cwd.to_string_lossy().as_ref())) ); + fs::write( + home.join("config.toml"), + format!( + "features.worktrees=true\n[projects.{}]\ntrust_level=\"untrusted\"\n", + serde_json::to_string(&source)?, + ), + )?; + for args in [vec!["prompt"], vec!["fork", legacy_id.as_str(), "prompt"]] { + let output = test.cmd().arg("--worktree").args(args).output()?; + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("explicitly untrusted")); + } + assert!(!home.join("worktrees").exists()); + + // Synthetic enterprise claims let the cloud bundle, not a local project entry, + // supply the source's distrust decision. + fs::write( + home.join("auth.json"), + serde_json::to_vec(&serde_json::json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9wbGFuX3R5cGUiOiJlbnRlcnByaXNlIiwiY2hhdGdwdF9hY2NvdW50X2lkIjoid29ya3NwYWNlLTEyMyIsImNoYXRncHRfdXNlcl9pZCI6InVzZXItMTIzIn19.signature", + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "account_id": "workspace-123" + } + }))?, + )?; + fs::write( + home.join("config.toml"), + format!( + "features.worktrees=true\ncli_auth_credentials_store=\"file\"\nchatgpt_base_url=\"{}/backend-api\"\n", + server.uri(), + ), + )?; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/config/bundle")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "config_toml": { "enterprise_managed": [{ + "id": "source-trust", "name": "Source trust", + "contents": format!( + "[projects.{}]\ntrust_level=\"untrusted\"\n[projects.{}]\ntrust_level=\"untrusted\"\n", + serde_json::to_string(&source)?, + serde_json::to_string(&legacy.cwd.join("extra"))?, + ) + }] } + })), + ) + .mount(&server) + .await; + for args in [vec!["prompt"], vec!["fork", legacy_id.as_str(), "prompt"]] { + let request_count = if args.first() == Some(&"fork") { + // The saved checkout can choose file auth even when the launcher does not. + fs::remove_file(source.join(".codex/config.toml"))?; + fs::create_dir_all(legacy.cwd.join(".codex"))?; + fs::write( + legacy.cwd.join(".codex/config.toml"), + "cli_auth_credentials_store=\"file\"\n", + )?; + git(&legacy.cwd, &["add", ".codex/config.toml"])?; + git( + &legacy.cwd, + &["commit", "--quiet", "--no-gpg-sign", "-m", "file auth"], + )?; + fs::write( + home.join("config.toml"), + format!( + "features.worktrees=true\ncli_auth_credentials_store=\"ephemeral\"\nchatgpt_base_url=\"{}/backend-api\"\n[projects.{}]\ntrust_level=\"trusted\"\n", + server.uri(), + serde_json::to_string(&source)?, + ), + )?; + let cache = home.join("cloud-config-bundle-cache.json"); + if cache.exists() { + fs::remove_file(&cache)?; + } + Some(server.received_requests().await.context("requests")?.len()) + } else { + None + }; + let output = test + .cmd() + .current_dir(if request_count.is_some() { + launcher.path() + } else { + &source + }) + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .env_remove("CODEX_ACCESS_TOKEN") + .args(["--json", "--worktree", "--strict-config"]) + .args(args) + .output()?; + assert!( + !output.status.success() + && String::from_utf8_lossy(&output.stderr).contains("explicitly untrusted"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + if let Some(request_count) = request_count { + assert!(!String::from_utf8_lossy(&output.stdout).contains("thread.started")); + let denied_manager = + WorktreeManager::new(WorktreeSettings::for_cli(&home, /*desktop*/ None)?); + let checkout = denied_manager + .list(&legacy.cwd)? + .into_iter() + .next() + .context("denied checkout remains for manual removal")?; + assert_eq!(denied_manager.owner(&checkout.root)?, None); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains( + checkout + .root + .file_name() + .context("checkout name")? + .to_string_lossy() + .as_ref() + ) + ); + assert!(stderr.contains("Remove it manually with `git worktree remove`")); + assert!( + server + .received_requests() + .await + .context("requests")? + .iter() + .skip(request_count) + .any(|request| request.url.path() == "/backend-api/wham/config/bundle") + ); + } else { + assert!(!home.join("worktrees").exists()); + } + } Ok(()) } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 455f2af6c1..61a7d4853a 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -57,6 +57,7 @@ codex-sandboxing = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } +codex-worktree = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 1a459dd9bb..ae32424052 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -25,6 +25,7 @@ fn spawn_startup_thread_start( local_settings: crate::local_settings::LocalSettings, config: Config, app_event_tx: AppEventSender, + worktree: Option, ) { let request_handle = app_server.request_handle(); let thread_params_mode = app_server.thread_params_mode(); @@ -39,7 +40,13 @@ fn spawn_startup_thread_start( remote_cwd_override, thread_tool_transport, ) - .await; + .await + .and_then(|started| { + if let Some(worktree) = worktree.as_ref() { + worktree.bind(started.session.thread_id)?; + } + Ok(started) + }); app_event_tx.send(AppEvent::StartupThreadStarted { result }); }); } @@ -102,6 +109,7 @@ impl App { startup_bootstrap: Option, startup_hooks_browser: Option, mut startup_draft: StartupDraftPump, + managed_worktree: Option, ) -> Result { use tokio_stream::StreamExt; @@ -295,6 +303,7 @@ impl App { local_settings.clone(), config.clone(), app_event_tx.clone(), + managed_worktree.clone(), ); } // Count a startup tooltip once the initial chat widget can render it. @@ -457,6 +466,11 @@ impl App { else { return Ok(cancel_session_start(app_server).await); }; + if let Some(worktree) = managed_worktree.as_ref() + && let Err(err) = worktree.bind(forked.session.thread_id) + { + return shutdown_on_startup_error(app_server, err).await; + } let init = crate::chatwidget::ChatWidgetInit { local_settings: local_settings.clone(), config: config.clone(), diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index fad4c82bab..78408ed521 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -393,11 +393,17 @@ mod tests { additional_writable_roots: vec![PathBuf::from("rel")], ..Default::default() }; - let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?; + let mut normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?; + let destination = temp_dir.path().join("worktree").abs(); + normalized.cwd = Some(destination.to_path_buf()); + let normalized = normalize_harness_overrides_for_cwd(normalized, &destination)?; assert_eq!( - normalized.additional_writable_roots, - vec![base_cwd.join("rel").into_path_buf()] + (normalized.cwd, normalized.additional_writable_roots), + ( + Some(destination.to_path_buf()), + vec![base_cwd.join("rel").into_path_buf()] + ) ); Ok(()) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 121f13aba1..9a11323f3e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -96,6 +96,8 @@ use uuid::Uuid; pub(crate) use codex_app_server_client::legacy_core; +pub(crate) use worktree_startup::ManagedTuiWorktree; + mod additional_dirs; mod app; mod app_backtrack; @@ -214,6 +216,7 @@ mod tui; mod ui_consts; mod unarchive_prompt; pub(crate) mod update_action; +mod worktree_startup; pub use update_action::UpdateAction; #[cfg(not(debug_assertions))] pub use update_action::get_update_action; @@ -1009,6 +1012,7 @@ async fn run_ratatui_app( log_db: Option, mut state_db: Option, environment_manager: Arc, + managed_worktree: Option, startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { let uses_remote_workspace = app_server_target.uses_remote_workspace(); @@ -1257,12 +1261,14 @@ async fn run_ratatui_app( if !uses_remote_workspace && (onboarding_result.directory_trust_persisted || show_login_screen) { - load_config_or_exit( + load_config_or_exit_with_fallback_cwd( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), cloud_config_bundle.clone(), strict_config, + /*fallback_cwd*/ None, + managed_worktree.as_ref(), ) .await } else { @@ -1581,6 +1587,7 @@ async fn run_ratatui_app( cloud_config_bundle.clone(), strict_config, fallback_cwd, + managed_worktree.as_ref(), ), ) .await @@ -1784,6 +1791,7 @@ async fn run_ratatui_app( startup_bootstrap, startup_hooks_browser, startup_draft, + managed_worktree, ) .await; @@ -1886,10 +1894,12 @@ async fn load_config_or_exit( cloud_config_bundle, strict_config, /*fallback_cwd*/ None, + /*worktree*/ None, ) .await } +#[allow(clippy::too_many_arguments)] async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, @@ -1897,27 +1907,66 @@ async fn load_config_or_exit_with_fallback_cwd( cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, fallback_cwd: Option, + worktree: Option<&ManagedTuiWorktree>, ) -> Config { #[allow(clippy::print_stderr)] - match ConfigBuilder::default() - .cli_overrides(cli_kv_overrides) - .harness_overrides(overrides) - .loader_overrides(loader_overrides) - .strict_config(strict_config) - .cloud_config_bundle(cloud_config_bundle) - .fallback_cwd(fallback_cwd) - .build() - .await + match load_config_with_worktree_source_policy( + cli_kv_overrides, + overrides, + loader_overrides, + cloud_config_bundle, + strict_config, + fallback_cwd, + worktree, + ) + .await { Ok(config) => config, Err(err) => { restore_terminal_before_fatal_exit(); eprintln!("Error loading configuration: {err}"); + if let Some(worktree) = worktree { + worktree.report_startup_failure(); + } std::process::exit(1); } } } +#[allow(clippy::too_many_arguments)] +async fn load_config_with_worktree_source_policy( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, + strict_config: bool, + fallback_cwd: Option, + worktree: Option<&ManagedTuiWorktree>, +) -> std::io::Result { + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides.clone()) + .harness_overrides(overrides.clone()) + .loader_overrides(loader_overrides.clone()) + .strict_config(strict_config) + .cloud_config_bundle(cloud_config_bundle.clone()) + .fallback_cwd(fallback_cwd) + .build() + .await + .map_err(std::io::Error::other)?; + if let Some(worktree) = worktree { + worktree + .check_source_policy( + &cli_kv_overrides, + &overrides, + &loader_overrides, + &cloud_config_bundle, + strict_config, + ) + .await?; + } + Ok(config) +} + #[allow(clippy::print_stderr)] async fn load_bootstrap_config_or_exit( codex_home: &Path, @@ -2500,6 +2549,23 @@ requires_openai_auth = {requires_openai_auth} error.to_string(), "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" ); + let explicit = resolve_startup_resume_or_fork_cwd( + &mut tui, + &config, + /*state_db*/ None, + &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { + path: None, + thread_id: ThreadId::new(), + history_mode: None, + }), + Some(Path::new("/remote-only/project")), + /*uses_remote_workspace*/ true, + /*uses_remote_workspace_or_environment*/ true, + ) + .await?; + assert!( + matches!(explicit, ResolveCwdOutcome::Continue(Some(cwd)) if cwd == config.cwd.as_path()) + ); Ok(()) } diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 253c699140..783a12f5e4 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -12,6 +12,18 @@ pub(super) async fn run_main_inner( explicit_remote_endpoint: Option, ) -> std::io::Result { let strict_config = cli.strict_config; + if cli.shared.worktree { + if explicit_remote_endpoint.is_some() { + return Err(std::io::Error::other( + "`--worktree` is only supported for local sessions", + )); + } + if cli.fork_picker || cli.fork_last { + return Err(std::io::Error::other( + "`codex fork --worktree` requires an explicit session ID", + )); + } + } let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox { ( Some(SandboxMode::DangerFullAccess), @@ -136,7 +148,8 @@ pub(super) async fn run_main_inner( .await; } - let reuse_implicit_local_daemon = !workload_identity_selected + let reuse_implicit_local_daemon = !cli.shared.worktree + && !workload_identity_selected && (cli.agents_overview || can_reuse_implicit_local_daemon( &cli_kv_overrides, @@ -210,6 +223,14 @@ pub(super) async fn run_main_inner( .await? } .map_err(std::io::Error::other)?; + if cli.shared.worktree + && (app_server_target.uses_remote_workspace() + || prepared_environment_manager.default_environment_is_remote()) + { + return Err(std::io::Error::other( + "`--worktree` is only supported for local sessions", + )); + } let cwd = cli.cwd.clone(); let config_cwd = config_cwd_for_app_server_target( cwd.as_deref(), @@ -234,7 +255,6 @@ pub(super) async fn run_main_inner( CloudConfigBundleLoader::default(), )) .await?; - let bootstrap_config_toml = &bootstrap_config.config_toml; let cloud_config_bundle = startup_draft .run_until(cloud_config_bundle_for_app_server_target( &app_server_target, @@ -242,6 +262,7 @@ pub(super) async fn run_main_inner( &codex_home, )) .await??; + let bootstrap_config_toml = &bootstrap_config.config_toml; let cwd_override = if app_server_target.uses_remote_workspace() { None @@ -324,7 +345,7 @@ pub(super) async fn run_main_inner( let additional_dirs = cli.add_dir.clone(); - let overrides = ConfigOverrides { + let mut overrides = ConfigOverrides { model, approval_policy, sandbox_mode, @@ -339,7 +360,7 @@ pub(super) async fn run_main_inner( ..Default::default() }; - let config = startup_draft + let mut config = startup_draft .run_until(load_config_or_exit( cli_kv_overrides.clone(), overrides.clone(), @@ -350,7 +371,7 @@ pub(super) async fn run_main_inner( .await?; startup_draft.apply_config(&config); - let cloud_config_bundle = if workload_identity_selected { + let mut cloud_config_bundle = if workload_identity_selected { cloud_config_bundle } else { startup_draft @@ -360,6 +381,28 @@ pub(super) async fn run_main_inner( )) .await?? }; + let managed_worktree = if cli.shared.worktree { + let (destination, bundle, worktree) = startup_draft + .run_until(worktree_startup::prepare( + &mut cli, + config.clone(), + &mut overrides, + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + &app_server_target, + &arg0_paths, + cloud_config_bundle.clone(), + )) + .await? + .map_err(|err| std::io::Error::other(err.to_string()))?; + config = destination; + cloud_config_bundle = bundle; + startup_draft.apply_config(&config); + Some(worktree) + } else { + None + }; #[cfg(target_os = "macos")] let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home( codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home), @@ -449,6 +492,9 @@ pub(super) async fn run_main_inner( { restore_terminal_before_fatal_exit(); eprintln!("Error adding directories: {warning}"); + if let Some(worktree) = managed_worktree.as_ref() { + worktree.report_startup_failure(); + } std::process::exit(1); } } @@ -461,6 +507,9 @@ pub(super) async fn run_main_inner( { restore_terminal_before_fatal_exit(); eprintln!("{err}"); + if let Some(worktree) = managed_worktree.as_ref() { + worktree.report_startup_failure(); + } std::process::exit(1); } } @@ -561,6 +610,7 @@ pub(super) async fn run_main_inner( log_db, state_db, environment_manager, + managed_worktree.clone(), startup_draft, ) .await @@ -569,6 +619,10 @@ pub(super) async fn run_main_inner( .unwrap_or_else(|err| std::io::Error::other(err.to_string())) }); + if let Some(worktree) = managed_worktree.as_ref() { + worktree.report_startup_failure(); + } + if let Some(otel) = otel && let Err(err) = otel .shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT) diff --git a/codex-rs/tui/src/worktree_startup.rs b/codex-rs/tui/src/worktree_startup.rs new file mode 100644 index 0000000000..a576ec52b0 --- /dev/null +++ b/codex-rs/tui/src/worktree_startup.rs @@ -0,0 +1,292 @@ +//! Resolves and allocates the destination before telemetry and login policy are initialized. +//! Source distrust is never upgraded; ownership is bound before exposing the new thread. +//! Failed startup retains the checkout and reports manual recovery until ownership is bound. +use super::*; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +#[derive(Clone)] +pub(crate) struct ManagedTuiWorktree { + manager: codex_worktree::WorktreeManager, + checkout: codex_worktree::ManagedWorktree, + recovery: Arc, +} + +impl ManagedTuiWorktree { + pub(crate) fn bind(&self, thread_id: ThreadId) -> color_eyre::Result<()> { + self.manager + .bind_thread(&self.checkout.root, &thread_id.to_string()) + .map_err(std::io::Error::other) + .wrap_err("failed to bind managed worktree thread")?; + self.recovery.finished.store(true, Ordering::Relaxed); + Ok(()) + } + + pub(crate) async fn check_source_policy( + &self, + cli_overrides: &[(String, toml::Value)], + overrides: &ConfigOverrides, + loader_overrides: &LoaderOverrides, + bundle: &CloudConfigBundleLoader, + strict_config: bool, + ) -> std::io::Result<()> { + let mut source_overrides = overrides.clone(); + source_overrides.cwd = Some(self.checkout.source_cwd.clone()); + let source = ConfigBuilder::default() + .cli_overrides(cli_overrides.to_vec()) + .harness_overrides(source_overrides) + .loader_overrides(LoaderOverrides { + ignore_project_config: true, + ..loader_overrides.clone() + }) + .cloud_config_bundle(bundle.clone()) + .strict_config(strict_config) + .build() + .await + .map_err(std::io::Error::other)?; + if source.active_project.is_untrusted() { + return Err(std::io::Error::other( + "`--worktree` cannot create a checkout from an explicitly untrusted source", + )); + } + Ok(()) + } +} + +/// Shared by startup and its background thread request; reports at most once. +struct StartupRecovery { + root: PathBuf, + finished: AtomicBool, +} + +impl StartupRecovery { + fn message(&self) -> String { + format!( + "Startup did not finish binding a thread to this worktree: {:?}\n\ + The checkout was kept. Inspect it and confirm no session is using it.\n\ + To remove it, run `git worktree remove ` from the source repository,\n\ + replacing with the path above. Do not use --force.", + self.root + ) + } + + fn report(&self) { + if !self.finished.swap(true, Ordering::Relaxed) { + restore_terminal_before_fatal_exit(); + #[allow(clippy::print_stderr)] + { + eprintln!("{}", self.message()); + } + } + } +} + +impl Drop for StartupRecovery { + fn drop(&mut self) { + self.report(); + } +} + +impl ManagedTuiWorktree { + pub(crate) fn report_startup_failure(&self) { + self.recovery.report(); + } +} + +async fn latest_thread_cwd(path: Option, fallback: PathBuf) -> PathBuf { + let Some(path) = path else { return fallback }; + tokio::task::spawn_blocking(move || { + let reader = codex_rollout::open_rollout_seekable_reader(&path).ok()?; + let mut scanner = codex_rollout::ReverseJsonlScanner::new(reader).ok()?; + while let Some(outcome) = scanner.scan_next_rollout_line().ok()? { + if let codex_rollout::ScanOutcome::Parsed(codex_rollout::RolloutLine { + item: codex_rollout::RolloutItem::TurnContext(item), + .. + }) = outcome + { + return Some(item.cwd.into_path_buf()); + } + } + None + }) + .await + .ok() + .flatten() + .unwrap_or(fallback) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare( + cli: &mut Cli, + mut source: Config, + overrides: &mut ConfigOverrides, + cli_overrides: Vec<(String, toml::Value)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + target: &AppServerTarget, + arg0_paths: &Arg0DispatchPaths, + source_bundle: CloudConfigBundleLoader, +) -> color_eyre::Result<(Config, CloudConfigBundleLoader, ManagedTuiWorktree)> { + if let Some(id_or_name) = cli.fork_session_id.as_deref() { + let prepared = if should_load_configured_environments(&loader_overrides, target) { + EnvironmentManager::prepare_from_codex_home(&source.codex_home).await + } else { + EnvironmentManager::prepare_from_env().await + } + .map_err(std::io::Error::other)?; + if prepared.default_environment_is_remote() { + color_eyre::eyre::bail!("`--worktree` is only supported for local sessions"); + } + let environment = prepared.build( + Some(ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?), + source.http_client_factory(), + )?; + let state = + init_state_db_for_app_server_target(&source, &AppServerTarget::Embedded).await?; + let mut lookup_config = source.clone(); + lookup_config.analytics_enabled = Some(false); + let client = start_embedded_app_server( + arg0_paths.clone(), + lookup_config, + cli_overrides.clone(), + loader_overrides.clone(), + strict_config, + source_bundle.clone(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + state, + Arc::new(environment), + ) + .await?; + let mut lookup = AppServerSession::new( + AppServerClient::InProcess(client), + crate::app_server_session::ThreadParamsMode::Embedded, + ); + let resolved = async { + let target = lookup_session_target_with_app_server(&mut lookup, &source, id_or_name) + .await? + .ok_or_else(|| color_eyre::eyre::eyre!("Session not found: {id_or_name}"))?; + lookup + .thread_read(target.thread_id, /*include_turns*/ false) + .await + } + .await; + let shutdown = lookup.shutdown().await; + let thread = resolved?; + shutdown?; + cli.fork_session_id = Some(thread.id); + if cli.cwd.is_none() { + let cwd = latest_thread_cwd(thread.path, thread.cwd.into_path_buf()).await; + let cwd = AbsolutePathBuf::from_absolute_path(cwd)?; + let bootstrap = load_bootstrap_config_or_exit( + &source.codex_home, + Some(&cwd), + cli_overrides.clone(), + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), + ) + .await; + let source_bundle = + cloud_config_bundle_for_app_server_target(target, &bootstrap, &source.codex_home) + .await?; + overrides.cwd = Some(cwd.into_path_buf()); + source = load_config_or_exit( + cli_overrides.clone(), + overrides.clone(), + loader_overrides.clone(), + source_bundle, + strict_config, + ) + .await; + } + } + if !source.features.enabled(codex_features::Feature::Worktrees) { + color_eyre::eyre::bail!( + "`--worktree` requires the worktrees feature; enable it with `--enable worktrees`" + ); + } + if source.active_project.is_untrusted() { + color_eyre::eyre::bail!( + "`--worktree` cannot create a checkout from an explicitly untrusted source" + ); + } + let invocation_cwd = std::env::current_dir()?; + for path in &mut overrides.additional_writable_roots { + if path.is_relative() { + *path = invocation_cwd.join(&*path); + } + } + let host = load_bootstrap_config_or_exit( + &source.codex_home, + /*cwd*/ None, + Vec::new(), + LoaderOverrides::default(), + strict_config, + CloudConfigBundleLoader::default(), + ) + .await; + let manager = codex_worktree::WorktreeManager::new( + codex_worktree::WorktreeSettings::for_cli( + &source.codex_home, + host.config_toml.desktop.as_ref(), + ) + .map_err(std::io::Error::other)?, + ); + let checkout = manager + .create(&codex_worktree::CreateWorktree { + source_cwd: source.cwd.to_path_buf(), + base: None, + }) + .map_err(std::io::Error::other)?; + let recovery = Arc::new(StartupRecovery { + root: checkout.root.clone(), + finished: AtomicBool::new(false), + }); + let managed = ManagedTuiWorktree { + manager, + checkout, + recovery, + }; + let destination = AbsolutePathBuf::from_absolute_path(managed.checkout.cwd.clone())?; + let bootstrap = load_config_toml_with_layer_stack( + &source.codex_home, + Some(&destination), + cli_overrides.clone(), + codex_config::ConfigLoadOptions { + loader_overrides: loader_overrides.clone(), + strict_config, + cloud_config_bundle: CloudConfigBundleLoader::default(), + }, + ) + .await?; + let bundle = + cloud_config_bundle_for_app_server_target(target, &bootstrap, &source.codex_home).await?; + managed + .check_source_policy( + &cli_overrides, + overrides, + &loader_overrides, + &bundle, + strict_config, + ) + .await?; + overrides.cwd = Some(managed.checkout.cwd.clone()); + let config = ConfigBuilder::default() + .cli_overrides(cli_overrides) + .harness_overrides(overrides.clone()) + .loader_overrides(loader_overrides) + .cloud_config_bundle(bundle.clone()) + .strict_config(strict_config) + .build() + .await?; + cli.cwd = Some(managed.checkout.cwd.clone()); + Ok((config, bundle, managed)) +} + +#[cfg(test)] +#[path = "worktree_startup_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/worktree_startup_tests.rs b/codex-rs/tui/src/worktree_startup_tests.rs new file mode 100644 index 0000000000..f0d6beed01 --- /dev/null +++ b/codex-rs/tui/src/worktree_startup_tests.rs @@ -0,0 +1,134 @@ +//! Checks saved-directory fallback and the user-facing retained-checkout notice. +use super::*; +use clap::Parser; +use pretty_assertions::assert_eq; + +#[test] +fn recovery_message_names_checkout_and_safe_manual_action() { + let recovery = StartupRecovery { + root: PathBuf::from("checkout with spaces"), + // This test inspects rendering without printing or changing terminal state on drop. + finished: AtomicBool::new(true), + }; + insta::assert_snapshot!(recovery.message(), @r#" + Startup did not finish binding a thread to this worktree: "checkout with spaces" + The checkout was kept. Inspect it and confirm no session is using it. + To remove it, run `git worktree remove ` from the source repository, + replacing with the path above. Do not use --force. + "#); +} + +#[tokio::test] +async fn explicit_remote_worktree_rejection_is_snapshotted() -> anyhow::Result<()> { + let cli = Cli::parse_from(["codex", "--worktree"]); + let endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("remote.sock")?, + }; + let error = crate::startup_orchestration::run_main_inner( + cli, + Arg0DispatchPaths::default(), + LoaderOverrides::default(), + Some(endpoint), + ) + .await + .expect_err("managed worktrees require a local session"); + insta::assert_snapshot!(error.to_string(), @"`--worktree` is only supported for local sessions"); + Ok(()) +} + +#[tokio::test] +async fn latest_directory_uses_turn_context_and_preserves_fallback() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let fallback = dir.path().join("old"); + let latest = dir.path().join("latest"); + let path = dir.path().join("rollout.jsonl"); + let turn = serde_json::json!({ + "timestamp": "2026-09-04T00:00:00Z", "type": "turn_context", "payload": { + "cwd": latest, "approval_policy": "never", "sandbox_policy": {"type":"read-only"}, + "model": "test-model", "summary": "auto" + } + }); + std::fs::write(&path, format!("{turn}\n"))?; + assert_eq!( + latest_thread_cwd(Some(path.clone()), fallback.clone()).await, + latest + ); + std::fs::write(&path, "")?; + assert_eq!( + latest_thread_cwd(Some(path), fallback.clone()).await, + fallback + ); + assert_eq!( + latest_thread_cwd(/*path*/ None, fallback.clone()).await, + fallback + ); + Ok(()) +} + +#[tokio::test] +async fn refreshed_bundle_rechecks_source_during_config_reload() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let source = dir.path().join("source"); + let nested = source.join(".codex"); + let destination = dir.path().join("checkout"); + for path in [&nested, &destination] { + std::fs::create_dir_all(path)?; + } + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + loader_overrides.ignore_user_config = true; + let manager = codex_worktree::WorktreeManager::new(codex_worktree::WorktreeSettings::for_cli( + dir.path(), + /*desktop*/ None, + )?); + let checkout = codex_worktree::ManagedWorktree { + root: destination.clone(), + cwd: destination.clone(), + source_root: source, + source_cwd: nested.clone(), + head_sha: String::new(), + branch: None, + }; + let recovery = Arc::new(StartupRecovery { + root: destination.clone(), + finished: AtomicBool::new(true), + }); + let worktree = ManagedTuiWorktree { + manager, + checkout, + recovery, + }; + let overrides = ConfigOverrides { + cwd: Some(destination), + ..Default::default() + }; + crate::load_config_with_worktree_source_policy( + Vec::new(), + overrides.clone(), + loader_overrides.clone(), + CloudConfigBundleLoader::default(), + /*strict_config*/ false, + /*fallback_cwd*/ None, + Some(&worktree), + ) + .await?; + let refreshed = + codex_config::test_support::CloudConfigBundleFixture::loader_with_enterprise_config( + format!( + "[projects.{}]\ntrust_level = \"untrusted\"\n", + serde_json::to_string(&codex_config::loader::project_trust_key(&nested))? + ), + ); + let err = crate::load_config_with_worktree_source_policy( + Vec::new(), + overrides, + loader_overrides, + refreshed, + /*strict_config*/ false, + /*fallback_cwd*/ None, + Some(&worktree), + ) + .await + .expect_err("refreshed source distrust must reject"); + assert!(err.to_string().contains("explicitly untrusted source")); + Ok(()) +}