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 <session-id>` 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
This commit is contained in:
Benjamin Carlsson
2026-09-05 18:06:26 +00:00
committed by copyberry
parent 52e73e3a54
commit 3525845978
15 changed files with 1626 additions and 41 deletions
+5
View File
@@ -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 }
+1
View File
@@ -4621,6 +4621,7 @@ dependencies = [
"codex-utils-sleep-inhibitor",
"codex-utils-string",
"codex-windows-sandbox",
"codex-worktree",
"color-eyre",
"core_test_support",
"crossterm",
+28 -12
View File
@@ -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]
@@ -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`
+691
View File
@@ -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<String> {
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<u8>);
impl TerminalQueries {
fn feed(&mut self, bytes: &[u8]) -> Vec<u8> {
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::<u8>::new());
}
}
async fn rejected_start(
program: &Path,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
expected: &str,
) -> anyhow::Result<String> {
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::<String>(),
output.chars().take(/*n*/ 4000).collect::<String>()
);
}
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<String, String> = 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<String> = 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::<String>()
);
}
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::<Vec<_>>();
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::<String>()
)
})?
.with_context(|| {
format!(
"startup output: {}",
output.chars().take(/*n*/ 4000).collect::<String>()
)
})?;
renamed.with_context(|| {
format!(
"rename output: {}",
output.chars().take(/*n*/ 4000).collect::<String>()
)
})?;
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::<Vec<_>>()
.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::<Vec<_>>();
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 <checkout-path>"),
"{output}"
);
Ok(())
}
+57 -3
View File
@@ -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();
+82
View File
@@ -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<std::path::PathBuf> {
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)
}
+163 -7
View File
@@ -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::<Option<String>>::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::<Vec<_>>()
.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(())
}
+1
View File
@@ -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 }
+15 -1
View File
@@ -25,6 +25,7 @@ fn spawn_startup_thread_start(
local_settings: crate::local_settings::LocalSettings,
config: Config,
app_event_tx: AppEventSender,
worktree: Option<crate::ManagedTuiWorktree>,
) {
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<AppServerBootstrap>,
startup_hooks_browser: Option<HooksListEntry>,
mut startup_draft: StartupDraftPump,
managed_worktree: Option<crate::ManagedTuiWorktree>,
) -> Result<AppExitInfo> {
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(),
+9 -3
View File
@@ -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(())
}
+76 -10
View File
@@ -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<log_db::LogDbLayer>,
mut state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
managed_worktree: Option<ManagedTuiWorktree>,
startup_draft: startup_draft::StartupDraft,
) -> color_eyre::Result<AppExitInfo> {
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<PathBuf>,
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<PathBuf>,
worktree: Option<&ManagedTuiWorktree>,
) -> std::io::Result<Config> {
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(())
}
+59 -5
View File
@@ -12,6 +12,18 @@ pub(super) async fn run_main_inner(
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
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)
+292
View File
@@ -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<StartupRecovery>,
}
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 <checkout-path>` from the source repository,\n\
replacing <checkout-path> 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<PathBuf>, 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;
+134
View File
@@ -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 <checkout-path>` from the source repository,
replacing <checkout-path> 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(())
}