mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
committed by
copyberry
parent
52e73e3a54
commit
3525845978
@@ -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 }
|
||||
|
||||
Generated
+1
@@ -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
@@ -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`
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user