improvements

This commit is contained in:
easong-openai
2025-09-25 03:05:30 -07:00
parent 3d12b46b18
commit 16ac10f9d3
18 changed files with 503 additions and 688 deletions

14
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -22,6 +22,7 @@ members = [
"protocol",
"protocol-ts",
"tui",
"git-apply",
]
resolver = "2"

View File

@@ -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<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
match serde_json::from_str::<T>(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<i32>,
@@ -117,7 +143,7 @@ impl Client {
) -> Result<PaginatedListTaskListItem> {
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::<PaginatedListTaskListItem>(&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::<PaginatedListTaskListItem>(&url, &ct, &body)
}
pub async fn get_task_details(&self, task_id: &str) -> Result<CodeTaskDetailsResponse> {
@@ -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::<CodeTaskDetailsResponse>(&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<String> {
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::<serde_json::Value>(&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}"),
}
}
}

View File

@@ -14,8 +14,6 @@ pub trait CodeTaskDetailsResponseExt {
fn user_text_prompt(&self) -> Option<String>;
/// Extract an assistant error message (if the turn failed and provided one).
fn assistant_error_message(&self) -> Option<String>;
/// 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<String> {
@@ -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<String> {
@@ -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<serde_json::Value>) -> 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<std::collections::HashMap<String, serde_json::Value>>; 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<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();
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<std::collections::HashMap<String, Value>>; 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.

View File

@@ -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"

View File

@@ -56,46 +56,24 @@ pub async fn apply_diff_from_task(
}
async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> 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(())
}

View File

@@ -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" }

View File

@@ -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<Vec<TaskSummary>>;
async fn get_task_diff(&self, id: TaskId) -> Result<String>;
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>>;
/// Return assistant output messages (no diff) when available.
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;
/// Return the creating prompt and assistant messages (when available).
async fn get_task_text(&self, id: TaskId) -> Result<TaskText>;
/// 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<ApplyOutcome>;
async fn apply_task(&self, id: TaskId) -> Result<ApplyOutcome>;
async fn create_task(
&self,

View File

@@ -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<String> {
async fn get_task_diff(&self, _id: TaskId) -> Result<Option<String>> {
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<Vec<String>> {
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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<ApplyOutcome> {
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<String> {
let mut msgs = Vec::new();
if let Ok(full) = serde_json::from_str::<serde_json::Value>(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<String, Value>>) -> Option<String> {
let obj = v?;

View File

@@ -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`.

View File

@@ -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<String> {
Ok(mock_diff_for(&id))
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
Ok(Some(mock_diff_for(&id)))
}
async fn get_task_messages(&self, _id: TaskId) -> Result<Vec<String>> {
@@ -83,6 +83,16 @@ impl CloudBackend for MockClient {
})
}
async fn apply_task_preflight(&self, id: TaskId) -> Result<ApplyOutcome> {
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,

View File

@@ -221,7 +221,10 @@ mod tests {
Ok(out)
}
async fn get_task_diff(&self, _id: TaskId) -> codex_cloud_tasks_client::Result<String> {
async fn get_task_diff(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<Option<String>> {
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<codex_cloud_tasks_client::TaskText> {
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<codex_cloud_tasks_client::ApplyOutcome> {
Err(codex_cloud_tasks_client::Error::Unimplemented(
"not used in test",
))
}
async fn create_task(
&self,
_env_id: &str,

View File

@@ -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<String> {
// 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())
}

View File

@@ -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<str>) {
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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> a
Ok(())
}
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
// 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<String>, messages: &[String]) -> Vec<String> {

View File

@@ -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<ratatui::text::Span> = 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<ratatui::text::Span> = 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<Line<'static>> = 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());

View File

@@ -0,0 +1,90 @@
use base64::Engine as _;
use chrono::Utc;
use reqwest::header::HeaderMap;
pub fn append_error_log(message: impl AsRef<str>) {
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<String> {
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
}

View File

@@ -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"

View File

@@ -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<ApplyGitResult> {
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");