diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d3af0e7796..f2d2768dc3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -693,6 +693,7 @@ dependencies = [ "clap", "codex-common", "codex-core", + "codex-git-apply", "codex-protocol", "reqwest", "serde", @@ -761,13 +762,11 @@ dependencies = [ "async-trait", "chrono", "codex-backend-client", + "codex-git-apply", "diffy", - "once_cell", - "regex", "reqwest", "serde", "serde_json", - "tempfile", "thiserror 2.0.16", "tokio", ] @@ -901,6 +900,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-git-apply" +version = "0.0.0" +dependencies = [ + "once_cell", + "regex", + "tempfile", +] + [[package]] name = "codex-linux-sandbox" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3e3d60b3c1..e42afe4924 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -22,6 +22,7 @@ members = [ "protocol", "protocol-ts", "tui", + "git-apply", ] resolver = "2" diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 60d42a3d9d..13c0a22626 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -7,11 +7,22 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; +use serde::de::DeserializeOwned; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PathStyle { - CodexApi, // /api/codex/... - Wham, // /wham/... + CodexApi, // /api/codex/... + ChatGptApi, // /wham/... +} + +impl PathStyle { + pub fn from_base_url(base_url: &str) -> Self { + if base_url.contains("/backend-api") { + PathStyle::ChatGptApi + } else { + PathStyle::CodexApi + } + } } #[derive(Clone, Debug)] @@ -39,11 +50,7 @@ impl Client { base_url = format!("{base_url}/backend-api"); } let http = reqwest::Client::builder().build()?; - let path_style = if base_url.contains("/backend-api") { - PathStyle::Wham - } else { - PathStyle::CodexApi - }; + let path_style = PathStyle::from_base_url(&base_url); Ok(Self { base_url, http, @@ -95,20 +102,39 @@ impl Client { { h.insert(name, hv); } - // Optional internal toggle: send WHAM-FORCE-INTERNAL header when requested. - // if matches!( - // std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL") - // .ok() - // .as_deref(), - // Some("1") | Some("true") | Some("TRUE") - // ) { - // if let Ok(name) = HeaderName::from_lowercase(b"wham-force-internal") { - // h.insert(name, HeaderValue::from_static("true")); - // } - // } h } + async fn exec_request( + &self, + req: reqwest::RequestBuilder, + method: &str, + url: &str, + ) -> Result<(String, String)> { + let res = req.send().await?; + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}"); + } + Ok((body, ct)) + } + + fn decode_json(&self, url: &str, ct: &str, body: &str) -> Result { + match serde_json::from_str::(body) { + Ok(v) => Ok(v), + Err(e) => { + anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); + } + } + } + pub async fn list_tasks( &self, limit: Option, @@ -117,7 +143,7 @@ impl Client { ) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), - PathStyle::Wham => format!("{}/wham/tasks/list", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url), }; let req = self.http.get(&url).headers(self.headers()); let req = if let Some(lim) = limit { @@ -135,33 +161,8 @@ impl Client { } else { req }; - let res = req.send().await?; - let status = res.status(); - if !status.is_success() { - let ct = res - .headers() - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let body = res.text().await.unwrap_or_default(); - anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}"); - } - // Decode with better diagnostics on failure - let ct = res - .headers() - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let body = res.text().await.unwrap_or_default(); - match serde_json::from_str::(&body) { - Ok(v) => Ok(v), - Err(e) => { - // Include the full response body to aid debugging rather than truncating. - anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); - } - } + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) } pub async fn get_task_details(&self, task_id: &str) -> Result { @@ -175,33 +176,12 @@ impl Client { ) -> Result<(CodeTaskDetailsResponse, String, String)> { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id), - PathStyle::Wham => format!("{}/wham/tasks/{}", self.base_url, task_id), + PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id), }; - let res = self.http.get(&url).headers(self.headers()).send().await?; - let status = res.status(); - if !status.is_success() { - let ct = res - .headers() - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let body = res.text().await.unwrap_or_default(); - anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}"); - } - let ct = res - .headers() - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let body = res.text().await.unwrap_or_default(); - match serde_json::from_str::(&body) { - Ok(v) => Ok((v, body, ct)), - Err(e) => { - anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); - } - } + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?; + Ok((parsed, body, ct)) } /// Create a new task (user turn) by POSTing to the appropriate backend path @@ -209,27 +189,15 @@ impl Client { pub async fn create_task(&self, request_body: serde_json::Value) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url), - PathStyle::Wham => format!("{}/wham/tasks", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url), }; - let res = self + let req = self .http .post(&url) .headers(self.headers()) .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .json(&request_body) - .send() - .await?; - let status = res.status(); - let ct = res - .headers() - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let body = res.text().await.unwrap_or_default(); - if !status.is_success() { - anyhow::bail!("POST {url} failed: {status}; content-type={ct}; body={body}"); - } + .json(&request_body); + let (body, ct) = self.exec_request(req, "POST", &url).await?; // Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present. match serde_json::from_str::(&body) { Ok(v) => { @@ -247,9 +215,7 @@ impl Client { ); } } - Err(e) => { - anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); - } + Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"), } } } diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 3f2c9445a0..6d6de9d657 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -14,8 +14,6 @@ pub trait CodeTaskDetailsResponseExt { fn user_text_prompt(&self) -> Option; /// Extract an assistant error message (if the turn failed and provided one). fn assistant_error_message(&self) -> Option; - /// Best-effort: extract a single file old/new path for header synthesis when only hunk bodies are provided. - fn single_file_paths(&self) -> Option<(String, String)>; } impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse { fn unified_diff(&self) -> Option { @@ -108,7 +106,11 @@ impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse { } } } - if parts.is_empty() { None } else { Some(parts.join("\n\n")) } + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } } fn assistant_error_message(&self) -> Option { @@ -126,137 +128,7 @@ impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse { Some(format!("{code}: {message}")) } } - fn single_file_paths(&self) -> Option<(String, String)> { - fn try_from_items(items: &Vec) -> Option<(String, String)> { - use serde_json::Value; - for item in items { - if let Some(obj) = item.as_object() { - if let Some(p) = obj.get("path").and_then(Value::as_str) { - let p = p.to_string(); - return Some((p.clone(), p)); - } - let old = obj.get("old_path").and_then(Value::as_str); - let newp = obj.get("new_path").and_then(Value::as_str); - if let (Some(o), Some(n)) = (old, newp) { - return Some((o.to_string(), n.to_string())); - } - if let Some(od) = obj.get("output_diff").and_then(Value::as_object) { - if let Some(fm) = od.get("files_modified") { - if let Some(map) = fm.as_object() - && map.len() == 1 - && let Some((k, _)) = map.iter().next() - { - let p = k.to_string(); - return Some((p.clone(), p)); - } else if let Some(arr) = fm.as_array() - && arr.len() == 1 - { - let el = &arr[0]; - if let Some(p) = el.as_str() { - let p = p.to_string(); - return Some((p.clone(), p)); - } - if let Some(o) = el.as_object() { - let path = o.get("path").and_then(Value::as_str); - let oldp = o.get("old_path").and_then(Value::as_str); - let newp = o.get("new_path").and_then(Value::as_str); - if let Some(p) = path { - let p = p.to_string(); - return Some((p.clone(), p)); - } - if let (Some(o1), Some(n1)) = (oldp, newp) { - return Some((o1.to_string(), n1.to_string())); - } - } - } - } - if let Some(p) = od.get("path").and_then(Value::as_str) { - let p = p.to_string(); - return Some((p.clone(), p)); - } - } - } - } - None - } - let candidates: [&Option>; 2] = - [&self.current_diff_task_turn, &self.current_assistant_turn]; - for map in candidates { - if let Some(m) = map.as_ref() - && let Some(items) = m.get("output_items").and_then(serde_json::Value::as_array) - && let Some(p) = try_from_items(items) - { - return Some(p); - } - } - None - } } -/// Best-effort extraction of a list of (old_path, new_path) pairs for files involved -/// in the current task's diff output. For entries where only a single `path` is present, -/// the pair will be (path, path). -pub fn extract_file_paths_list(details: &CodeTaskDetailsResponse) -> Vec<(String, String)> { - use serde_json::Value; - fn push_from_items(out: &mut Vec<(String, String)>, items: &Vec) { - for item in items { - if let Some(obj) = item.as_object() { - if let Some(p) = obj.get("path").and_then(Value::as_str) { - let p = p.to_string(); - out.push((p.clone(), p)); - continue; - } - let old = obj.get("old_path").and_then(Value::as_str); - let newp = obj.get("new_path").and_then(Value::as_str); - if let (Some(o), Some(n)) = (old, newp) { - out.push((o.to_string(), n.to_string())); - continue; - } - if let Some(od) = obj.get("output_diff").and_then(Value::as_object) { - if let Some(fm) = od.get("files_modified") { - if let Some(map) = fm.as_object() { - for (k, _v) in map { - let p = k.to_string(); - out.push((p.clone(), p)); - } - } else if let Some(arr) = fm.as_array() { - for el in arr { - if let Some(p) = el.as_str() { - let p = p.to_string(); - out.push((p.clone(), p)); - } else if let Some(o) = el.as_object() { - let path = o.get("path").and_then(Value::as_str); - let oldp = o.get("old_path").and_then(Value::as_str); - let newp = o.get("new_path").and_then(Value::as_str); - if let Some(p) = path { - let p = p.to_string(); - out.push((p.clone(), p)); - } else if let (Some(o1), Some(n1)) = (oldp, newp) { - out.push((o1.to_string(), n1.to_string())); - } - } - } - } - } - if let Some(p) = od.get("path").and_then(Value::as_str) { - let p = p.to_string(); - out.push((p.clone(), p)); - } - } - } - } - } - let mut out: Vec<(String, String)> = Vec::new(); - let candidates: [&Option>; 2] = [ - &details.current_diff_task_turn, - &details.current_assistant_turn, - ]; - for map in candidates { - if let Some(m) = map.as_ref() - && let Some(items) = m.get("output_items").and_then(Value::as_array) - { - push_from_items(&mut out, items); - } - } - out -} +// Removed unused helpers `single_file_paths` and `extract_file_paths_list` to reduce +// surface area; reintroduce as needed near call sites. diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 78ec5599e7..4560d1b856 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -16,6 +16,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } +codex-git-apply = { path = "../git-apply" } [dev-dependencies] tempfile = "3" diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 3dbfeb1618..9813e7c2f9 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -56,46 +56,24 @@ pub async fn apply_diff_from_task( } async fn apply_diff(diff: &str, cwd: Option) -> anyhow::Result<()> { - let mut cmd = tokio::process::Command::new("git"); - if let Some(cwd) = cwd { - cmd.current_dir(cwd); - } - let toplevel_output = cmd - .args(vec!["rev-parse", "--show-toplevel"]) - .output() - .await?; - - if !toplevel_output.status.success() { - anyhow::bail!("apply must be run from a git repository."); - } - - let repo_root = String::from_utf8(toplevel_output.stdout)? - .trim() - .to_string(); - - let mut git_apply_cmd = tokio::process::Command::new("git") - .args(vec!["apply", "--3way"]) - .current_dir(&repo_root) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn()?; - - if let Some(mut stdin) = git_apply_cmd.stdin.take() { - tokio::io::AsyncWriteExt::write_all(&mut stdin, diff.as_bytes()).await?; - drop(stdin); - } - - let output = git_apply_cmd.wait_with_output().await?; - - if !output.status.success() { + let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir())); + let req = codex_git_apply::ApplyGitRequest { + cwd, + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let res = codex_git_apply::apply_git_patch(&req)?; + if res.exit_code != 0 { anyhow::bail!( - "Git apply failed with status {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr) + "Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}", + res.applied_paths.len(), + res.skipped_paths.len(), + res.conflicted_paths.len(), + res.stdout, + res.stderr ); } - println!("Successfully applied diff"); Ok(()) } diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml index 7dac03976d..b41f926829 100644 --- a/codex-rs/cloud-tasks-client/Cargo.toml +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -26,6 +26,4 @@ serde_json = "1" thiserror = "2.0.12" tokio = { version = "1", features = ["macros", "rt-multi-thread"], optional = true } codex-backend-client = { path = "../backend-client", optional = true } -regex = "1" -tempfile = "3" -once_cell = "1" +codex-git-apply = { path = "../git-apply" } diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index c5496bb832..a673ed01bb 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -13,9 +13,6 @@ pub enum Error { Http(String), #[error("io error: {0}")] Io(String), - /// Expected condition: the task has no diff available yet (e.g., still in progress). - #[error("no diff available yet")] - NoDiffYet, #[error("{0}")] Msg(String), } @@ -86,11 +83,14 @@ pub struct TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { async fn list_tasks(&self, env: Option<&str>) -> Result>; - async fn get_task_diff(&self, id: TaskId) -> Result; + async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; /// Return the creating prompt and assistant messages (when available). async fn get_task_text(&self, id: TaskId) -> Result; + /// Dry-run apply (preflight) that validates whether the patch would apply cleanly. + /// Never modifies the working tree. + async fn apply_task_preflight(&self, id: TaskId) -> Result; async fn apply_task(&self, id: TaskId) -> Result; async fn create_task( &self, diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 226011a488..ffb0cd2c0d 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -1,5 +1,4 @@ use crate::ApplyOutcome; -use crate::api::TaskText; use crate::ApplyStatus; use crate::CloudBackend; use crate::DiffSummary; @@ -8,6 +7,7 @@ use crate::Result; use crate::TaskId; use crate::TaskStatus; use crate::TaskSummary; +use crate::api::TaskText; use chrono::DateTime; use chrono::Utc; @@ -87,7 +87,7 @@ impl CloudBackend for HttpClient { Ok(tasks) } - async fn get_task_diff(&self, _id: TaskId) -> Result { + async fn get_task_diff(&self, _id: TaskId) -> Result> { let id = _id.0; let (details, body, ct) = self .backend @@ -95,12 +95,12 @@ impl CloudBackend for HttpClient { .await .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; if let Some(diff) = details.unified_diff() { - return Ok(diff); + return Ok(Some(diff)); } - // No diff yet (pending or non-diff task). Return a structured error so UI can render cleanly. - // Keep a concise body tail in logs if needed by callers. - let _ = (body, ct); // silence unused if logging is disabled at callsite - Err(Error::NoDiffYet) + // No diff yet (pending or non-diff task). + // Keep variables bound for potential future logging. + let _ = (body, ct); + Ok(None) } async fn get_task_messages(&self, _id: TaskId) -> Result> { @@ -112,49 +112,7 @@ impl CloudBackend for HttpClient { .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; let mut msgs = details.assistant_text_messages(); if msgs.is_empty() { - // Fallback: some pending tasks expose only worklog messages; parse from raw body. - if let Ok(full) = serde_json::from_str::(&body) { - // worklog.messages[*] where author.role == "assistant" → content.parts[*].text - if let Some(arr) = full - .get("current_assistant_turn") - .and_then(|v| v.get("worklog")) - .and_then(|v| v.get("messages")) - .and_then(|v| v.as_array()) - { - for m in arr { - let is_assistant = m - .get("author") - .and_then(|a| a.get("role")) - .and_then(|r| r.as_str()) - == Some("assistant"); - if !is_assistant { - continue; - } - if let Some(parts) = m - .get("content") - .and_then(|c| c.get("parts")) - .and_then(|p| p.as_array()) - { - for p in parts { - if let Some(s) = p.as_str() { - // Shape: content { content_type: "text", parts: ["..."] } - if !s.is_empty() { - msgs.push(s.to_string()); - } - continue; - } - if let Some(obj) = p.as_object() - && obj.get("content_type").and_then(|t| t.as_str()) - == Some("text") - && let Some(txt) = obj.get("text").and_then(|t| t.as_str()) - { - msgs.push(txt.to_string()); - } - } - } - } - } - } + msgs.extend(extract_assistant_messages_from_body(&body)); } if !msgs.is_empty() { return Ok(msgs); @@ -182,44 +140,8 @@ impl CloudBackend for HttpClient { .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; let prompt = details.user_text_prompt(); let mut messages = details.assistant_text_messages(); - if messages.is_empty() - && let Ok(full) = serde_json::from_str::(&body) - && let Some(arr) = full - .get("current_assistant_turn") - .and_then(|v| v.get("worklog")) - .and_then(|v| v.get("messages")) - .and_then(|v| v.as_array()) - { - for m in arr { - let is_assistant = m - .get("author") - .and_then(|a| a.get("role")) - .and_then(|r| r.as_str()) - == Some("assistant"); - if !is_assistant { - continue; - } - if let Some(parts) = m - .get("content") - .and_then(|c| c.get("parts")) - .and_then(|p| p.as_array()) - { - for p in parts { - if let Some(s) = p.as_str() { - if !s.is_empty() { - messages.push(s.to_string()); - } - continue; - } - if let Some(obj) = p.as_object() - && obj.get("content_type").and_then(|t| t.as_str()) == Some("text") - && let Some(txt) = obj.get("text").and_then(|t| t.as_str()) - { - messages.push(txt.to_string()); - } - } - } - } + if messages.is_empty() { + messages.extend(extract_assistant_messages_from_body(&body)); } Ok(TaskText { prompt, messages }) } @@ -252,12 +174,13 @@ impl CloudBackend for HttpClient { } // Run the new Git apply engine - let req = crate::git_apply::ApplyGitRequest { + let req = codex_git_apply::ApplyGitRequest { cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), diff: diff.clone(), revert: false, + preflight: false, }; - let r = crate::git_apply::apply_git_patch(&req) + let r = codex_git_apply::apply_git_patch(&req) .map_err(|e| Error::Io(format!("git apply failed to run: {e}")))?; let status = if r.exit_code == 0 { @@ -352,6 +275,103 @@ impl CloudBackend for HttpClient { }) } + async fn apply_task_preflight(&self, _id: TaskId) -> Result { + let id = _id.0; + // Fetch diff fresh and apply locally via git (unified diffs). + let details = self + .backend + .get_task_details(&id) + .await + .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; + let diff = details + .unified_diff() + .ok_or_else(|| Error::Msg(format!("No diff available for task {id}")))?; + // Enforce unified diff format only + if !is_unified_diff(&diff) { + let summary = summarize_patch_for_logging(&diff); + append_error_log(&format!( + "apply_error: id={id} format=non-unified; {summary}" + )); + return Ok(ApplyOutcome { + applied: false, + status: ApplyStatus::Error, + message: "Expected unified git diff; backend returned an incompatible format." + .to_string(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + }); + } + + // Preflight: run with --check (no changes) + let req = codex_git_apply::ApplyGitRequest { + cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), + diff: diff.clone(), + revert: false, + preflight: true, + }; + let r = codex_git_apply::apply_git_patch(&req) + .map_err(|e| Error::Io(format!("git apply failed to run: {e}")))?; + + let status = if r.exit_code == 0 { + ApplyStatus::Success + } else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() { + ApplyStatus::Partial + } else { + ApplyStatus::Error + }; + let message = match status { + ApplyStatus::Success => format!("Preflight passed for task {id} (applies cleanly)"), + ApplyStatus::Partial => format!( + "Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})", + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len() + ), + ApplyStatus::Error => format!( + "Preflight failed for task {id} (applied={}, skipped={}, conflicts={})", + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len() + ), + }; + + if !matches!(status, ApplyStatus::Success) { + let mut log = String::new(); + let summary = summarize_patch_for_logging(&diff); + use std::fmt::Write as _; + let _ = writeln!( + &mut log, + "apply_preflight_result: id={} status={:?} applied={} skipped={} conflicts={} cmd={}", + id, + status, + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len(), + r.cmd_for_log + ); + let _ = writeln!( + &mut log, + "stdout_tail=\n{}\nstderr_tail=\n{}", + tail(&r.stdout, 2000), + tail(&r.stderr, 2000) + ); + let _ = writeln!(&mut log, "{summary}"); + let _ = writeln!( + &mut log, + "----- PATCH BEGIN -----\n{diff}\n----- PATCH END -----" + ); + append_error_log(&log); + } + + Ok(ApplyOutcome { + applied: false, + status, + message, + skipped_paths: r.skipped_paths, + conflict_paths: r.conflicted_paths, + }) + } + async fn create_task( &self, env_id: &str, @@ -408,6 +428,51 @@ impl CloudBackend for HttpClient { } } +/// Best-effort extraction of assistant text messages from a raw `get_task_details` body. +/// Falls back to worklog messages when structured turns are not present. +fn extract_assistant_messages_from_body(body: &str) -> Vec { + let mut msgs = Vec::new(); + if let Ok(full) = serde_json::from_str::(body) + && let Some(arr) = full + .get("current_assistant_turn") + .and_then(|v| v.get("worklog")) + .and_then(|v| v.get("messages")) + .and_then(|v| v.as_array()) + { + for m in arr { + let is_assistant = m + .get("author") + .and_then(|a| a.get("role")) + .and_then(|r| r.as_str()) + == Some("assistant"); + if !is_assistant { + continue; + } + if let Some(parts) = m + .get("content") + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()) + { + for p in parts { + if let Some(s) = p.as_str() { + if !s.is_empty() { + msgs.push(s.to_string()); + } + continue; + } + if let Some(obj) = p.as_object() + && obj.get("content_type").and_then(|t| t.as_str()) == Some("text") + && let Some(txt) = obj.get("text").and_then(|t| t.as_str()) + { + msgs.push(txt.to_string()); + } + } + } + } + } + msgs +} + fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary { fn env_label_from_status_display(v: Option<&HashMap>) -> Option { let obj = v?; diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs index face547bca..649e910fa0 100644 --- a/codex-rs/cloud-tasks-client/src/lib.rs +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -26,6 +26,4 @@ pub use mock::MockClient; #[cfg(feature = "online")] pub use http::HttpClient; -// Reusable apply engine (git apply runner and helpers) -// Legacy engine remains until migration completes. New engine lives in git_apply. -mod git_apply; +// Reusable apply engine now lives in the shared crate `codex-git-apply`. diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index 940c5325a9..de1160f65f 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -1,11 +1,11 @@ use crate::ApplyOutcome; -use crate::api::TaskText; use crate::CloudBackend; use crate::DiffSummary; use crate::Result; use crate::TaskId; use crate::TaskStatus; use crate::TaskSummary; +use crate::api::TaskText; use chrono::Utc; #[derive(Clone, Default)] @@ -56,8 +56,8 @@ impl CloudBackend for MockClient { Ok(out) } - async fn get_task_diff(&self, id: TaskId) -> Result { - Ok(mock_diff_for(&id)) + async fn get_task_diff(&self, id: TaskId) -> Result> { + Ok(Some(mock_diff_for(&id))) } async fn get_task_messages(&self, _id: TaskId) -> Result> { @@ -83,6 +83,16 @@ impl CloudBackend for MockClient { }) } + async fn apply_task_preflight(&self, id: TaskId) -> Result { + Ok(ApplyOutcome { + applied: false, + status: crate::ApplyStatus::Success, + message: format!("Preflight passed for task {} (mock)", id.0), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + }) + } + async fn create_task( &self, env_id: &str, diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index 147150179b..1cc1c48aee 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -221,7 +221,10 @@ mod tests { Ok(out) } - async fn get_task_diff(&self, _id: TaskId) -> codex_cloud_tasks_client::Result { + async fn get_task_diff( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result> { Err(codex_cloud_tasks_client::Error::Unimplemented( "not used in test", )) @@ -237,7 +240,10 @@ mod tests { &self, _id: TaskId, ) -> codex_cloud_tasks_client::Result { - Ok(codex_cloud_tasks_client::TaskText { prompt: Some("Example prompt".to_string()), messages: Vec::new() }) + Ok(codex_cloud_tasks_client::TaskText { + prompt: Some("Example prompt".to_string()), + messages: Vec::new(), + }) } async fn apply_task( @@ -249,6 +255,15 @@ mod tests { )) } + async fn apply_task_preflight( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::Error::Unimplemented( + "not used in test", + )) + } + async fn create_task( &self, _env_id: &str, diff --git a/codex-rs/cloud-tasks/src/bin/conncheck.rs b/codex-rs/cloud-tasks/src/bin/conncheck.rs index f0948a8ebb..411b7908d0 100644 --- a/codex-rs/cloud-tasks/src/bin/conncheck.rs +++ b/codex-rs/cloud-tasks/src/bin/conncheck.rs @@ -1,19 +1,20 @@ #![deny(clippy::unwrap_used, clippy::expect_used)] -use std::time::Duration; - -use base64::Engine; use codex_backend_client::Client as BackendClient; +use codex_cloud_tasks::util::extract_chatgpt_account_id; +use codex_cloud_tasks::util::normalize_base_url; use codex_core::config::find_codex_home; use codex_core::default_client::get_codex_user_agent; use codex_login::AuthManager; use codex_login::AuthMode; +use std::time::Duration; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Base URL (default to ChatGPT backend API) - let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + // Base URL (default to ChatGPT backend API) and normalize to canonical form + let raw_base = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + let base_url = normalize_base_url(&raw_base); println!("base_url: {base_url}"); let path_style = if base_url.contains("/backend-api") { "wham" @@ -102,34 +103,9 @@ async fn main() -> anyhow::Result<()> { for item in list.items.iter().take(5) { println!("- {} — {}", item.id, item.title); } - // Print the full response object for debugging/inspection. - match serde_json::to_string_pretty(&list) { - Ok(json) => { - println!("\nfull response object (pretty JSON):\n{json}"); - } - Err(e) => { - println!("failed to serialize response to JSON: {e}"); - } - } + // Keep output concise; omit full JSON payload to stay readable. } } Ok(()) } - -fn extract_chatgpt_account_id(token: &str) -> Option { - // JWT: header.payload.signature - let mut parts = token.split('.'); - let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => return None, - }; - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .ok()?; - let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; - v.get("https://api.openai.com/auth") - .and_then(|auth| auth.get("chatgpt_account_id")) - .and_then(|id| id.as_str()) - .map(|s| s.to_string()) -} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 6631f0377e..1facd26e17 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -6,28 +6,17 @@ pub mod env_detect; mod new_task; pub mod scrollable_diff; mod ui; +pub mod util; pub use cli::Cli; -use base64::Engine as _; -use chrono::Utc; -use std::fs::OpenOptions; use std::io::IsTerminal; -use std::io::Write as _; use std::path::PathBuf; use std::time::Duration; use tracing::info; use tracing_subscriber::EnvFilter; +use util::append_error_log; -pub(crate) fn append_error_log(message: impl AsRef) { - let ts = Utc::now().to_rfc3339(); - if let Ok(mut f) = OpenOptions::new() - .create(true) - .append(true) - .open("error.log") - { - let _ = writeln!(f, "[{ts}] {}", message.as_ref()); - } -} +// logging helper lives in util module // (no standalone patch summarizer needed – UI displays raw diffs) @@ -98,7 +87,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a http = http.with_bearer_token(t.clone()); if let Some(acc) = auth .get_account_id() - .or_else(|| extract_chatgpt_account_id(&t)) + .or_else(|| util::extract_chatgpt_account_id(&t)) { append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); http = http.with_chatgpt_account_id(acc); @@ -201,50 +190,11 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a { let tx2 = tx.clone(); tokio::spawn(async move { - let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") - .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - while base_url.ends_with('/') { - base_url.pop(); - } - if (base_url.starts_with("https://chatgpt.com") - || base_url.starts_with("https://chat.openai.com")) - && !base_url.contains("/backend-api") - { - base_url = format!("{base_url}/backend-api"); - } - let ua = - codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::USER_AGENT, - reqwest::header::HeaderValue::from_str(&ua) - .unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")), + let base_url = util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); - if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new( - home, - codex_login::AuthMode::ChatGPT, - "codex_cloud_tasks_tui".to_string(), - ); - if let Some(auth) = am.auth() - && let Ok(tok) = auth.get_token().await - && !tok.is_empty() - { - let v = format!("Bearer {tok}"); - if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { - headers.insert(reqwest::header::AUTHORIZATION, hv); - } - if let Some(acc) = auth - .get_account_id() - .or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = - reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) - { - headers.insert(name, hv); - } - } - } + let headers = util::build_chatgpt_headers().await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -255,54 +205,12 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a { let tx2 = tx.clone(); tokio::spawn(async move { - // Normalize base URL like envcheck.rs does - let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") - .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - while base_url.ends_with('/') { - base_url.pop(); - } - if (base_url.starts_with("https://chatgpt.com") - || base_url.starts_with("https://chat.openai.com")) - && !base_url.contains("/backend-api") - { - base_url = format!("{base_url}/backend-api"); - } - - // Build headers: UA + ChatGPT auth if available - let ua = - codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::USER_AGENT, - reqwest::header::HeaderValue::from_str(&ua) - .unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")), + let base_url = util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); - if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new( - home, - codex_login::AuthMode::ChatGPT, - "codex_cloud_tasks_tui".to_string(), - ); - if let Some(auth) = am.auth() - && let Ok(token) = auth.get_token().await - && !token.is_empty() - { - if let Ok(hv) = - reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) - { - headers.insert(reqwest::header::AUTHORIZATION, hv); - } - if let Some(account_id) = auth - .get_account_id() - .or_else(|| extract_chatgpt_account_id(&token)) - && let Ok(name) = - reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = reqwest::header::HeaderValue::from_str(&account_id) - { - headers.insert(name, hv); - } - } - } + // Build headers: UA + ChatGPT auth if available + let headers = util::build_chatgpt_headers().await; // Run autodetect. If it fails, we keep using "All". let res = crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await; @@ -512,34 +420,11 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a app.env_loading = true; let tx3 = tx.clone(); tokio::spawn(async move { - // Build headers (UA + ChatGPT token + account id) like elsewhere - let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - while base_url.ends_with('/') { base_url.pop(); } - if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { - base_url = format!("{base_url}/backend-api"); - } - let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); - if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new( - home, - codex_login::AuthMode::ChatGPT, - "codex_cloud_tasks_tui".to_string(), - ); - if let Some(auth) = am.auth() - && let Ok(tok) = auth.get_token().await - && !tok.is_empty() - { - let v = format!("Bearer {tok}"); - if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } - if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { - headers.insert(name, hv); - } - } - } + let base_url = crate::util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = crate::util::build_chatgpt_headers().await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx3.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -673,38 +558,13 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a } needs_redraw = true; if should_fetch { - let tx2 = tx.clone(); - tokio::spawn(async move { - // Build headers (UA + ChatGPT token + account id) - let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") - .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - while base_url.ends_with('/') { base_url.pop(); } - if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { - base_url = format!("{base_url}/backend-api"); - } - let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); - if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new( - home, - codex_login::AuthMode::ChatGPT, - "codex_cloud_tasks_tui".to_string(), - ); - if let Some(auth) = am.auth() - && let Ok(tok) = auth.get_token().await && !tok.is_empty() { - let v = format!("Bearer {tok}"); - if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } - if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { - headers.insert(name, hv); - } - } - } - let res = crate::env_detect::list_environments(&base_url, &headers).await; - let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res)); - }); + let tx2 = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res)); + }); } // Render after opening env modal to show it instantly. render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; @@ -798,9 +658,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let id2 = m.task_id.clone(); let title2 = m.title.clone(); tokio::spawn(async move { - unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") }; - let out = codex_cloud_tasks_client::CloudBackend::apply_task(&*backend2, id2.clone()).await; - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; + let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await; let evt = match out { Ok(outcome) => { let level = match outcome.status { @@ -835,9 +693,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let id2 = ov.task_id.clone(); let title2 = ov.title.clone(); tokio::spawn(async move { - unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") }; - let out = codex_cloud_tasks_client::CloudBackend::apply_task(&*backend2, id2.clone()).await; - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; + let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await; let evt = match out { Ok(outcome) => { let level = match outcome.status { @@ -867,29 +723,11 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a if app.environments.is_empty() { let tx2 = tx.clone(); tokio::spawn(async move { - // Build headers (UA + ChatGPT token + account id) - let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - while base_url.ends_with('/') { base_url.pop(); } - if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); } - let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); - if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new( - home, - codex_login::AuthMode::ChatGPT, - "codex_cloud_tasks_tui".to_string(), - ); - if let Some(auth) = am.auth() && let Ok(tok) = auth.get_token().await && !tok.is_empty() { - let v = format!("Bearer {tok}"); - if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } - if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { - headers.insert(name, hv); - } - } - } + let base_url = crate::util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = crate::util::build_chatgpt_headers().await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -957,28 +795,8 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); let tx2 = tx.clone(); tokio::spawn(async move { - // Build headers (UA + ChatGPT token + account id) - let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - while base_url.ends_with('/') { base_url.pop(); } - if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); } - let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); - if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new( - home, - codex_login::AuthMode::ChatGPT, - "codex_cloud_tasks_tui".to_string(), - ); - if let Some(auth) = am.auth() - && let Ok(tok) = auth.get_token().await && !tok.is_empty() { - let v = format!("Bearer {tok}"); - if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } - if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); } - } - } + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -1094,33 +912,13 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a if should_fetch { app.env_loading = true; app.env_error = None; } needs_redraw = true; if should_fetch { - let tx2 = tx.clone(); - tokio::spawn(async move { - // Build headers (UA + ChatGPT token + account id) - let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - while base_url.ends_with('/') { base_url.pop(); } - if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); } - let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli"))); - if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new( - home, - codex_login::AuthMode::ChatGPT, - "codex_cloud_tasks_tui".to_string(), - ); - if let Some(auth) = am.auth() - && let Ok(tok) = auth.get_token().await && !tok.is_empty() { - let v = format!("Bearer {tok}"); - if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); } - if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); } - } - } - let res = crate::env_detect::list_environments(&base_url, &headers).await; - let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res)); - }); + let tx2 = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res)); + }); } } KeyCode::Char('n') => { @@ -1147,11 +945,20 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let title2 = title1.clone(); tokio::spawn(async move { match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend2, id1.clone()).await { - Ok(diff) => { + Ok(Some(diff)) => { let _ = tx2.send(app::AppEvent::DetailsDiffLoaded { id: id1, title: title1, diff }); } + Ok(None) => { + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend2, id1.clone()).await { + Ok(text) => { + let _ = tx2.send(app::AppEvent::DetailsMessagesLoaded { id: id1, title: title1, messages: text.messages, prompt: text.prompt }); + } + Err(e2) => { + let _ = tx2.send(app::AppEvent::DetailsFailed { id: id1, title: title1, error: format!("{e2}") }); + } + } + } Err(e) => { - // Always log errors while we debug non-success states. append_error_log(format!("get_task_diff failed for {}: {e}", id1.0)); match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend2, id1.clone()).await { Ok(text) => { @@ -1183,7 +990,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a KeyCode::Char('a') => { if let Some(task) = app.tasks.get(app.selected) { match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await { - Ok(_) => { + Ok(Some(_)) => { app.apply_modal = Some(app::ApplyModalState { task_id: task.id.clone(), title: task.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() }); app.apply_preflight_inflight = true; let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); @@ -1192,9 +999,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let id2 = task.id.clone(); let title2 = task.title.clone(); tokio::spawn(async move { - unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") }; - let out = codex_cloud_tasks_client::CloudBackend::apply_task(&*backend2, id2.clone()).await; - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; + let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await; let evt = match out { Ok(outcome) => { let level = match outcome.status { @@ -1209,7 +1014,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let _ = tx2.send(evt); }); } - Err(_) => { + Ok(None) | Err(_) => { app.status = "No diff available to apply".to_string(); } } @@ -1249,22 +1054,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a Ok(()) } -fn extract_chatgpt_account_id(token: &str) -> Option { - // JWT: header.payload.signature - let mut parts = token.split('.'); - let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => return None, - }; - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .ok()?; - let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; - v.get("https://api.openai.com/auth") - .and_then(|auth| auth.get("chatgpt_account_id")) - .and_then(|id| id.as_str()) - .map(|s| s.to_string()) -} +// extract_chatgpt_account_id moved to util.rs /// Build plain-text conversation lines: a labeled user prompt followed by assistant messages. fn conversation_lines(prompt: Option, messages: &[String]) -> Vec { diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index 4e0fc3611c..a53ffcef7e 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -286,7 +286,11 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { if app.diff_overlay.is_none() { return; } - let ov_can_apply = app.diff_overlay.as_ref().map(|o| o.can_apply).unwrap_or(false); + let ov_can_apply = app + .diff_overlay + .as_ref() + .map(|o| o.can_apply) + .unwrap_or(false); let is_error = app .diff_overlay .as_ref() @@ -302,7 +306,12 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { // Title block let mut title_spans: Vec = if is_error { - vec!["Details ".magenta(), "[FAILED]".red().bold(), " ".into(), title.clone().magenta()] + vec![ + "Details ".magenta(), + "[FAILED]".red().bold(), + " ".into(), + title.clone().magenta(), + ] } else if ov_can_apply { vec!["Diff: ".magenta(), title.clone().magenta()] } else { @@ -317,7 +326,10 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { title_spans.push(format!("{p}%").dim()); } frame.render_widget(Clear, inner); - frame.render_widget(overlay_block().title(Line::from(title_spans)).clone(), inner); + frame.render_widget( + overlay_block().title(Line::from(title_spans)).clone(), + inner, + ); // Content area and optional status bar let content_full = overlay_content(inner); @@ -333,9 +345,23 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { // Status bar label let mut spans: Vec = Vec::new(); if has_diff && has_text { - let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) { "[Prompt]".magenta().bold() } else { "Prompt".dim() }; - let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) { "[Diff]".magenta().bold() } else { "Diff".dim() }; - spans.extend(vec![prompt_lbl, " ".into(), diff_lbl, " ".into(), "(← → to switch)".dim()]); + let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) { + "[Prompt]".magenta().bold() + } else { + "Prompt".dim() + }; + let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) { + "[Diff]".magenta().bold() + } else { + "Diff".dim() + }; + spans.extend(vec![ + prompt_lbl, + " ".into(), + diff_lbl, + " ".into(), + "(← → to switch)".dim(), + ]); } else if has_text { spans.push("Conversation".magenta().bold()); } else { @@ -361,7 +387,10 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { .unwrap_or(false); let styled_lines: Vec> = if is_diff_view { let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); - raw.unwrap_or(&[]).iter().map(|l| style_diff_line(l)).collect() + raw.unwrap_or(&[]) + .iter() + .map(|l| style_diff_line(l)) + .collect() } else { let mut in_code = false; let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs new file mode 100644 index 0000000000..63040efcaf --- /dev/null +++ b/codex-rs/cloud-tasks/src/util.rs @@ -0,0 +1,90 @@ +use base64::Engine as _; +use chrono::Utc; +use reqwest::header::HeaderMap; + +pub fn append_error_log(message: impl AsRef) { + let ts = Utc::now().to_rfc3339(); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("error.log") + { + use std::io::Write as _; + let _ = writeln!(f, "[{ts}] {}", message.as_ref()); + } +} + +/// Normalize the configured base URL to a canonical form used by the backend client. +/// - trims trailing '/' +/// - appends '/backend-api' for ChatGPT hosts when missing +pub fn normalize_base_url(input: &str) -> String { + let mut base_url = input.to_string(); + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{base_url}/backend-api"); + } + base_url +} + +/// Extract the ChatGPT account id from a JWT token, when present. +pub fn extract_chatgpt_account_id(token: &str) -> Option { + let mut parts = token.split('.'); + let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return None, + }; + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + v.get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) +} + +/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`, +/// and optional `ChatGPT-Account-Id`. +pub async fn build_chatgpt_headers() -> HeaderMap { + use reqwest::header::AUTHORIZATION; + use reqwest::header::HeaderName; + use reqwest::header::HeaderValue; + use reqwest::header::USER_AGENT; + + let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui")); + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), + ); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new( + home, + codex_login::AuthMode::ChatGPT, + "codex_cloud_tasks_tui".to_string(), + ); + if let Some(auth) = am.auth() + && let Ok(tok) = auth.get_token().await + && !tok.is_empty() + { + let v = format!("Bearer {tok}"); + if let Ok(hv) = HeaderValue::from_str(&v) { + headers.insert(AUTHORIZATION, hv); + } + if let Some(acc) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&tok)) + && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") + && let Ok(hv) = HeaderValue::from_str(&acc) + { + headers.insert(name, hv); + } + } + } + headers +} diff --git a/codex-rs/git-apply/Cargo.toml b/codex-rs/git-apply/Cargo.toml new file mode 100644 index 0000000000..0c17ccc3a4 --- /dev/null +++ b/codex-rs/git-apply/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-git-apply" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_git_apply" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +once_cell = "1" +regex = "1" +tempfile = "3" + diff --git a/codex-rs/cloud-tasks-client/src/git_apply.rs b/codex-rs/git-apply/src/lib.rs similarity index 97% rename from codex-rs/cloud-tasks-client/src/git_apply.rs rename to codex-rs/git-apply/src/lib.rs index 8edb8c00f3..d4fac0f6f0 100644 --- a/codex-rs/cloud-tasks-client/src/git_apply.rs +++ b/codex-rs/git-apply/src/lib.rs @@ -1,16 +1,18 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use once_cell::sync::Lazy; use regex::Regex; use std::ffi::OsStr; use std::io; use std::path::Path; use std::path::PathBuf; -use once_cell::sync::Lazy; - #[derive(Debug, Clone)] pub struct ApplyGitRequest { pub cwd: PathBuf, pub diff: String, pub revert: bool, + pub preflight: bool, } #[derive(Debug, Clone)] @@ -58,11 +60,8 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result { args.push(patch_path.to_string_lossy().to_string()); - // Optional preflight: CODEX_APPLY_PREFLIGHT=1 (dry-run only; do not modify WT) - if matches!( - std::env::var("CODEX_APPLY_PREFLIGHT").ok().as_deref(), - Some("1" | "true" | "yes") - ) { + // Optional preflight: dry-run only; do not modify working tree + if req.preflight { let mut check_args = vec!["apply".to_string(), "--check".to_string()]; if req.revert { check_args.push("-R".to_string()); @@ -479,6 +478,7 @@ fn regex_ci(pat: &str) -> Regex { #[cfg(test)] mod tests { use super::*; + use std::path::Path; use std::sync::Mutex; use std::sync::OnceLock; @@ -513,7 +513,6 @@ mod tests { #[test] fn apply_add_success() { let _g = env_lock().lock().unwrap(); - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; let repo = init_repo(); let root = repo.path().to_path_buf(); @@ -522,6 +521,7 @@ mod tests { cwd: root.clone(), diff: diff.to_string(), revert: false, + preflight: false, }; let r = apply_git_patch(&req).expect("run apply"); assert_eq!(r.exit_code, 0, "exit code 0"); @@ -532,7 +532,6 @@ mod tests { #[test] fn apply_modify_conflict() { let _g = env_lock().lock().unwrap(); - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; let repo = init_repo(); let root = repo.path(); // seed file and commit @@ -547,6 +546,7 @@ mod tests { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, + preflight: false, }; let r = apply_git_patch(&req).expect("run apply"); assert_ne!(r.exit_code, 0, "non-zero exit on conflict"); @@ -555,7 +555,6 @@ mod tests { #[test] fn apply_modify_skipped_missing_index() { let _g = env_lock().lock().unwrap(); - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; let repo = init_repo(); let root = repo.path(); // Try to modify a file that is not in the index @@ -564,6 +563,7 @@ mod tests { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, + preflight: false, }; let r = apply_git_patch(&req).expect("run apply"); assert_ne!(r.exit_code, 0, "non-zero exit on missing index"); @@ -572,7 +572,6 @@ mod tests { #[test] fn apply_then_revert_success() { let _g = env_lock().lock().unwrap(); - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; let repo = init_repo(); let root = repo.path(); // Seed file and commit original content @@ -586,6 +585,7 @@ mod tests { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, + preflight: false, }; let res_apply = apply_git_patch(&apply_req).expect("apply ok"); assert_eq!(res_apply.exit_code, 0, "forward apply succeeded"); @@ -597,6 +597,7 @@ mod tests { cwd: root.to_path_buf(), diff: diff.to_string(), revert: true, + preflight: false, }; let res_revert = apply_git_patch(&revert_req).expect("revert ok"); assert_eq!(res_revert.exit_code, 0, "revert apply succeeded"); @@ -614,14 +615,13 @@ mod tests { diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"; // 1) With preflight enabled, nothing should be changed (even though ok.txt could be added) - unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") }; let req1 = ApplyGitRequest { cwd: root.clone(), diff: diff.to_string(), revert: false, + preflight: true, }; let r1 = apply_git_patch(&req1).expect("preflight apply"); - unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") }; assert_ne!(r1.exit_code, 0, "preflight reports failure"); assert!( !root.join("ok.txt").exists(), @@ -637,6 +637,7 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1 cwd: root.clone(), diff: diff.to_string(), revert: false, + preflight: false, }; let r2 = apply_git_patch(&req2).expect("direct apply"); assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");