Skip to main content

codex_login/auth/
default_client.rs

1//! Default Codex HTTP client: shared `User-Agent`, `originator`, optional residency header, and
2//! `HttpClient` construction.
3//!
4//! Use [`crate::default_client`] or [`codex_login::default_client`] from other crates in this
5//! workspace.
6
7use codex_http_client::BuildRouteAwareHttpClientError;
8use codex_http_client::ClientRouteClass;
9use codex_http_client::HttpClient;
10use codex_http_client::HttpClientBuilder;
11use codex_http_client::HttpClientFactory;
12use codex_http_client::OutboundProxyPolicy;
13pub use codex_http_client::RequestBuilder as CodexRequestBuilder;
14use codex_terminal_detection::user_agent;
15use http::HeaderMap;
16use http::HeaderValue;
17use http::header::USER_AGENT;
18use std::sync::LazyLock;
19use std::sync::Mutex;
20use std::sync::RwLock;
21
22use crate::outbound_proxy::AuthRouteConfig;
23
24/// Set this to add a suffix to the User-Agent string.
25///
26/// It is not ideal that we're using a global singleton for this.
27/// This is primarily designed to differentiate MCP clients from each other.
28/// Because there can only be one MCP server per process, it should be safe for this to be a global static.
29/// However, future users of this should use this with caution as a result.
30/// In addition, we want to be confident that this value is used for ALL clients and doing that requires a
31/// lot of wiring and it's easy to miss code paths by doing so.
32/// See https://github.com/openai/codex/pull/3388/files for an example of what that would look like.
33/// Finally, we want to make sure this is set for ALL mcp clients without needing to know a special env var
34/// or having to set data that they already specified in the mcp initialize request somewhere else.
35///
36/// A space is automatically added between the suffix and the rest of the User-Agent string.
37/// The full user agent string is returned from the mcp initialize response.
38/// Parenthesis will be added by Codex. This should only specify what goes inside of the parenthesis.
39pub static USER_AGENT_SUFFIX: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
40pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
41pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE";
42pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency";
43
44pub use codex_config::ResidencyRequirement;
45
46#[derive(Debug, Clone)]
47pub struct Originator {
48    pub value: String,
49    pub header_value: HeaderValue,
50}
51static ORIGINATOR: LazyLock<RwLock<Option<Originator>>> = LazyLock::new(|| RwLock::new(None));
52static REQUIREMENTS_RESIDENCY: LazyLock<RwLock<Option<ResidencyRequirement>>> =
53    LazyLock::new(|| RwLock::new(None));
54static ROUTE_AWARE_CLIENT_BUILD_PERMIT: tokio::sync::Semaphore =
55    tokio::sync::Semaphore::const_new(1);
56
57#[derive(Debug)]
58pub enum SetOriginatorError {
59    InvalidHeaderValue,
60    AlreadyInitialized,
61}
62
63fn get_originator_value(provided: Option<String>) -> Originator {
64    let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR)
65        .ok()
66        .or(provided)
67        .unwrap_or(DEFAULT_ORIGINATOR.to_string());
68
69    match HeaderValue::from_str(&value) {
70        Ok(header_value) => Originator {
71            value,
72            header_value,
73        },
74        Err(e) => {
75            tracing::error!("Unable to turn originator override {value} into header value: {e}");
76            Originator {
77                value: DEFAULT_ORIGINATOR.to_string(),
78                header_value: HeaderValue::from_static(DEFAULT_ORIGINATOR),
79            }
80        }
81    }
82}
83
84pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> {
85    if HeaderValue::from_str(&value).is_err() {
86        return Err(SetOriginatorError::InvalidHeaderValue);
87    }
88    let originator = get_originator_value(Some(value));
89    let Ok(mut guard) = ORIGINATOR.write() else {
90        return Err(SetOriginatorError::AlreadyInitialized);
91    };
92    if guard.is_some() {
93        return Err(SetOriginatorError::AlreadyInitialized);
94    }
95    *guard = Some(originator);
96    Ok(())
97}
98
99pub fn set_default_client_residency_requirement(enforce_residency: Option<ResidencyRequirement>) {
100    let Ok(mut guard) = REQUIREMENTS_RESIDENCY.write() else {
101        tracing::warn!("Failed to acquire requirements residency lock");
102        return;
103    };
104    *guard = enforce_residency;
105}
106
107pub fn originator() -> Originator {
108    if let Ok(guard) = ORIGINATOR.read()
109        && let Some(originator) = guard.as_ref()
110    {
111        return originator.clone();
112    }
113
114    if std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).is_ok() {
115        let originator = get_originator_value(/*provided*/ None);
116        if let Ok(mut guard) = ORIGINATOR.write() {
117            match guard.as_ref() {
118                Some(originator) => return originator.clone(),
119                None => *guard = Some(originator.clone()),
120            }
121        }
122        return originator;
123    }
124
125    get_originator_value(/*provided*/ None)
126}
127
128/// Adds a valid, non-default thread originator override to request headers.
129///
130/// The default client already supplies the process originator. Thread-scoped callers should use
131/// this helper to override that value only when the thread originator differs.
132pub fn add_originator_header(headers: &mut HeaderMap, originator_value: &str) {
133    let default_originator = originator();
134    if originator_value == default_originator.value.as_str() {
135        return;
136    }
137
138    match HeaderValue::from_str(originator_value) {
139        Ok(header_value) => {
140            headers.insert("originator", header_value);
141        }
142        Err(err) => {
143            tracing::warn!("ignoring invalid thread originator header value: {err}");
144        }
145    }
146}
147
148pub fn is_first_party_originator(originator_value: &str) -> bool {
149    originator_value == DEFAULT_ORIGINATOR
150        || originator_value == "codex-tui"
151        || originator_value == "codex_vscode"
152        || originator_value.starts_with("Codex ")
153}
154
155pub fn is_first_party_chat_originator(originator_value: &str) -> bool {
156    originator_value == "codex_atlas" || originator_value == "codex_chatgpt_desktop"
157}
158
159pub fn get_codex_user_agent() -> String {
160    let build_version = env!("CARGO_PKG_VERSION");
161    let os_info = os_info::get();
162    let originator = originator();
163    let prefix = format!(
164        "{}/{build_version} ({} {}; {}) {}",
165        originator.value.as_str(),
166        os_info.os_type(),
167        os_info.version(),
168        os_info.architecture().unwrap_or("unknown"),
169        user_agent()
170    );
171    let suffix = USER_AGENT_SUFFIX
172        .lock()
173        .ok()
174        .and_then(|guard| guard.clone());
175    let suffix = suffix
176        .as_deref()
177        .map(str::trim)
178        .filter(|value| !value.is_empty())
179        .map_or_else(String::new, |value| format!(" ({value})"));
180
181    let candidate = format!("{prefix}{suffix}");
182    sanitize_user_agent(candidate, &prefix)
183}
184
185/// Sanitize the user agent string.
186///
187/// Invalid characters are replaced with an underscore.
188///
189/// If the user agent fails to parse, it falls back to fallback and then to ORIGINATOR.
190fn sanitize_user_agent(candidate: String, fallback: &str) -> String {
191    if HeaderValue::from_str(candidate.as_str()).is_ok() {
192        return candidate;
193    }
194
195    let sanitized: String = candidate
196        .chars()
197        .map(|ch| if matches!(ch, ' '..='~') { ch } else { '_' })
198        .collect();
199    if !sanitized.is_empty() && HeaderValue::from_str(sanitized.as_str()).is_ok() {
200        tracing::warn!(
201            "Sanitized Codex user agent because provided suffix contained invalid header characters"
202        );
203        sanitized
204    } else if HeaderValue::from_str(fallback).is_ok() {
205        tracing::warn!(
206            "Falling back to base Codex user agent because provided suffix could not be sanitized"
207        );
208        fallback.to_string()
209    } else {
210        tracing::warn!(
211            "Falling back to default Codex originator because base user agent string is invalid"
212        );
213        originator().value
214    }
215}
216
217/// Create an HTTP client with default `originator` and `User-Agent` headers set.
218///
219/// This supported default path preserves the transport's existing proxy behavior and does not opt into
220/// Codex's route-aware system/PAC resolution.
221pub fn create_client() -> HttpClient {
222    build_default_client(default_http_client_builder())
223}
224
225/// Create the default HTTP client without request URL or response-header diagnostics.
226///
227/// This preserves the default client's legacy custom-CA fallback and transport proxy behavior while
228/// avoiding diagnostics that could expose credentials embedded in request URLs or headers.
229pub fn create_client_without_request_logging() -> HttpClient {
230    build_default_client(default_http_client_builder().without_request_logging())
231}
232
233/// Builds the default Codex HTTP client for a concrete outbound route.
234///
235/// When route-aware proxy handling is disabled, or the client is running inside the Codex
236/// sandbox, this preserves the default client's existing proxy behavior. Otherwise it resolves
237/// the destination through the shared system/PAC-aware routing policy.
238pub fn create_client_for_route(
239    http_client_factory: &HttpClientFactory,
240    request_url: &str,
241    route_class: ClientRouteClass,
242) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
243    if matches!(
244        http_client_factory.outbound_proxy_policy(),
245        OutboundProxyPolicy::ReqwestDefault
246    ) {
247        return Ok(create_client());
248    }
249    if is_sandboxed() {
250        // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed
251        // separately through network-proxy.
252        return Ok(create_client());
253    }
254
255    default_http_client_builder().build_respecting_outbound_proxy_policy(
256        http_client_factory,
257        request_url,
258        route_class,
259    )
260}
261
262/// Builds the default Codex HTTP client for a concrete outbound route without blocking the
263/// async runtime worker that initiated the request.
264pub async fn create_client_for_route_async(
265    http_client_factory: HttpClientFactory,
266    request_url: String,
267    route_class: ClientRouteClass,
268) -> std::io::Result<HttpClient> {
269    let permit = ROUTE_AWARE_CLIENT_BUILD_PERMIT
270        .acquire()
271        .await
272        .map_err(std::io::Error::other)?;
273    tokio::task::spawn_blocking(move || {
274        let _permit = permit;
275        create_client_for_route(&http_client_factory, &request_url, route_class)
276            .map_err(std::io::Error::from)
277    })
278    .await
279    .map_err(std::io::Error::other)?
280}
281
282fn default_http_client_builder() -> HttpClientBuilder {
283    HttpClientBuilder::new()
284        .default_headers(default_headers())
285        .with_chatgpt_cloudflare_cookie_store()
286}
287
288// These legacy constructors intentionally preserve the infallible behavior of `create_client`.
289// New endpoint-aware call sites use `create_client_for_route` and propagate construction errors.
290#[allow(deprecated)]
291fn build_default_client(builder: HttpClientBuilder) -> HttpClient {
292    if is_sandboxed() {
293        builder.build_direct_with_custom_ca_fallback()
294    } else {
295        builder.build_with_transport_default_proxy_and_custom_ca_fallback()
296    }
297}
298
299/// Builds an HTTP client for an auth endpoint without Codex default headers.
300pub(crate) fn create_raw_auth_client(
301    endpoint: &str,
302    auth_route_config: Option<&AuthRouteConfig>,
303) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
304    auth_http_client_factory(auth_route_config)
305        .build_client_without_request_logging(endpoint, ClientRouteClass::Auth)
306}
307
308/// Builds the default Codex HTTP client wrapper for an auth endpoint.
309pub(crate) fn create_default_auth_client(
310    endpoint: &str,
311    auth_route_config: Option<&AuthRouteConfig>,
312) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
313    create_client_for_route(
314        &auth_http_client_factory(auth_route_config),
315        endpoint,
316        ClientRouteClass::Auth,
317    )
318}
319
320fn auth_http_client_factory(auth_route_config: Option<&AuthRouteConfig>) -> HttpClientFactory {
321    auth_route_config.map_or_else(
322        || HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
323        |config| config.http_client_factory().clone(),
324    )
325}
326
327pub fn default_headers() -> HeaderMap {
328    let mut headers = HeaderMap::new();
329    headers.insert("originator", originator().header_value);
330    if let Ok(user_agent) = HeaderValue::from_str(&get_codex_user_agent()) {
331        headers.insert(USER_AGENT, user_agent);
332    }
333    if let Ok(guard) = REQUIREMENTS_RESIDENCY.read()
334        && let Some(requirement) = guard.as_ref()
335        && !headers.contains_key(RESIDENCY_HEADER_NAME)
336    {
337        let value = match requirement {
338            ResidencyRequirement::Us => HeaderValue::from_static("us"),
339        };
340        headers.insert(RESIDENCY_HEADER_NAME, value);
341    }
342    headers
343}
344
345fn is_sandboxed() -> bool {
346    std::env::var("CODEX_SANDBOX").as_deref() == Ok("seatbelt")
347}
348
349#[cfg(test)]
350#[path = "default_client_tests.rs"]
351mod tests;