[codex] cover standalone CLI HTTP requests [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-06-03 23:44:25 +00:00
parent d766866d3c
commit 041e3a7b02
9 changed files with 219 additions and 40 deletions
+3
View File
@@ -2268,6 +2268,7 @@ dependencies = [
"codex-execpolicy",
"codex-features",
"codex-git-utils",
"codex-http-state",
"codex-install-context",
"codex-login",
"codex-mcp",
@@ -2379,11 +2380,13 @@ dependencies = [
"async-trait",
"chrono",
"clap",
"codex-api",
"codex-client",
"codex-cloud-tasks-client",
"codex-cloud-tasks-mock-client",
"codex-core",
"codex-git-utils",
"codex-http-state",
"codex-login",
"codex-model-provider",
"codex-tui",
+1
View File
@@ -39,6 +39,7 @@ codex-execpolicy = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-install-context = { workspace = true }
codex-http-state = { workspace = true }
codex-login = { workspace = true }
codex-memories-write = { workspace = true }
codex-mcp = { workspace = true }
+14 -1
View File
@@ -39,6 +39,8 @@ use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_features::FEATURES;
use codex_http_state::HttpStateContext;
use codex_http_state::HttpStateSurface;
use codex_install_context::CodexPackageLayout;
use codex_install_context::InstallContext;
use codex_install_context::InstallMethod;
@@ -53,6 +55,7 @@ use codex_login::default_client::build_reqwest_client;
use codex_login::default_client::default_headers;
use codex_login::load_auth_dot_json;
use codex_model_provider::create_model_provider;
use codex_model_provider::with_native_integrity_state;
use codex_protocol::protocol::AskForApproval;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
@@ -2261,7 +2264,9 @@ async fn websocket_reachability_check(
));
let runtime_provider = create_model_provider(provider.clone(), auth_manager);
let auth = runtime_provider.auth().await;
let auth = runtime_provider
.auth_for_surface(HttpStateSurface::CodexCli)
.await;
details.push(format!(
"auth mode: {}",
auth.as_ref().map(auth_mode_name).unwrap_or("none")
@@ -2311,6 +2316,14 @@ async fn websocket_reachability_check(
OPENAI_BETA_HEADER,
HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE),
);
let api_auth = with_native_integrity_state(
api_auth,
auth.as_ref(),
Some(HttpStateContext::new(
config.codex_home.to_path_buf(),
HttpStateSurface::CodexCli,
)),
);
let client = ResponsesWebsocketClient::new(api_provider, api_auth);
match tokio::time::timeout(
provider.websocket_connect_timeout(),
+21 -4
View File
@@ -1642,7 +1642,7 @@ async fn load_exec_server_remote_auth_provider(
let auth =
CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url))
.await?;
return Ok(codex_model_provider::auth_provider_from_auth(&auth));
return Ok(exec_server_remote_auth_provider(config, &auth));
}
let auth = load_exec_server_remote_auth(
@@ -1661,7 +1661,21 @@ async fn load_exec_server_remote_auth_provider(
validate_api_key_remote_host(base_url)?;
}
Ok(codex_model_provider::auth_provider_from_auth(&auth))
Ok(exec_server_remote_auth_provider(config, &auth))
}
fn exec_server_remote_auth_provider(
config: &codex_core::config::Config,
auth: &CodexAuth,
) -> codex_api::SharedAuthProvider {
codex_model_provider::with_native_integrity_state(
codex_model_provider::auth_provider_from_auth(auth),
Some(auth),
Some(codex_http_state::HttpStateContext::new(
config.codex_home.to_path_buf(),
codex_http_state::HttpStateSurface::CodexExec,
)),
)
}
fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool {
@@ -1723,12 +1737,15 @@ async fn load_exec_server_remote_auth(
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
let auth = match auth_manager.auth().await {
let auth = match auth_manager
.auth_for_surface(codex_http_state::HttpStateSurface::CodexExec)
.await
{
Some(auth) => auth,
None => {
auth_manager.reload().await;
auth_manager
.auth()
.auth_for_surface(codex_http_state::HttpStateSurface::CodexExec)
.await
.ok_or_else(|| anyhow::anyhow!(missing_auth_error))?
}
+2
View File
@@ -16,12 +16,14 @@ workspace = true
anyhow = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-api = { workspace = true }
codex-client = { workspace = true }
codex-cloud-tasks-client = { workspace = true }
# TODO: codex-cloud-tasks-mock-client should be in dev-dependencies.
codex-cloud-tasks-mock-client = { workspace = true }
codex-core = { workspace = true }
codex-git-utils = { workspace = true }
codex-http-state = { workspace = true }
codex-login = { path = "../login" }
codex-model-provider = { workspace = true }
codex-tui = { workspace = true }
+10 -7
View File
@@ -1,6 +1,5 @@
use codex_client::build_reqwest_client_with_custom_ca;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use std::collections::HashMap;
use tracing::info;
use tracing::warn;
@@ -24,7 +23,7 @@ pub struct AutodetectSelection {
pub async fn autodetect_environment_id(
base_url: &str,
headers: &HeaderMap,
request_auth: &crate::util::ChatGptRequestAuth,
desired_label: Option<String>,
) -> anyhow::Result<AutodetectSelection> {
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode)
@@ -45,7 +44,7 @@ pub async fn autodetect_environment_id(
)
};
crate::append_error_log(format!("env: GET {url}"));
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
match get_json::<Vec<CodeEnvironment>>(&url, request_auth).await {
Ok(mut list) => {
crate::append_error_log(format!(
"env: by-repo returned {} env(s) for {owner}/{repo}",
@@ -75,7 +74,9 @@ pub async fn autodetect_environment_id(
crate::append_error_log(format!("env: GET {list_url}"));
// Fetch and log the full environments JSON for debugging
let http = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let headers = request_auth.headers_for_url(&list_url);
let res = http.get(&list_url).headers(headers.clone()).send().await?;
request_auth.observe_response_headers(&list_url, &headers, res.headers());
let status = res.status();
let ct = res
.headers()
@@ -146,10 +147,12 @@ fn pick_environment_row(
async fn get_json<T: serde::de::DeserializeOwned>(
url: &str,
headers: &HeaderMap,
request_auth: &crate::util::ChatGptRequestAuth,
) -> anyhow::Result<T> {
let http = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let headers = request_auth.headers_for_url(url);
let res = http.get(url).headers(headers.clone()).send().await?;
request_auth.observe_response_headers(url, &headers, res.headers());
let status = res.status();
let ct = res
.headers()
@@ -255,7 +258,7 @@ fn parse_owner_repo(url: &str) -> Option<(String, String)> {
/// Returns a de-duplicated, sorted set suitable for the TUI modal.
pub async fn list_environments(
base_url: &str,
headers: &HeaderMap,
request_auth: &crate::util::ChatGptRequestAuth,
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
@@ -274,7 +277,7 @@ pub async fn list_environments(
base_url, "github", owner, repo
)
};
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
match get_json::<Vec<CodeEnvironment>>(&url, request_auth).await {
Ok(list) => {
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
for e in list {
@@ -312,7 +315,7 @@ pub async fn list_environments(
} else {
format!("{base_url}/api/codex/environments")
};
match get_json::<Vec<CodeEnvironment>>(&list_url, headers).await {
match get_json::<Vec<CodeEnvironment>>(&list_url, request_auth).await {
Ok(list) => {
info!("env_tui: global list -> {} envs", list.len());
for e in list {
+23 -9
View File
@@ -70,7 +70,11 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
let auth_manager = util::load_auth_manager(Some(base_url.clone())).await;
let auth = match auth_manager.as_ref() {
Some(manager) => manager.auth().await,
Some(manager) => {
manager
.auth_for_surface(codex_http_state::HttpStateSurface::CodexCli)
.await
}
None => None,
};
let auth = match auth {
@@ -94,7 +98,17 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
std::process::exit(1);
}
let auth_provider = codex_model_provider::auth_provider_from_auth(&auth);
let http_state = auth_manager.as_ref().map(|manager| {
codex_http_state::HttpStateContext::new(
manager.codex_home().to_path_buf(),
codex_http_state::HttpStateSurface::CodexCli,
)
});
let auth_provider = codex_model_provider::with_native_integrity_state(
codex_model_provider::auth_provider_from_auth(&auth),
Some(&auth),
http_state,
);
http = http.with_auth_provider(auth_provider);
if let Some(acc) = auth.get_account_id() {
append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}"));
@@ -185,7 +199,7 @@ async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow
return Err(anyhow!("environment id must not be empty"));
}
let normalized = util::normalize_base_url(&ctx.base_url);
let headers = util::build_chatgpt_headers().await;
let headers = util::build_chatgpt_request_auth().await;
let environments = crate::env_detect::list_environments(&normalized, &headers).await?;
if environments.is_empty() {
return Err(anyhow!(
@@ -841,7 +855,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
&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 headers = util::build_chatgpt_request_auth().await;
let res = crate::env_detect::list_environments(&base_url, &headers).await;
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
});
@@ -857,7 +871,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
);
// Build headers: UA + ChatGPT auth if available
let headers = util::build_chatgpt_headers().await;
let headers = util::build_chatgpt_request_auth().await;
// Run autodetect. If it fails, we keep using "All".
let res = crate::env_detect::autodetect_environment_id(
@@ -1082,7 +1096,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
&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 headers = crate::util::build_chatgpt_request_auth().await;
let res = crate::env_detect::list_environments(&base_url, &headers).await;
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
});
@@ -1465,7 +1479,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
let tx = 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 headers = crate::util::build_chatgpt_request_auth().await;
let res = crate::env_detect::list_environments(&base_url, &headers).await;
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
});
@@ -1654,7 +1668,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
&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 headers = crate::util::build_chatgpt_request_auth().await;
let res = crate::env_detect::list_environments(&base_url, &headers).await;
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
});
@@ -1830,7 +1844,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
let tx = 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 headers = crate::util::build_chatgpt_request_auth().await;
let res = crate::env_detect::list_environments(&base_url, &headers).await;
let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res));
});
+51 -14
View File
@@ -1,7 +1,12 @@
use chrono::DateTime;
use chrono::Local;
use chrono::Utc;
use codex_api::SharedAuthProvider;
use codex_http_state::HttpStateContext;
use codex_http_state::HttpStateSurface;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
use codex_core::config::Config;
use codex_login::AuthManager;
@@ -55,26 +60,58 @@ pub async fn load_auth_manager(chatgpt_base_url: Option<String>) -> Option<AuthM
)
}
/// 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::HeaderValue;
use reqwest::header::USER_AGENT;
#[derive(Clone)]
pub struct ChatGptRequestAuth {
user_agent: HeaderValue,
auth_provider: Option<SharedAuthProvider>,
}
impl ChatGptRequestAuth {
pub fn headers_for_url(&self, request_url: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, self.user_agent.clone());
if let Some(auth_provider) = &self.auth_provider {
auth_provider.add_auth_headers_for_url(request_url, &mut headers);
}
headers
}
pub fn observe_response_headers(
&self,
request_url: &str,
request_headers: &HeaderMap,
response_headers: &HeaderMap,
) {
if let Some(auth_provider) = &self.auth_provider {
auth_provider.observe_response_headers(request_url, request_headers, response_headers);
}
}
}
/// Build request auth for ChatGPT-backed cloud-task requests.
pub async fn build_chatgpt_request_auth() -> ChatGptRequestAuth {
set_user_agent_suffix("codex_cloud_tasks_tui");
let ua = codex_login::default_client::get_codex_user_agent();
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await
&& let Some(auth) = am.auth().await
let auth_manager = load_auth_manager(/*chatgpt_base_url*/ None).await;
let auth_provider = if let Some(am) = auth_manager
&& let Some(auth) = am.auth_for_surface(HttpStateSurface::CodexCli).await
&& auth.uses_codex_backend()
{
headers.extend(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers());
Some(codex_model_provider::with_native_integrity_state(
codex_model_provider::auth_provider_from_auth(&auth),
Some(&auth),
Some(HttpStateContext::new(
am.codex_home().to_path_buf(),
HttpStateSurface::CodexCli,
)),
))
} else {
None
};
ChatGptRequestAuth {
user_agent: HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
auth_provider,
}
headers
}
/// Construct a browser-friendly task URL for the given backend base URL.
+94 -5
View File
@@ -2,6 +2,7 @@ use std::time::Duration;
use codex_api::SharedAuthProvider;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde::Deserialize;
use tokio::time::sleep;
use tokio_tungstenite::connect_async;
@@ -48,15 +49,24 @@ impl EnvironmentRegistryClient {
&self,
environment_id: &str,
) -> Result<EnvironmentRegistryRegistrationResponse, ExecServerError> {
let request_url = endpoint_url(
&self.base_url,
&format!("/cloud/environment/{environment_id}/register"),
);
let mut request_headers = HeaderMap::new();
self.auth_provider
.add_auth_headers_for_url(&request_url, &mut request_headers);
let response = self
.http
.post(endpoint_url(
&self.base_url,
&format!("/cloud/environment/{environment_id}/register"),
))
.headers(self.auth_provider.to_auth_headers())
.post(&request_url)
.headers(request_headers.clone())
.send()
.await?;
self.auth_provider.observe_response_headers(
&request_url,
&request_headers,
response.headers(),
);
self.parse_json_response(response).await
}
@@ -244,6 +254,7 @@ fn preview_error_body(body: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use codex_api::AuthProvider;
use http::HeaderMap;
@@ -278,6 +289,43 @@ mod tests {
Arc::new(StaticRegistryAuthProvider)
}
#[derive(Debug, Default)]
struct RecordingRegistryAuthProvider {
observed_updates: StdMutex<Vec<(String, String, String)>>,
}
impl AuthProvider for RecordingRegistryAuthProvider {
fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
fn add_auth_headers_for_url(&self, _request_url: &str, headers: &mut HeaderMap) {
let _ = headers.insert("x-test-state", HeaderValue::from_static("sent-state"));
}
fn observe_response_headers(
&self,
request_url: &str,
request_headers: &HeaderMap,
response_headers: &HeaderMap,
) {
let sent_state = request_headers
.get("x-test-state")
.and_then(|value| value.to_str().ok());
let update_state = response_headers
.get("x-test-state-update")
.and_then(|value| value.to_str().ok());
if let (Some(sent_state), Some(update_state)) = (sent_state, update_state) {
self.observed_updates
.lock()
.expect("observed updates lock should not be poisoned")
.push((
request_url.to_string(),
sent_state.to_string(),
update_state.to_string(),
));
}
}
}
#[tokio::test]
async fn register_environment_posts_with_auth_provider_headers() {
let server = MockServer::start().await;
@@ -349,6 +397,47 @@ mod tests {
));
}
#[tokio::test]
async fn register_environment_uses_url_scoped_auth_and_observes_response_headers() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/cloud/environment/environment-requested/register"))
.and(header("x-test-state", "sent-state"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("x-test-state-update", "next-state")
.set_body_json(serde_json::json!({
"environment_id": "env-1",
"url": "wss://rendezvous.test/ws"
})),
)
.mount(&server)
.await;
let auth = Arc::new(RecordingRegistryAuthProvider::default());
let client =
EnvironmentRegistryClient::new(server.uri(), auth.clone()).expect("registry client");
client
.register_environment("environment-requested")
.await
.expect("register environment");
assert_eq!(
*auth
.observed_updates
.lock()
.expect("observed updates lock should not be poisoned"),
vec![(
format!(
"{}/cloud/environment/environment-requested/register",
server.uri()
),
"sent-state".to_string(),
"next-state".to_string(),
)]
);
}
#[test]
fn debug_output_redacts_auth_provider() {
let config = RemoteEnvironmentConfig::new(