mirror of
https://github.com/openai/codex.git
synced 2026-11-08 00:36:31 +00:00
Route cloud environment discovery through the HTTP client pool (#34491)
## What changed - Build a route-aware API client from the cloud backend's configured HTTP factory and reuse it for environment listing and autodetection. - Replace direct `reqwest` environment requests with a small injectable HTTP boundary while preserving status, content-type, body, and header handling. - Centralize asynchronous environment-list loading around the initialized backend context. ## Testing - Add coverage for production header forwarding and response decoding. - Verify repository-specific lookup, global fallback, endpoint selection, deduplication, and merged environment metadata with a fake HTTP client. GitOrigin-RevId: 2e6812015ec3eb01773184541a1fad135ed53edb
This commit is contained in:
1 parent
dc21b46aea
commit
0e15c31d91
7 files changed
+447
-93
No files matched your search
Generated
+1
-1
@@ -2436,10 +2436,10 @@ dependencies = [
|
|||||||
"codex-tui",
|
"codex-tui",
|
||||||
"codex-utils-cli",
|
"codex-utils-cli",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"http 1.4.0",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reqwest 0.12.28",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"supports-color 3.0.2",
|
"supports-color 3.0.2",
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ codex-model-provider = { workspace = true }
|
|||||||
codex-tui = { workspace = true }
|
codex-tui = { workspace = true }
|
||||||
codex-utils-cli = { workspace = true }
|
codex-utils-cli = { workspace = true }
|
||||||
crossterm = { workspace = true, features = ["event-stream"] }
|
crossterm = { workspace = true, features = ["event-stream"] }
|
||||||
|
http = { workspace = true }
|
||||||
owo-colors = { workspace = true, features = ["supports-colors"] }
|
owo-colors = { workspace = true, features = ["supports-colors"] }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["json"] }
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
supports-color = { workspace = true }
|
supports-color = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use codex_http_client::build_reqwest_client_with_custom_ca;
|
use codex_http_client::RouteAwareClientPool;
|
||||||
use reqwest::header::CONTENT_TYPE;
|
use http::StatusCode;
|
||||||
use reqwest::header::HeaderMap;
|
use http::header::CONTENT_TYPE;
|
||||||
|
use http::header::HeaderMap;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -16,22 +17,39 @@ struct CodeEnvironment {
|
|||||||
task_count: Option<i64>,
|
task_count: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AutodetectSelection {
|
pub struct AutodetectSelection {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn autodetect_environment_id(
|
pub async fn autodetect_environment_id(
|
||||||
|
http: &RouteAwareClientPool,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
desired_label: Option<String>,
|
desired_label: Option<String>,
|
||||||
|
) -> anyhow::Result<AutodetectSelection> {
|
||||||
|
autodetect_environment_id_with_origins(
|
||||||
|
http,
|
||||||
|
base_url,
|
||||||
|
headers,
|
||||||
|
desired_label,
|
||||||
|
&get_git_origins(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn autodetect_environment_id_with_origins(
|
||||||
|
http: &impl EnvironmentHttp,
|
||||||
|
base_url: &str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
desired_label: Option<String>,
|
||||||
|
origins: &[String],
|
||||||
) -> anyhow::Result<AutodetectSelection> {
|
) -> anyhow::Result<AutodetectSelection> {
|
||||||
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode)
|
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode)
|
||||||
let origins = get_git_origins();
|
|
||||||
crate::append_error_log(format!("env: git origins: {origins:?}"));
|
crate::append_error_log(format!("env: git origins: {origins:?}"));
|
||||||
let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new();
|
let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new();
|
||||||
for origin in &origins {
|
for origin in origins {
|
||||||
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
||||||
let url = if base_url.contains("/backend-api") {
|
let url = if base_url.contains("/backend-api") {
|
||||||
format!(
|
format!(
|
||||||
@@ -45,7 +63,7 @@ pub async fn autodetect_environment_id(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
crate::append_error_log(format!("env: GET {url}"));
|
crate::append_error_log(format!("env: GET {url}"));
|
||||||
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
|
match get_json::<Vec<CodeEnvironment>>(http, &url, headers).await {
|
||||||
Ok(mut list) => {
|
Ok(mut list) => {
|
||||||
crate::append_error_log(format!(
|
crate::append_error_log(format!(
|
||||||
"env: by-repo returned {} env(s) for {owner}/{repo}",
|
"env: by-repo returned {} env(s) for {owner}/{repo}",
|
||||||
@@ -74,16 +92,10 @@ pub async fn autodetect_environment_id(
|
|||||||
};
|
};
|
||||||
crate::append_error_log(format!("env: GET {list_url}"));
|
crate::append_error_log(format!("env: GET {list_url}"));
|
||||||
// Fetch and log the full environments JSON for debugging
|
// Fetch and log the full environments JSON for debugging
|
||||||
let http = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
|
let response = http.get(&list_url, headers).await?;
|
||||||
let res = http.get(&list_url).headers(headers.clone()).send().await?;
|
let status = response.status;
|
||||||
let status = res.status();
|
let ct = response.content_type;
|
||||||
let ct = res
|
let body = response.body;
|
||||||
.headers()
|
|
||||||
.get(CONTENT_TYPE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let body = res.text().await.unwrap_or_default();
|
|
||||||
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
||||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
@@ -145,19 +157,14 @@ fn pick_environment_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_json<T: serde::de::DeserializeOwned>(
|
async fn get_json<T: serde::de::DeserializeOwned>(
|
||||||
|
http: &impl EnvironmentHttp,
|
||||||
url: &str,
|
url: &str,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
) -> anyhow::Result<T> {
|
) -> anyhow::Result<T> {
|
||||||
let http = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
|
let response = http.get(url, headers).await?;
|
||||||
let res = http.get(url).headers(headers.clone()).send().await?;
|
let status = response.status;
|
||||||
let status = res.status();
|
let ct = response.content_type;
|
||||||
let ct = res
|
let body = response.body;
|
||||||
.headers()
|
|
||||||
.get(CONTENT_TYPE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let body = res.text().await.unwrap_or_default();
|
|
||||||
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
|
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
|
||||||
@@ -168,6 +175,44 @@ async fn get_json<T: serde::de::DeserializeOwned>(
|
|||||||
Ok(parsed)
|
Ok(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct EnvironmentResponse {
|
||||||
|
status: StatusCode,
|
||||||
|
content_type: String,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP boundary used by environment discovery.
|
||||||
|
///
|
||||||
|
/// Implementations must issue a GET for the complete `url`, forward all supplied headers, and
|
||||||
|
/// return the response status, content type, and body for the caller to validate and decode.
|
||||||
|
trait EnvironmentHttp: Send + Sync {
|
||||||
|
fn get<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
headers: &'a HeaderMap,
|
||||||
|
) -> impl std::future::Future<Output = anyhow::Result<EnvironmentResponse>> + Send + 'a;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvironmentHttp for RouteAwareClientPool {
|
||||||
|
async fn get(&self, url: &str, headers: &HeaderMap) -> anyhow::Result<EnvironmentResponse> {
|
||||||
|
let response = RouteAwareClientPool::get(self, url)
|
||||||
|
.headers(headers.clone())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(EnvironmentResponse {
|
||||||
|
status: response.status(),
|
||||||
|
content_type: response
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
body: response.text().await.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_git_origins() -> Vec<String> {
|
fn get_git_origins() -> Vec<String> {
|
||||||
// Prefer: git config --get-regexp remote\..*\.url
|
// Prefer: git config --get-regexp remote\..*\.url
|
||||||
let out = std::process::Command::new("git")
|
let out = std::process::Command::new("git")
|
||||||
@@ -254,14 +299,23 @@ fn parse_owner_repo(url: &str) -> Option<(String, String)> {
|
|||||||
/// List environments for the current repo(s) with a fallback to the global list.
|
/// List environments for the current repo(s) with a fallback to the global list.
|
||||||
/// Returns a de-duplicated, sorted set suitable for the TUI modal.
|
/// Returns a de-duplicated, sorted set suitable for the TUI modal.
|
||||||
pub async fn list_environments(
|
pub async fn list_environments(
|
||||||
|
http: &RouteAwareClientPool,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
|
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
|
||||||
|
list_environments_with_origins(http, base_url, headers, &get_git_origins()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_environments_with_origins(
|
||||||
|
http: &impl EnvironmentHttp,
|
||||||
|
base_url: &str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
origins: &[String],
|
||||||
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
|
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
|
||||||
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
|
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
|
||||||
|
|
||||||
// 1) By-repo lookup for each parsed GitHub origin
|
// 1) By-repo lookup for each parsed GitHub origin
|
||||||
let origins = get_git_origins();
|
for origin in origins {
|
||||||
for origin in &origins {
|
|
||||||
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
||||||
let url = if base_url.contains("/backend-api") {
|
let url = if base_url.contains("/backend-api") {
|
||||||
format!(
|
format!(
|
||||||
@@ -274,7 +328,7 @@ pub async fn list_environments(
|
|||||||
base_url, "github", owner, repo
|
base_url, "github", owner, repo
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
|
match get_json::<Vec<CodeEnvironment>>(http, &url, headers).await {
|
||||||
Ok(list) => {
|
Ok(list) => {
|
||||||
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
|
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
|
||||||
for e in list {
|
for e in list {
|
||||||
@@ -312,7 +366,7 @@ pub async fn list_environments(
|
|||||||
} else {
|
} else {
|
||||||
format!("{base_url}/api/codex/environments")
|
format!("{base_url}/api/codex/environments")
|
||||||
};
|
};
|
||||||
match get_json::<Vec<CodeEnvironment>>(&list_url, headers).await {
|
match get_json::<Vec<CodeEnvironment>>(http, &list_url, headers).await {
|
||||||
Ok(list) => {
|
Ok(list) => {
|
||||||
info!("env_tui: global list -> {} envs", list.len());
|
info!("env_tui: global list -> {} envs", list.len());
|
||||||
for e in list {
|
for e in list {
|
||||||
@@ -360,3 +414,7 @@ pub async fn list_environments(
|
|||||||
});
|
});
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "env_detect_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use codex_http_client::ClientRouteClass;
|
||||||
|
use codex_http_client::HttpClientFactory;
|
||||||
|
use codex_http_client::OutboundProxyPolicy;
|
||||||
|
use http::HeaderMap;
|
||||||
|
use http::HeaderValue;
|
||||||
|
use http::StatusCode;
|
||||||
|
use http::header::AUTHORIZATION;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://chatgpt.com/backend-api";
|
||||||
|
const BY_REPO_URL: &str =
|
||||||
|
"https://chatgpt.com/backend-api/wham/environments/by-repo/github/openai/codex";
|
||||||
|
const GLOBAL_URL: &str = "https://chatgpt.com/backend-api/wham/environments";
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn production_http_forwards_headers_and_decodes_response() {
|
||||||
|
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.expect("environment HTTP listener should bind");
|
||||||
|
let address = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("environment HTTP listener should have an address");
|
||||||
|
listener
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.expect("environment HTTP listener should become nonblocking");
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(2);
|
||||||
|
let (mut stream, _) = loop {
|
||||||
|
match listener.accept() {
|
||||||
|
Ok(connection) => break connection,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"environment HTTP listener should receive a request"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
Err(error) => panic!("environment HTTP listener should accept: {error}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||||
|
.expect("environment HTTP stream should get a read timeout");
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let bytes_read = stream
|
||||||
|
.read(&mut buffer)
|
||||||
|
.expect("environment HTTP request should read");
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
request.extend_from_slice(&buffer[..bytes_read]);
|
||||||
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = r#"[{"id":"env-real","label":"Real"}]"#;
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.expect("environment HTTP response should write");
|
||||||
|
String::from_utf8(request).expect("environment HTTP request should be UTF-8")
|
||||||
|
});
|
||||||
|
let http = RouteAwareClientPool::new_without_request_logging(
|
||||||
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||||
|
ClientRouteClass::Api,
|
||||||
|
);
|
||||||
|
let base_url = format!("http://{address}");
|
||||||
|
let headers =
|
||||||
|
HeaderMap::from_iter([(AUTHORIZATION, HeaderValue::from_static("Bearer real-token"))]);
|
||||||
|
|
||||||
|
let selection = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
autodetect_environment_id_with_origins(
|
||||||
|
&http,
|
||||||
|
&base_url,
|
||||||
|
&headers,
|
||||||
|
/*desired_label*/ None,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("environment request should finish")
|
||||||
|
.expect("environment response should decode");
|
||||||
|
let request = server
|
||||||
|
.join()
|
||||||
|
.expect("environment HTTP server should finish");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
selection,
|
||||||
|
AutodetectSelection {
|
||||||
|
id: "env-real".to_string(),
|
||||||
|
label: Some("Real".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(request.starts_with("GET /api/codex/environments HTTP/1.1\r\n"));
|
||||||
|
assert!(
|
||||||
|
request
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("authorization: bearer real-token\r\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn autodetect_requests_exact_repository_endpoint_and_decodes_selection() {
|
||||||
|
let http = FakeHttp::new(HashMap::from([(
|
||||||
|
BY_REPO_URL.to_string(),
|
||||||
|
json_response(r#"[{"id":"env-repo","label":"Repository","is_pinned":true}]"#),
|
||||||
|
)]));
|
||||||
|
|
||||||
|
let headers = HeaderMap::from_iter([(
|
||||||
|
AUTHORIZATION,
|
||||||
|
HeaderValue::from_static("Bearer forwarded-token"),
|
||||||
|
)]);
|
||||||
|
let selection = autodetect_environment_id_with_origins(
|
||||||
|
&http,
|
||||||
|
BASE_URL,
|
||||||
|
&headers,
|
||||||
|
Some("Repository".to_string()),
|
||||||
|
&["git@github.com:openai/codex.git".to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("repository environment should be selected");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
selection,
|
||||||
|
AutodetectSelection {
|
||||||
|
id: "env-repo".to_string(),
|
||||||
|
label: Some("Repository".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
http.requests(),
|
||||||
|
vec![RecordedRequest {
|
||||||
|
url: BY_REPO_URL.to_string(),
|
||||||
|
headers,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn autodetect_falls_back_to_exact_global_endpoint_and_decodes_selection() {
|
||||||
|
let http = FakeHttp::new(HashMap::from([
|
||||||
|
(BY_REPO_URL.to_string(), json_response("[]")),
|
||||||
|
(
|
||||||
|
GLOBAL_URL.to_string(),
|
||||||
|
json_response(r#"[{"id":"env-global","label":"Global"}]"#),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let selection = autodetect_environment_id_with_origins(
|
||||||
|
&http,
|
||||||
|
BASE_URL,
|
||||||
|
&HeaderMap::new(),
|
||||||
|
/*desired_label*/ None,
|
||||||
|
&["git@github.com:openai/codex.git".to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("global environment should be selected");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
selection,
|
||||||
|
AutodetectSelection {
|
||||||
|
id: "env-global".to_string(),
|
||||||
|
label: Some("Global".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
http.requested_urls(),
|
||||||
|
vec![BY_REPO_URL.to_string(), GLOBAL_URL.to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_requests_exact_repository_and_global_endpoints_and_merges_results() {
|
||||||
|
let http = FakeHttp::new(HashMap::from([
|
||||||
|
(
|
||||||
|
BY_REPO_URL.to_string(),
|
||||||
|
json_response(r#"[{"id":"env-repo","label":"Repository"}]"#),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
GLOBAL_URL.to_string(),
|
||||||
|
json_response(
|
||||||
|
r#"[{"id":"env-repo","is_pinned":true},{"id":"env-global","label":"Global"}]"#,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let rows = list_environments_with_origins(
|
||||||
|
&http,
|
||||||
|
BASE_URL,
|
||||||
|
&HeaderMap::new(),
|
||||||
|
&["https://github.com/openai/codex.git".to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("environment list should decode");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|row| (row.id, row.label, row.is_pinned, row.repo_hints))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"env-repo".to_string(),
|
||||||
|
Some("Repository".to_string()),
|
||||||
|
true,
|
||||||
|
Some("openai/codex".to_string()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"env-global".to_string(),
|
||||||
|
Some("Global".to_string()),
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
http.requested_urls(),
|
||||||
|
vec![BY_REPO_URL.to_string(), GLOBAL_URL.to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeHttp {
|
||||||
|
responses: HashMap<String, EnvironmentResponse>,
|
||||||
|
requests: Mutex<Vec<RecordedRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct RecordedRequest {
|
||||||
|
url: String,
|
||||||
|
headers: HeaderMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeHttp {
|
||||||
|
fn new(responses: HashMap<String, EnvironmentResponse>) -> Self {
|
||||||
|
Self {
|
||||||
|
responses,
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requests(&self) -> Vec<RecordedRequest> {
|
||||||
|
self.requests.lock().expect("request lock").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requested_urls(&self) -> Vec<String> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.expect("request lock")
|
||||||
|
.iter()
|
||||||
|
.map(|request| request.url.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvironmentHttp for FakeHttp {
|
||||||
|
async fn get(&self, url: &str, headers: &HeaderMap) -> anyhow::Result<EnvironmentResponse> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.expect("request lock")
|
||||||
|
.push(RecordedRequest {
|
||||||
|
url: url.to_string(),
|
||||||
|
headers: headers.clone(),
|
||||||
|
});
|
||||||
|
self.responses
|
||||||
|
.get(url)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unexpected URL: {url}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_response(body: &str) -> EnvironmentResponse {
|
||||||
|
EnvironmentResponse {
|
||||||
|
status: StatusCode::OK,
|
||||||
|
content_type: "application/json".to_string(),
|
||||||
|
body: body.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ use chrono::Utc;
|
|||||||
use codex_cloud_tasks_client::TaskStatus;
|
use codex_cloud_tasks_client::TaskStatus;
|
||||||
use codex_git_utils::current_branch_name;
|
use codex_git_utils::current_branch_name;
|
||||||
use codex_git_utils::default_branch_name;
|
use codex_git_utils::default_branch_name;
|
||||||
|
use codex_http_client::ClientRouteClass;
|
||||||
|
use codex_http_client::HttpClientFactory;
|
||||||
|
use codex_http_client::OutboundProxyPolicy;
|
||||||
|
use codex_http_client::RouteAwareClientPool;
|
||||||
use codex_login::default_client::get_codex_user_agent;
|
use codex_login::default_client::get_codex_user_agent;
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use owo_colors::Stream;
|
use owo_colors::Stream;
|
||||||
@@ -38,6 +42,7 @@ struct ApplyJob {
|
|||||||
struct BackendContext {
|
struct BackendContext {
|
||||||
backend: Arc<dyn codex_cloud_tasks_client::CloudBackend>,
|
backend: Arc<dyn codex_cloud_tasks_client::CloudBackend>,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
|
environment_http: RouteAwareClientPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext> {
|
async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext> {
|
||||||
@@ -56,11 +61,19 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
|
|||||||
return Ok(BackendContext {
|
return Ok(BackendContext {
|
||||||
backend: Arc::new(codex_cloud_tasks_mock_client::MockClient),
|
backend: Arc::new(codex_cloud_tasks_mock_client::MockClient),
|
||||||
base_url,
|
base_url,
|
||||||
|
environment_http: RouteAwareClientPool::new_without_request_logging(
|
||||||
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||||
|
ClientRouteClass::Api,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let ua = get_codex_user_agent();
|
let ua = get_codex_user_agent();
|
||||||
let (auth_manager, http_client_factory) = util::load_auth_manager(Some(base_url.clone())).await;
|
let (auth_manager, http_client_factory) = util::load_auth_manager(Some(base_url.clone())).await;
|
||||||
|
let environment_http = RouteAwareClientPool::new_without_request_logging(
|
||||||
|
http_client_factory.clone(),
|
||||||
|
ClientRouteClass::Api,
|
||||||
|
);
|
||||||
let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone(), http_client_factory)
|
let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone(), http_client_factory)
|
||||||
.with_user_agent(ua);
|
.with_user_agent(ua);
|
||||||
let style = if base_url.contains("/backend-api") {
|
let style = if base_url.contains("/backend-api") {
|
||||||
@@ -104,6 +117,7 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
|
|||||||
Ok(BackendContext {
|
Ok(BackendContext {
|
||||||
backend: Arc::new(http),
|
backend: Arc::new(http),
|
||||||
base_url,
|
base_url,
|
||||||
|
environment_http,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +205,8 @@ async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow
|
|||||||
}
|
}
|
||||||
let normalized = util::normalize_base_url(&ctx.base_url);
|
let normalized = util::normalize_base_url(&ctx.base_url);
|
||||||
let headers = util::build_chatgpt_headers().await;
|
let headers = util::build_chatgpt_headers().await;
|
||||||
let environments = crate::env_detect::list_environments(&normalized, &headers).await?;
|
let environments =
|
||||||
|
crate::env_detect::list_environments(&ctx.environment_http, &normalized, &headers).await?;
|
||||||
if environments.is_empty() {
|
if environments.is_empty() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"no cloud environments are available for this workspace"
|
"no cloud environments are available for this workspace"
|
||||||
@@ -758,8 +773,11 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
|||||||
.try_init();
|
.try_init();
|
||||||
|
|
||||||
info!("Launching Cloud Tasks list UI");
|
info!("Launching Cloud Tasks list UI");
|
||||||
let BackendContext { backend, .. } = init_backend("codex_cloud_tasks_tui").await?;
|
let BackendContext {
|
||||||
let backend = backend;
|
backend,
|
||||||
|
base_url,
|
||||||
|
environment_http,
|
||||||
|
} = init_backend("codex_cloud_tasks_tui").await?;
|
||||||
|
|
||||||
// Terminal setup
|
// Terminal setup
|
||||||
use crossterm::ExecutableCommand;
|
use crossterm::ExecutableCommand;
|
||||||
@@ -839,34 +857,25 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Fetch environment list in parallel so the header can show friendly names quickly.
|
// Fetch environment list in parallel so the header can show friendly names quickly.
|
||||||
{
|
spawn_environment_load(tx.clone(), base_url.clone(), environment_http.clone());
|
||||||
let tx = tx.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
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()),
|
|
||||||
);
|
|
||||||
let headers = util::build_chatgpt_headers().await;
|
|
||||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
|
||||||
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to auto-detect a likely environment id on startup and refresh if found.
|
// Try to auto-detect a likely environment id on startup and refresh if found.
|
||||||
// Do this concurrently so the initial list shows quickly; on success we refetch with filter.
|
// Do this concurrently so the initial list shows quickly; on success we refetch with filter.
|
||||||
{
|
{
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
|
let base_url = base_url.clone();
|
||||||
|
let environment_http = environment_http.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let base_url = util::normalize_base_url(
|
let base_url = util::normalize_base_url(&base_url);
|
||||||
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
|
||||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
|
|
||||||
);
|
|
||||||
// Build headers: UA + ChatGPT auth if available
|
// Build headers: UA + ChatGPT auth if available
|
||||||
let headers = util::build_chatgpt_headers().await;
|
let headers = util::build_chatgpt_headers().await;
|
||||||
|
|
||||||
// Run autodetect. If it fails, we keep using "All".
|
// Run autodetect. If it fails, we keep using "All".
|
||||||
let res = crate::env_detect::autodetect_environment_id(
|
let res = crate::env_detect::autodetect_environment_id(
|
||||||
&base_url, &headers, /*desired_label*/ None,
|
&environment_http,
|
||||||
|
&base_url,
|
||||||
|
&headers,
|
||||||
|
/*desired_label*/ None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = tx.send(app::AppEvent::EnvironmentAutodetected(res));
|
let _ = tx.send(app::AppEvent::EnvironmentAutodetected(res));
|
||||||
@@ -1080,18 +1089,11 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
|||||||
}
|
}
|
||||||
// Proactively fetch environments to resolve a friendly name for the header.
|
// Proactively fetch environments to resolve a friendly name for the header.
|
||||||
app.env_loading = true;
|
app.env_loading = true;
|
||||||
{
|
spawn_environment_load(
|
||||||
let tx = tx.clone();
|
tx.clone(),
|
||||||
tokio::spawn(async move {
|
base_url.clone(),
|
||||||
let base_url = crate::util::normalize_base_url(
|
environment_http.clone(),
|
||||||
&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 _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let _ = frame_tx.send(Instant::now());
|
let _ = frame_tx.send(Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1467,13 +1469,11 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
|||||||
}
|
}
|
||||||
needs_redraw = true;
|
needs_redraw = true;
|
||||||
if should_fetch {
|
if should_fetch {
|
||||||
let tx = tx.clone();
|
spawn_environment_load(
|
||||||
tokio::spawn(async move {
|
tx.clone(),
|
||||||
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()));
|
base_url.clone(),
|
||||||
let headers = crate::util::build_chatgpt_headers().await;
|
environment_http.clone(),
|
||||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
);
|
||||||
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Render after opening env modal to show it instantly.
|
// Render after opening env modal to show it instantly.
|
||||||
render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?;
|
render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?;
|
||||||
@@ -1653,16 +1653,11 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
|||||||
if app.environments.is_empty() { app.env_loading = true; app.env_error = None; }
|
if app.environments.is_empty() { app.env_loading = true; app.env_error = None; }
|
||||||
needs_redraw = true;
|
needs_redraw = true;
|
||||||
if app.environments.is_empty() {
|
if app.environments.is_empty() {
|
||||||
let tx = tx.clone();
|
spawn_environment_load(
|
||||||
tokio::spawn(async move {
|
tx.clone(),
|
||||||
let base_url = crate::util::normalize_base_url(
|
base_url.clone(),
|
||||||
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
environment_http.clone(),
|
||||||
.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 _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
@@ -1832,13 +1827,11 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
|||||||
if should_fetch { app.env_loading = true; app.env_error = None; }
|
if should_fetch { app.env_loading = true; app.env_error = None; }
|
||||||
needs_redraw = true;
|
needs_redraw = true;
|
||||||
if should_fetch {
|
if should_fetch {
|
||||||
let tx = tx.clone();
|
spawn_environment_load(
|
||||||
tokio::spawn(async move {
|
tx.clone(),
|
||||||
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()));
|
base_url.clone(),
|
||||||
let headers = crate::util::build_chatgpt_headers().await;
|
environment_http.clone(),
|
||||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
);
|
||||||
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('n') => {
|
KeyCode::Char('n') => {
|
||||||
@@ -2020,6 +2013,19 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_environment_load(
|
||||||
|
tx: UnboundedSender<app::AppEvent>,
|
||||||
|
base_url: String,
|
||||||
|
http: RouteAwareClientPool,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let base_url = util::normalize_base_url(&base_url);
|
||||||
|
let headers = util::build_chatgpt_headers().await;
|
||||||
|
let result = crate::env_detect::list_environments(&http, &base_url, &headers).await;
|
||||||
|
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// extract_chatgpt_account_id moved to util.rs
|
// extract_chatgpt_account_id moved to util.rs
|
||||||
|
|
||||||
/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages.
|
/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use reqwest::header::HeaderMap;
|
use http::header::HeaderMap;
|
||||||
|
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_http_client::HttpClientFactory;
|
use codex_http_client::HttpClientFactory;
|
||||||
@@ -70,8 +70,8 @@ pub async fn load_auth_manager(
|
|||||||
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
|
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
|
||||||
/// and optional `ChatGPT-Account-Id`.
|
/// and optional `ChatGPT-Account-Id`.
|
||||||
pub async fn build_chatgpt_headers() -> HeaderMap {
|
pub async fn build_chatgpt_headers() -> HeaderMap {
|
||||||
use reqwest::header::HeaderValue;
|
use http::header::HeaderValue;
|
||||||
use reqwest::header::USER_AGENT;
|
use http::header::USER_AGENT;
|
||||||
|
|
||||||
set_user_agent_suffix("codex_cloud_tasks_tui");
|
set_user_agent_suffix("codex_cloud_tasks_tui");
|
||||||
let ua = codex_login::default_client::get_codex_user_agent();
|
let ua = codex_login::default_client::get_codex_user_agent();
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ deny = [
|
|||||||
"codex-api",
|
"codex-api",
|
||||||
"codex-app-server",
|
"codex-app-server",
|
||||||
"codex-app-server-daemon",
|
"codex-app-server-daemon",
|
||||||
"codex-cloud-tasks",
|
|
||||||
"codex-core",
|
"codex-core",
|
||||||
"codex-core-plugins",
|
"codex-core-plugins",
|
||||||
"codex-exec-server",
|
"codex-exec-server",
|
||||||
|
|||||||
Reference in new issue
Block a user