mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
## Why Executor-owned HTTP MCP servers need to read their bearer credentials from the selected executor environment instead of the host process. ## What changed - Preserve `bearer_token_env_var` for executor-owned HTTP MCP configurations and resolve it when the executor sends each request. - Extend delegated HTTP headers with executor-local environment references while rejecting missing, empty, or protected credential variables. - Keep transport-provided bearer authentication compatible with MCP redirect and OAuth handling without sending a placeholder authorization value. ## Testing - Cover authenticated executor-owned MCP requests end to end. - Cover delegated header resolution and rejection of protected variables. - Cover parsing executor-owned bearer configuration and transport-provided bearer behavior. GitOrigin-RevId: 442bf7382198ffb69eba797eb36d4c74faabda88
379 lines
14 KiB
Rust
379 lines
14 KiB
Rust
//! Route-aware local HTTP capability implementation.
|
|
//!
|
|
//! This code runs wherever the real network request should originate:
|
|
//! - in a local environment, that means the orchestrator process
|
|
//! - in a remote environment, that means the remote runtime after the
|
|
//! orchestrator has forwarded `http/request` over JSON-RPC
|
|
|
|
use std::time::Duration;
|
|
|
|
use codex_exec_server_protocol::JSONRPCErrorError;
|
|
use codex_http_client::ClientRouteClass;
|
|
use codex_http_client::HttpClientFactory;
|
|
use codex_http_client::RouteAwareClientPool;
|
|
use codex_http_client::RouteAwareRequestError;
|
|
use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR;
|
|
use codex_protocol::shell_environment::OPENAI_FEDERATION_RULE_ID_ENV_VAR;
|
|
use codex_protocol::shell_environment::OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR;
|
|
use codex_protocol::shell_environment::OPENAI_WORKLOAD_IDENTITY_CONTEXT_ENV_VAR;
|
|
use futures::FutureExt;
|
|
use futures::StreamExt;
|
|
use futures::future::BoxFuture;
|
|
use http::HeaderMap;
|
|
use http::HeaderName;
|
|
use http::HeaderValue;
|
|
use http::Method;
|
|
use tracing::Instrument;
|
|
use url::Url;
|
|
|
|
use super::HttpResponseBodyStream;
|
|
use super::response_body_stream::send_body_delta;
|
|
use crate::HttpClient;
|
|
use crate::client::ExecServerError;
|
|
use crate::protocol::HttpHeader;
|
|
use crate::protocol::HttpRedirectPolicy;
|
|
use crate::protocol::HttpRequestBodyDeltaNotification;
|
|
use crate::protocol::HttpRequestParams;
|
|
use crate::protocol::HttpRequestResponse;
|
|
use crate::protocol::MAX_HTTP_BODY_DELTA_BYTES;
|
|
use crate::rpc::RpcNotificationSender;
|
|
use crate::rpc::internal_error;
|
|
use crate::rpc::invalid_params;
|
|
|
|
const HTTP_HEADER_ENV_DENYLIST: &[&str] = &[
|
|
CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR,
|
|
OPENAI_FEDERATION_RULE_ID_ENV_VAR,
|
|
OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR,
|
|
OPENAI_WORKLOAD_IDENTITY_CONTEXT_ENV_VAR,
|
|
"OPENAI_API_KEY",
|
|
"CODEX_API_KEY",
|
|
"CODEX_ACCESS_TOKEN",
|
|
"CODEX_CONNECTORS_TOKEN",
|
|
"AWS_ACCESS_KEY_ID",
|
|
"AWS_SECRET_ACCESS_KEY",
|
|
"AWS_SESSION_TOKEN",
|
|
"AZURE_CLIENT_SECRET",
|
|
"AZURE_FEDERATED_TOKEN_FILE",
|
|
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
];
|
|
|
|
/// HTTP capability implementation backed by the shared route-aware transport.
|
|
#[derive(Clone)]
|
|
pub struct RouteAwareHttpClient {
|
|
follow_redirects: RouteAwareClientPool,
|
|
stop_redirects: RouteAwareClientPool,
|
|
}
|
|
|
|
/// Streaming response state held between the initial HTTP response and
|
|
/// downstream body-delta forwarding.
|
|
pub(crate) struct PendingRouteAwareHttpBodyStream {
|
|
pub(crate) request_id: String,
|
|
pub(crate) response: codex_http_client::HttpResponse,
|
|
}
|
|
|
|
/// Validates `http/request` parameters and runs the actual HTTP call used
|
|
/// by the exec-server route and the local [`HttpClient`] backend.
|
|
pub(crate) struct RouteAwareHttpRequestRunner {
|
|
client: RouteAwareClientPool,
|
|
}
|
|
|
|
impl RouteAwareHttpClient {
|
|
pub fn new(http_client_factory: HttpClientFactory) -> Self {
|
|
Self {
|
|
follow_redirects: RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
|
|
http_client_factory.clone(),
|
|
// Delegated HTTP targets arbitrary endpoints; route class only labels diagnostics.
|
|
ClientRouteClass::Other,
|
|
),
|
|
stop_redirects:
|
|
RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_redirects_or_request_logging(
|
|
http_client_factory,
|
|
// Proxy routing comes from the factory, not this diagnostic-only route class.
|
|
ClientRouteClass::Other,
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Enables narrowly scoped TLS-backend fallback for both redirect policies.
|
|
pub fn with_tls_backend_fallback(mut self) -> Self {
|
|
self.follow_redirects = self.follow_redirects.with_tls_backend_fallback();
|
|
self.stop_redirects = self.stop_redirects.with_tls_backend_fallback();
|
|
self
|
|
}
|
|
|
|
pub(crate) fn runner(
|
|
&self,
|
|
redirect_policy: HttpRedirectPolicy,
|
|
) -> RouteAwareHttpRequestRunner {
|
|
let client = match redirect_policy {
|
|
HttpRedirectPolicy::Follow => self.follow_redirects.clone(),
|
|
HttpRedirectPolicy::Stop => self.stop_redirects.clone(),
|
|
};
|
|
RouteAwareHttpRequestRunner { client }
|
|
}
|
|
}
|
|
|
|
impl HttpClient for RouteAwareHttpClient {
|
|
fn http_request(
|
|
&self,
|
|
params: HttpRequestParams,
|
|
) -> BoxFuture<'_, Result<HttpRequestResponse, ExecServerError>> {
|
|
async move {
|
|
let runner = self.runner(params.redirect_policy);
|
|
let (response, _) = runner
|
|
.run(HttpRequestParams {
|
|
stream_response: false,
|
|
..params
|
|
})
|
|
.await
|
|
.map_err(|error| ExecServerError::HttpRequest(error.message))?;
|
|
Ok(response)
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn http_request_stream(
|
|
&self,
|
|
params: HttpRequestParams,
|
|
) -> BoxFuture<'_, Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError>> {
|
|
async move {
|
|
let runner = self.runner(params.redirect_policy);
|
|
let (response, pending_stream) = runner
|
|
.run(HttpRequestParams {
|
|
stream_response: true,
|
|
..params
|
|
})
|
|
.await
|
|
.map_err(|error| ExecServerError::HttpRequest(error.message))?;
|
|
let pending_stream = pending_stream.ok_or_else(|| {
|
|
ExecServerError::Protocol(
|
|
"http request stream did not return a response body stream".to_string(),
|
|
)
|
|
})?;
|
|
Ok((
|
|
response,
|
|
HttpResponseBodyStream::local(pending_stream.response),
|
|
))
|
|
}
|
|
.boxed()
|
|
}
|
|
}
|
|
|
|
impl RouteAwareHttpRequestRunner {
|
|
pub(crate) async fn run(
|
|
&self,
|
|
params: HttpRequestParams,
|
|
) -> Result<(HttpRequestResponse, Option<PendingRouteAwareHttpBodyStream>), JSONRPCErrorError>
|
|
{
|
|
let method = Method::from_bytes(params.method.as_bytes())
|
|
.map_err(|error| invalid_params(format!("http/request method is invalid: {error}")))?;
|
|
let url = Url::parse(¶ms.url)
|
|
.map_err(|error| invalid_params(format!("http/request url is invalid: {error}")))?;
|
|
match url.scheme() {
|
|
"http" | "https" => {}
|
|
scheme => {
|
|
return Err(invalid_params(format!(
|
|
"http/request only supports http and https URLs, got {scheme}"
|
|
)));
|
|
}
|
|
}
|
|
|
|
let request_span = tracing::info_span!(
|
|
"codex.exec_server.http_request",
|
|
otel.kind = "client",
|
|
http.request.method = method.as_str(),
|
|
server.address = url.host_str().unwrap_or_default(),
|
|
server.port = u64::from(url.port_or_known_default().unwrap_or_default()),
|
|
http.response.status_code = tracing::field::Empty,
|
|
error.type = tracing::field::Empty,
|
|
);
|
|
let mut headers = Self::build_headers(params.headers)?;
|
|
codex_otel::inject_span_w3c_trace_headers(&request_span, &mut headers);
|
|
let mut request = self.client.request(method.clone(), url).headers(headers);
|
|
if let Some(body) = params.body {
|
|
request = request.body(body.into_inner());
|
|
}
|
|
if let Some(timeout_ms) = params.timeout_ms {
|
|
request = request.timeout(Duration::from_millis(timeout_ms));
|
|
}
|
|
|
|
let response = match request.send().instrument(request_span.clone()).await {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
request_span.record("error.type", "request");
|
|
let error_message = error.to_string();
|
|
log_send_error(&method, error);
|
|
return Err(internal_error(format!(
|
|
"http/request failed: {error_message}"
|
|
)));
|
|
}
|
|
};
|
|
let status = response.status().as_u16();
|
|
request_span.record("http.response.status_code", u64::from(status));
|
|
let headers = Self::response_headers(response.headers());
|
|
|
|
if params.stream_response {
|
|
return Ok((
|
|
HttpRequestResponse {
|
|
status,
|
|
headers,
|
|
body: Vec::new().into(),
|
|
},
|
|
Some(PendingRouteAwareHttpBodyStream {
|
|
request_id: params.request_id,
|
|
response,
|
|
}),
|
|
));
|
|
}
|
|
|
|
let body = response.bytes().await.map_err(|error| {
|
|
internal_error(format!(
|
|
"failed to read http/request response body: {error}"
|
|
))
|
|
})?;
|
|
|
|
Ok((
|
|
HttpRequestResponse {
|
|
status,
|
|
headers,
|
|
body: body.to_vec().into(),
|
|
},
|
|
None,
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn stream_body(
|
|
pending_stream: PendingRouteAwareHttpBodyStream,
|
|
notifications: RpcNotificationSender,
|
|
) {
|
|
let PendingRouteAwareHttpBodyStream {
|
|
request_id,
|
|
response,
|
|
} = pending_stream;
|
|
let mut seq = 1;
|
|
let mut body = response.bytes_stream();
|
|
while let Some(chunk) = body.next().await {
|
|
match chunk {
|
|
Ok(bytes) => {
|
|
for chunk in bytes.chunks(MAX_HTTP_BODY_DELTA_BYTES) {
|
|
if !send_body_delta(
|
|
¬ifications,
|
|
HttpRequestBodyDeltaNotification {
|
|
request_id: request_id.clone(),
|
|
seq,
|
|
delta: chunk.to_vec().into(),
|
|
done: false,
|
|
error: None,
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
seq += 1;
|
|
}
|
|
}
|
|
Err(error) => {
|
|
let _ = send_body_delta(
|
|
¬ifications,
|
|
HttpRequestBodyDeltaNotification {
|
|
request_id,
|
|
seq,
|
|
delta: Vec::new().into(),
|
|
done: true,
|
|
error: Some(error.to_string()),
|
|
},
|
|
)
|
|
.await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
let _ = send_body_delta(
|
|
¬ifications,
|
|
HttpRequestBodyDeltaNotification {
|
|
request_id,
|
|
seq,
|
|
delta: Vec::new().into(),
|
|
done: true,
|
|
error: None,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
|
|
fn build_headers(headers: Vec<HttpHeader>) -> Result<HeaderMap, JSONRPCErrorError> {
|
|
let mut header_map = HeaderMap::new();
|
|
for header in headers {
|
|
let name = HeaderName::from_bytes(header.name.as_bytes()).map_err(|error| {
|
|
invalid_params(format!("http/request header name is invalid: {error}"))
|
|
})?;
|
|
let value = match header.value_env_var {
|
|
Some(env_var) => {
|
|
if HTTP_HEADER_ENV_DENYLIST
|
|
.iter()
|
|
.any(|denied| denied.eq_ignore_ascii_case(&env_var))
|
|
{
|
|
return Err(invalid_params(format!(
|
|
"http/request header {} cannot use executor environment variable {env_var}",
|
|
header.name
|
|
)));
|
|
}
|
|
let env_value = std::env::var(&env_var).map_err(|_| {
|
|
invalid_params(format!(
|
|
"http/request header {} requires executor environment variable {env_var}",
|
|
header.name
|
|
))
|
|
})?;
|
|
if env_value.is_empty() {
|
|
return Err(invalid_params(format!(
|
|
"http/request header {} requires a non-empty executor environment variable {env_var}",
|
|
header.name
|
|
)));
|
|
}
|
|
format!("{}{env_value}", header.value)
|
|
}
|
|
None => header.value,
|
|
};
|
|
let value = HeaderValue::from_str(&value).map_err(|error| {
|
|
invalid_params(format!(
|
|
"http/request header value is invalid for {}: {error}",
|
|
header.name
|
|
))
|
|
})?;
|
|
header_map.append(name, value);
|
|
}
|
|
Ok(header_map)
|
|
}
|
|
|
|
fn response_headers(headers: &HeaderMap) -> Vec<HttpHeader> {
|
|
headers
|
|
.iter()
|
|
.filter_map(|(name, value)| {
|
|
Some(HttpHeader {
|
|
name: name.as_str().to_string(),
|
|
value: value.to_str().ok()?.to_string(),
|
|
value_env_var: None,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
fn log_send_error(method: &Method, error: RouteAwareRequestError) {
|
|
let error_is_timeout = error.is_timeout();
|
|
let error_is_connect = error.is_connect();
|
|
let error = match error {
|
|
RouteAwareRequestError::Request(error) => error.without_url().to_string(),
|
|
error => error.to_string(),
|
|
};
|
|
tracing::warn!(
|
|
http_method = method.as_str(),
|
|
error_is_timeout,
|
|
error_is_connect,
|
|
error = %error,
|
|
"http/request send failed"
|
|
);
|
|
}
|