## Stacked PRs This work is now effectively split across two steps: - #14178: add custom CA support for browser and device-code login flows, docs, and hermetic subprocess tests - #14239: extend that shared custom CA handling across Codex HTTPS clients and secure websocket TLS Note: #14240 was merged into this branch while it was stacked on top of this PR. This PR now subsumes that websocket follow-up and should be treated as the combined change. Builds on top of #14178. ## Problem Custom CA support landed first in the login path, but the real requirement is broader. Codex constructs outbound TLS clients in multiple places, and both HTTPS and secure websocket paths can fail behind enterprise TLS interception if they do not honor `CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE` consistently. This PR broadens the shared custom-CA logic beyond login and applies the same policy to websocket TLS, so the enterprise-proxy story is no longer split between “HTTPS works” and “websockets still fail”. ## What This Delivers Custom CA support is no longer limited to login. Codex outbound HTTPS clients and secure websocket connections can now honor the same `CODEX_CA_CERTIFICATE` / `SSL_CERT_FILE` configuration, so enterprise proxy/intercept setups work more consistently end-to-end. For users and operators, nothing new needs to be configured beyond the same CA env vars introduced in #14178. The change is that more of Codex now respects them, including websocket-backed flows that were previously still using default trust roots. I also manually validated the proxy path locally with mitmproxy using: `CODEX_CA_CERTIFICATE=~/.mitmproxy/mitmproxy-ca-cert.pem HTTPS_PROXY=http://127.0.0.1:8080 just codex` with mitmproxy installed via `brew install mitmproxy` and configured as the macOS system proxy. ## Mental model `codex-client` is now the owner of shared custom-CA policy for outbound TLS client construction. Reqwest callers start from the builder configuration they already need, then pass that builder through `build_reqwest_client_with_custom_ca(...)`. Websocket callers ask the same module for a rustls client config when a custom CA bundle is configured. The env precedence is the same everywhere: - `CODEX_CA_CERTIFICATE` wins - otherwise fall back to `SSL_CERT_FILE` - otherwise use system roots The helper is intentionally narrow. It loads every usable certificate from the configured PEM bundle into the appropriate root store and returns either a configured transport or a typed error that explains what went wrong. ## Non-goals This does not add handshake-level integration tests against a live TLS endpoint. It does not validate that the configured bundle forms a meaningful certificate chain. It also does not try to force every transport in the repo through one abstraction; it extends the shared CA policy across the reqwest and websocket paths that actually needed it. ## Tradeoffs The main tradeoff is centralizing CA behavior in `codex-client` while still leaving adoption up to call sites. That keeps the implementation additive and reviewable, but it means the rule "outbound Codex TLS that should honor enterprise roots must use the shared helper" is still partly enforced socially rather than by types. For websockets, the shared helper only builds an explicit rustls config when a custom CA bundle is configured. When no override env var is set, websocket callers still use their ordinary default connector path. ## Architecture `codex-client::custom_ca` now owns CA bundle selection, PEM normalization, mixed-section parsing, certificate extraction, typed CA-loading errors, and optional rustls client-config construction for websocket TLS. The affected consumers now call into that shared helper directly rather than carrying login-local CA behavior: - backend-client - cloud-tasks - RMCP client paths that use `reqwest` - TUI voice HTTP paths - `codex-core` default reqwest client construction - `codex-api` websocket clients for both responses and realtime websocket connections The subprocess CA probe, env-sensitive integration tests, and shared PEM fixtures also live in `codex-client`, which is now the actual owner of the behavior they exercise. ## Observability The shared CA path logs: - which environment variable selected the bundle - which path was loaded - how many certificates were accepted - when `TRUSTED CERTIFICATE` labels were normalized - when CRLs were ignored - where client construction failed Returned errors remain user-facing and include the relevant env var, path, and remediation hint. That same error model now applies whether the failure surfaced while building a reqwest client or websocket TLS configuration. ## Tests Pure unit tests in `codex-client` cover env precedence and PEM normalization behavior. Real client construction remains in subprocess tests so the suite can control process env and avoid the macOS seatbelt panic path that motivated the hermetic test split. The subprocess coverage verifies: - `CODEX_CA_CERTIFICATE` precedence over `SSL_CERT_FILE` - fallback to `SSL_CERT_FILE` - single-cert and multi-cert bundles - malformed and empty-file errors - OpenSSL `TRUSTED CERTIFICATE` handling - CRL tolerance for well-formed CRL sections The websocket side is covered by the existing `codex-api` / `codex-core` websocket test suites plus the manual mitmproxy validation above. --------- Co-authored-by: Ivan Zakharchanka <3axap4eHko@gmail.com> Co-authored-by: Codex <noreply@openai.com>
362 lines
13 KiB
Rust
362 lines
13 KiB
Rust
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;
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
struct CodeEnvironment {
|
|
id: String,
|
|
#[serde(default)]
|
|
label: Option<String>,
|
|
#[serde(default)]
|
|
is_pinned: Option<bool>,
|
|
#[serde(default)]
|
|
task_count: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AutodetectSelection {
|
|
pub id: String,
|
|
pub label: Option<String>,
|
|
}
|
|
|
|
pub async fn autodetect_environment_id(
|
|
base_url: &str,
|
|
headers: &HeaderMap,
|
|
desired_label: Option<String>,
|
|
) -> anyhow::Result<AutodetectSelection> {
|
|
// 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:?}"));
|
|
let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new();
|
|
for origin in &origins {
|
|
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
|
let url = if base_url.contains("/backend-api") {
|
|
format!(
|
|
"{}/wham/environments/by-repo/{}/{}/{}",
|
|
base_url, "github", owner, repo
|
|
)
|
|
} else {
|
|
format!(
|
|
"{}/api/codex/environments/by-repo/{}/{}/{}",
|
|
base_url, "github", owner, repo
|
|
)
|
|
};
|
|
crate::append_error_log(format!("env: GET {url}"));
|
|
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
|
|
Ok(mut list) => {
|
|
crate::append_error_log(format!(
|
|
"env: by-repo returned {} env(s) for {owner}/{repo}",
|
|
list.len(),
|
|
));
|
|
by_repo_envs.append(&mut list);
|
|
}
|
|
Err(e) => crate::append_error_log(format!(
|
|
"env: by-repo fetch failed for {owner}/{repo}: {e}"
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) {
|
|
return Ok(AutodetectSelection {
|
|
id: env.id.clone(),
|
|
label: env.label.as_deref().map(str::to_owned),
|
|
});
|
|
}
|
|
|
|
// 2) Fallback to the full list
|
|
let list_url = if base_url.contains("/backend-api") {
|
|
format!("{base_url}/wham/environments")
|
|
} else {
|
|
format!("{base_url}/api/codex/environments")
|
|
};
|
|
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 res = http.get(&list_url).headers(headers.clone()).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();
|
|
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
|
match serde_json::from_str::<serde_json::Value>(&body) {
|
|
Ok(v) => {
|
|
let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone());
|
|
crate::append_error_log(format!("env: /environments JSON (pretty):\n{pretty}"));
|
|
}
|
|
Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{body}")),
|
|
}
|
|
if !status.is_success() {
|
|
anyhow::bail!("GET {list_url} failed: {status}; content-type={ct}; body={body}");
|
|
}
|
|
let all_envs: Vec<CodeEnvironment> = serde_json::from_str(&body).map_err(|e| {
|
|
anyhow::anyhow!("Decode error for {list_url}: {e}; content-type={ct}; body={body}")
|
|
})?;
|
|
if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) {
|
|
return Ok(AutodetectSelection {
|
|
id: env.id.clone(),
|
|
label: env.label.as_deref().map(str::to_owned),
|
|
});
|
|
}
|
|
anyhow::bail!("no environments available")
|
|
}
|
|
|
|
fn pick_environment_row(
|
|
envs: &[CodeEnvironment],
|
|
desired_label: Option<&str>,
|
|
) -> Option<CodeEnvironment> {
|
|
if envs.is_empty() {
|
|
return None;
|
|
}
|
|
if let Some(label) = desired_label {
|
|
let lc = label.to_lowercase();
|
|
if let Some(e) = envs
|
|
.iter()
|
|
.find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc)
|
|
{
|
|
crate::append_error_log(format!("env: matched by label: {label} -> {}", e.id));
|
|
return Some(e.clone());
|
|
}
|
|
}
|
|
if envs.len() == 1 {
|
|
crate::append_error_log("env: single environment available; selecting it");
|
|
return Some(envs[0].clone());
|
|
}
|
|
if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) {
|
|
crate::append_error_log(format!("env: selecting pinned environment: {}", e.id));
|
|
return Some(e.clone());
|
|
}
|
|
// Highest task_count as heuristic
|
|
if let Some(e) = envs
|
|
.iter()
|
|
.max_by_key(|e| e.task_count.unwrap_or(0))
|
|
.or_else(|| envs.first())
|
|
{
|
|
crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id));
|
|
return Some(e.clone());
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn get_json<T: serde::de::DeserializeOwned>(
|
|
url: &str,
|
|
headers: &HeaderMap,
|
|
) -> anyhow::Result<T> {
|
|
let http = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
|
|
let res = http.get(url).headers(headers.clone()).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();
|
|
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
|
if !status.is_success() {
|
|
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
|
|
}
|
|
let parsed = serde_json::from_str::<T>(&body).map_err(|e| {
|
|
anyhow::anyhow!("Decode error for {url}: {e}; content-type={ct}; body={body}")
|
|
})?;
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn get_git_origins() -> Vec<String> {
|
|
// Prefer: git config --get-regexp remote\..*\.url
|
|
let out = std::process::Command::new("git")
|
|
.args(["config", "--get-regexp", "remote\\..*\\.url"])
|
|
.output();
|
|
if let Ok(ok) = out
|
|
&& ok.status.success()
|
|
{
|
|
let s = String::from_utf8_lossy(&ok.stdout);
|
|
let mut urls = Vec::new();
|
|
for line in s.lines() {
|
|
if let Some((_, url)) = line.split_once(' ') {
|
|
urls.push(url.trim().to_string());
|
|
}
|
|
}
|
|
if !urls.is_empty() {
|
|
return uniq(urls);
|
|
}
|
|
}
|
|
// Fallback: git remote -v
|
|
let out = std::process::Command::new("git")
|
|
.args(["remote", "-v"])
|
|
.output();
|
|
if let Ok(ok) = out
|
|
&& ok.status.success()
|
|
{
|
|
let s = String::from_utf8_lossy(&ok.stdout);
|
|
let mut urls = Vec::new();
|
|
for line in s.lines() {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 2 {
|
|
urls.push(parts[1].to_string());
|
|
}
|
|
}
|
|
if !urls.is_empty() {
|
|
return uniq(urls);
|
|
}
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
fn uniq(mut v: Vec<String>) -> Vec<String> {
|
|
v.sort();
|
|
v.dedup();
|
|
v
|
|
}
|
|
|
|
fn parse_owner_repo(url: &str) -> Option<(String, String)> {
|
|
// Normalize common prefixes and handle multiple SSH/HTTPS variants.
|
|
let mut s = url.trim().to_string();
|
|
// Drop protocol scheme for ssh URLs
|
|
if let Some(rest) = s.strip_prefix("ssh://") {
|
|
s = rest.to_string();
|
|
}
|
|
// Accept any user before @github.com (e.g., git@, org-123@)
|
|
if let Some(idx) = s.find("@github.com:") {
|
|
let rest = &s[idx + "@github.com:".len()..];
|
|
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
|
|
let mut parts = rest.splitn(2, '/');
|
|
let owner = parts.next()?.to_string();
|
|
let repo = parts.next()?.to_string();
|
|
crate::append_error_log(format!("env: parsed SSH GitHub origin => {owner}/{repo}"));
|
|
return Some((owner, repo));
|
|
}
|
|
// HTTPS or git protocol
|
|
for prefix in [
|
|
"https://github.com/",
|
|
"http://github.com/",
|
|
"git://github.com/",
|
|
"github.com/",
|
|
] {
|
|
if let Some(rest) = s.strip_prefix(prefix) {
|
|
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
|
|
let mut parts = rest.splitn(2, '/');
|
|
let owner = parts.next()?.to_string();
|
|
let repo = parts.next()?.to_string();
|
|
crate::append_error_log(format!("env: parsed HTTP GitHub origin => {owner}/{repo}"));
|
|
return Some((owner, repo));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// 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.
|
|
pub async fn list_environments(
|
|
base_url: &str,
|
|
headers: &HeaderMap,
|
|
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
|
|
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
|
|
|
|
// 1) By-repo lookup for each parsed GitHub origin
|
|
let origins = get_git_origins();
|
|
for origin in &origins {
|
|
if let Some((owner, repo)) = parse_owner_repo(origin) {
|
|
let url = if base_url.contains("/backend-api") {
|
|
format!(
|
|
"{}/wham/environments/by-repo/{}/{}/{}",
|
|
base_url, "github", owner, repo
|
|
)
|
|
} else {
|
|
format!(
|
|
"{}/api/codex/environments/by-repo/{}/{}/{}",
|
|
base_url, "github", owner, repo
|
|
)
|
|
};
|
|
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
|
|
Ok(list) => {
|
|
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
|
|
for e in list {
|
|
let entry =
|
|
map.entry(e.id.clone())
|
|
.or_insert_with(|| crate::app::EnvironmentRow {
|
|
id: e.id.clone(),
|
|
label: e.label.clone(),
|
|
is_pinned: e.is_pinned.unwrap_or(false),
|
|
repo_hints: Some(format!("{owner}/{repo}")),
|
|
});
|
|
// Merge: keep label if present, or use new; accumulate pinned flag
|
|
if entry.label.is_none() {
|
|
entry.label = e.label.clone();
|
|
}
|
|
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
|
|
if entry.repo_hints.is_none() {
|
|
entry.repo_hints = Some(format!("{owner}/{repo}"));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
"env_tui: by-repo fetch failed for {}/{}: {}",
|
|
owner, repo, e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) Fallback to the full list; on error return what we have if any.
|
|
let list_url = if base_url.contains("/backend-api") {
|
|
format!("{base_url}/wham/environments")
|
|
} else {
|
|
format!("{base_url}/api/codex/environments")
|
|
};
|
|
match get_json::<Vec<CodeEnvironment>>(&list_url, headers).await {
|
|
Ok(list) => {
|
|
info!("env_tui: global list -> {} envs", list.len());
|
|
for e in list {
|
|
let entry = map
|
|
.entry(e.id.clone())
|
|
.or_insert_with(|| crate::app::EnvironmentRow {
|
|
id: e.id.clone(),
|
|
label: e.label.clone(),
|
|
is_pinned: e.is_pinned.unwrap_or(false),
|
|
repo_hints: None,
|
|
});
|
|
if entry.label.is_none() {
|
|
entry.label = e.label.clone();
|
|
}
|
|
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if map.is_empty() {
|
|
return Err(e);
|
|
} else {
|
|
warn!(
|
|
"env_tui: global list failed; using by-repo results only: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut rows: Vec<crate::app::EnvironmentRow> = map.into_values().collect();
|
|
rows.sort_by(|a, b| {
|
|
// pinned first
|
|
let p = b.is_pinned.cmp(&a.is_pinned);
|
|
if p != std::cmp::Ordering::Equal {
|
|
return p;
|
|
}
|
|
// then label (ci), then id
|
|
let al = a.label.as_deref().unwrap_or("").to_lowercase();
|
|
let bl = b.label.as_deref().unwrap_or("").to_lowercase();
|
|
let l = al.cmp(&bl);
|
|
if l != std::cmp::Ordering::Equal {
|
|
return l;
|
|
}
|
|
a.id.cmp(&b.id)
|
|
});
|
|
Ok(rows)
|
|
}
|