[codex] wire per-surface integrity state transport [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-06-03 03:20:55 +00:00
parent d31ede0634
commit fe2aee1632
14 changed files with 158 additions and 87 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -2539,6 +2539,7 @@ dependencies = [
"codex-feedback",
"codex-git-utils",
"codex-hooks",
"codex-http-state",
"codex-install-context",
"codex-login",
"codex-mcp",
@@ -3311,6 +3312,7 @@ dependencies = [
"codex-aws-auth",
"codex-client",
"codex-feedback",
"codex-http-state",
"codex-login",
"codex-model-provider-info",
"codex-models-manager",

View File

@@ -10,6 +10,18 @@ pub fn is_allowed_chatgpt_host(host: &str) -> bool {
.any(|suffix| host.ends_with(suffix))
}
/// Returns whether `url` is an HTTPS or secure WebSocket ChatGPT URL that Codex
/// may treat as first-party traffic.
pub fn is_allowed_chatgpt_request_url(url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(url) else {
return false;
};
if !matches!(url.scheme(), "https" | "wss") {
return false;
}
url.host_str().is_some_and(is_allowed_chatgpt_host)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -36,4 +48,25 @@ mod tests {
assert!(!is_allowed_chatgpt_host(host));
}
}
#[test]
fn recognizes_secure_chatgpt_request_urls() {
for url in [
"https://chatgpt.com/backend-api/codex/responses",
"https://preview.chatgpt.com/backend-api/codex/models",
"wss://chatgpt-staging.com/backend-api/codex/responses",
] {
assert!(is_allowed_chatgpt_request_url(url));
}
for url in [
"http://chatgpt.com/backend-api/codex/responses",
"ws://chatgpt.com/backend-api/codex/responses",
"https://api.openai.com/v1/responses",
"https://chatgpt.com.evil.example/backend-api",
"not a url",
] {
assert!(!is_allowed_chatgpt_request_url(url));
}
}
}

View File

@@ -1,3 +1,5 @@
use crate::INTEGRITY_STATE_HEADER_NAME;
use crate::INTEGRITY_STATE_UPDATE_HEADER_NAME;
use http::Error as HttpError;
use http::HeaderMap;
use http::HeaderName;
@@ -115,11 +117,12 @@ impl CodexRequestBuilder {
match self.builder.headers(headers).send().await {
Ok(response) => {
let headers = redacted_headers_for_logging(response.headers());
tracing::debug!(
method = %self.method,
url = %self.url,
status = %response.status(),
headers = ?response.headers(),
headers = ?headers,
version = ?response.version(),
"Request completed"
);
@@ -141,6 +144,19 @@ impl CodexRequestBuilder {
}
}
fn redacted_headers_for_logging(headers: &HeaderMap) -> HeaderMap {
let mut headers = headers.clone();
for name in [
INTEGRITY_STATE_HEADER_NAME,
INTEGRITY_STATE_UPDATE_HEADER_NAME,
] {
if headers.contains_key(name) {
headers.insert(name, HeaderValue::from_static("<redacted>"));
}
}
headers
}
struct HeaderMapInjector<'a>(&'a mut HeaderMap);
impl<'a> Injector for HeaderMapInjector<'a> {
@@ -174,10 +190,39 @@ mod tests {
use opentelemetry::trace::TracerProvider;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::SdkTracerProvider;
use pretty_assertions::assert_eq;
use tracing::trace_span;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
#[test]
fn redacted_headers_for_logging_hides_integrity_state() {
let mut headers = HeaderMap::new();
headers.insert(
INTEGRITY_STATE_HEADER_NAME,
HeaderValue::from_static("ois1.request.nonce.ciphertext"),
);
headers.insert(
INTEGRITY_STATE_UPDATE_HEADER_NAME,
HeaderValue::from_static("ois1.response.nonce.ciphertext"),
);
headers.insert("x-request-id", HeaderValue::from_static("request-id"));
let redacted = redacted_headers_for_logging(&headers);
let mut expected = HeaderMap::new();
expected.insert(
INTEGRITY_STATE_HEADER_NAME,
HeaderValue::from_static("<redacted>"),
);
expected.insert(
INTEGRITY_STATE_UPDATE_HEADER_NAME,
HeaderValue::from_static("<redacted>"),
);
expected.insert("x-request-id", HeaderValue::from_static("request-id"));
assert_eq!(redacted, expected);
}
#[test]
fn inject_trace_headers_uses_current_span_context() {
global::set_text_map_propagator(TraceContextPropagator::new());

View File

@@ -11,6 +11,7 @@ mod transport;
pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store;
pub use crate::chatgpt_hosts::is_allowed_chatgpt_host;
pub use crate::chatgpt_hosts::is_allowed_chatgpt_request_url;
pub use crate::custom_ca::BuildCustomCaTransportError;
/// Test-only subprocess hook for custom CA coverage.
///
@@ -40,3 +41,6 @@ pub use crate::transport::ByteStream;
pub use crate::transport::HttpTransport;
pub use crate::transport::ReqwestTransport;
pub use crate::transport::StreamResponse;
pub const INTEGRITY_STATE_HEADER_NAME: &str = "x-oai-is";
pub const INTEGRITY_STATE_UPDATE_HEADER_NAME: &str = "x-oai-is-update";

View File

@@ -48,6 +48,7 @@ codex-shell-command = { workspace = true }
codex-execpolicy = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-http-state = { workspace = true }
codex-install-context = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }

View File

@@ -312,7 +312,7 @@ impl AgentControl {
.session
.services
.model_client
.native_integrity_surface()
.http_state_surface()
{
new_thread
.thread
@@ -320,7 +320,7 @@ impl AgentControl {
.session
.services
.model_client
.set_native_integrity_surface(surface);
.set_http_state_surface(surface);
}
parent_thread
.codex

View File

@@ -10,8 +10,8 @@ use crate::context::ContextualUserFragment;
use crate::context::SubagentNotification;
use crate::init_state_db;
use assert_matches::assert_matches;
use codex_client::NativeIntegritySurface;
use codex_features::Feature;
use codex_http_state::HttpStateSurface;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ModeKind;
@@ -1586,7 +1586,7 @@ async fn spawn_child_completion_notifies_parent_history() {
}
#[tokio::test]
async fn spawn_thread_subagent_inherits_parent_native_integrity_surface() {
async fn spawn_thread_subagent_inherits_parent_http_state_surface() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
parent_thread
@@ -1625,8 +1625,8 @@ async fn spawn_thread_subagent_inherits_parent_native_integrity_surface() {
.session
.services
.model_client
.native_integrity_surface(),
Some(NativeIntegritySurface::CodexDesktop)
.http_state_surface(),
Some(HttpStateSurface::CodexDesktop)
);
}

View File

@@ -62,8 +62,8 @@ use codex_api::build_session_headers;
use codex_api::create_text_param_for_request;
use codex_api::response_create_client_metadata;
use codex_app_server_protocol::AuthMode;
use codex_client::NativeIntegrityStateContext;
use codex_client::NativeIntegritySurface;
use codex_http_state::HttpStateContext;
use codex_http_state::HttpStateSurface;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::RefreshTokenError;
@@ -183,7 +183,7 @@ struct ModelClientState {
beta_features_header: Option<String>,
include_attestation: bool,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
native_integrity_state: Option<NativeIntegrityStateContext>,
http_state: Option<HttpStateContext>,
disable_websockets: AtomicBool,
cached_websocket_session: StdMutex<WebsocketSession>,
}
@@ -333,10 +333,10 @@ impl ModelClient {
beta_features_header: Option<String>,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
) -> Self {
let native_integrity_state = auth_manager.as_ref().map(|auth_manager| {
NativeIntegrityStateContext::new(
let http_state = auth_manager.as_ref().map(|auth_manager| {
HttpStateContext::new(
auth_manager.codex_home().to_path_buf(),
native_integrity_surface_for_session_source(&session_source),
http_state_surface_for_session_source(&session_source),
)
});
let model_provider = create_model_provider(provider_info, auth_manager);
@@ -363,7 +363,7 @@ impl ModelClient {
beta_features_header,
include_attestation,
attestation_provider,
native_integrity_state,
http_state,
disable_websockets: AtomicBool::new(false),
cached_websocket_session: StdMutex::new(WebsocketSession::default()),
}),
@@ -403,26 +403,26 @@ impl ModelClient {
pub(crate) fn set_app_server_client_name(&self, client_name: Option<&str>) {
let surface = client_name
.map(NativeIntegritySurface::from_app_server_client_name)
.unwrap_or(NativeIntegritySurface::CodexCli);
self.set_native_integrity_surface(surface);
.map(HttpStateSurface::from_app_server_client_name)
.unwrap_or(HttpStateSurface::CodexCli);
self.set_http_state_surface(surface);
}
pub(crate) fn native_integrity_surface(&self) -> Option<NativeIntegritySurface> {
pub(crate) fn http_state_surface(&self) -> Option<HttpStateSurface> {
self.state
.native_integrity_state
.http_state
.as_ref()
.map(NativeIntegrityStateContext::surface)
.map(HttpStateContext::surface)
}
pub(crate) fn native_integrity_state_context(&self) -> Option<NativeIntegrityStateContext> {
self.state.native_integrity_state.clone()
pub(crate) fn http_state_context(&self) -> Option<HttpStateContext> {
self.state.http_state.clone()
}
pub(crate) fn set_native_integrity_surface(&self, surface: NativeIntegritySurface) {
pub(crate) fn set_http_state_surface(&self, surface: HttpStateSurface) {
if self
.state
.native_integrity_state
.http_state
.as_ref()
.is_some_and(|state| state.set_surface(surface))
{
@@ -855,7 +855,7 @@ impl ModelClient {
let api_auth = with_native_integrity_state(
self.state.provider.api_auth().await?,
auth.as_ref(),
self.state.native_integrity_state.clone(),
self.state.http_state.clone(),
);
Ok(CurrentClientSetup {
auth,
@@ -1780,18 +1780,16 @@ fn subagent_header_value(session_source: &SessionSource) -> Option<String> {
}
}
fn native_integrity_surface_for_session_source(
session_source: &SessionSource,
) -> NativeIntegritySurface {
fn http_state_surface_for_session_source(session_source: &SessionSource) -> HttpStateSurface {
match session_source {
SessionSource::Cli => NativeIntegritySurface::CodexTui,
SessionSource::Exec => NativeIntegritySurface::CodexExec,
SessionSource::VSCode => NativeIntegritySurface::CodexVscode,
SessionSource::Cli => HttpStateSurface::CodexTui,
SessionSource::Exec => HttpStateSurface::CodexExec,
SessionSource::VSCode => HttpStateSurface::CodexVscode,
SessionSource::SubAgent(_)
| SessionSource::Internal(_)
| SessionSource::Mcp
| SessionSource::Custom(_)
| SessionSource::Unknown => NativeIntegritySurface::CodexCli,
| SessionSource::Unknown => HttpStateSurface::CodexCli,
}
}

View File

@@ -110,16 +110,12 @@ pub(crate) async fn run_codex_thread_interactive(
}))
.or_cancel(&cancel_token)
.await??;
if let Some(surface) = parent_session
.services
.model_client
.native_integrity_surface()
{
if let Some(surface) = parent_session.services.model_client.http_state_surface() {
codex
.session
.services
.model_client
.set_native_integrity_surface(surface);
.set_http_state_surface(surface);
}
let thread_config = codex.thread_config_snapshot().await;
let client_metadata = parent_session.app_server_client_metadata().await;

View File

@@ -2,7 +2,7 @@ use super::*;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX;
use async_channel::bounded;
use codex_client::NativeIntegritySurface;
use codex_http_state::HttpStateSurface;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::NetworkPermissions;
@@ -183,13 +183,13 @@ async fn run_codex_thread_interactive_respects_pre_cancelled_spawn() {
}
#[tokio::test]
async fn run_codex_thread_interactive_inherits_parent_native_integrity_surface() {
async fn run_codex_thread_interactive_inherits_parent_http_state_surface() {
let (parent_session, parent_ctx, _rx_events) =
crate::session::tests::make_session_and_context_with_rx().await;
parent_session
.services
.model_client
.set_native_integrity_surface(NativeIntegritySurface::CodexDesktop);
.set_http_state_surface(HttpStateSurface::CodexDesktop);
let cancel_token = CancellationToken::new();
let delegated = run_codex_thread_interactive(
@@ -206,12 +206,8 @@ async fn run_codex_thread_interactive_inherits_parent_native_integrity_surface()
.expect("delegate should spawn");
assert_eq!(
delegated
.session
.services
.model_client
.native_integrity_surface(),
Some(NativeIntegritySurface::CodexDesktop)
delegated.session.services.model_client.http_state_surface(),
Some(HttpStateSurface::CodexDesktop)
);
cancel_token.cancel();
}

View File

@@ -13,7 +13,7 @@
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use codex_api::upload_local_file;
use codex_client::NativeIntegrityStateContext;
use codex_http_state::HttpStateContext;
use codex_login::CodexAuth;
use serde_json::Value as JsonValue;
@@ -34,7 +34,7 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
return Ok(Some(arguments_value));
};
let auth = sess.services.auth_manager.auth().await;
let native_integrity_state = sess.services.model_client.native_integrity_state_context();
let http_state = sess.services.model_client.http_state_context();
let mut rewritten_arguments = arguments.clone();
for field_name in openai_file_input_params {
@@ -44,7 +44,7 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
let Some(uploaded_value) = rewrite_argument_value_for_openai_files(
turn_context,
auth.as_ref(),
native_integrity_state.clone(),
http_state.clone(),
field_name,
value,
)
@@ -65,7 +65,7 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
async fn rewrite_argument_value_for_openai_files(
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
native_integrity_state: Option<NativeIntegrityStateContext>,
http_state: Option<HttpStateContext>,
field_name: &str,
value: &JsonValue,
) -> Result<Option<JsonValue>, String> {
@@ -74,7 +74,7 @@ async fn rewrite_argument_value_for_openai_files(
let rewritten = build_uploaded_local_argument_value(
turn_context,
auth,
native_integrity_state,
http_state,
field_name,
/*index*/ None,
path_or_file_ref,
@@ -91,7 +91,7 @@ async fn rewrite_argument_value_for_openai_files(
let rewritten = build_uploaded_local_argument_value(
turn_context,
auth,
native_integrity_state.clone(),
http_state.clone(),
field_name,
Some(index),
path_or_file_ref,
@@ -108,7 +108,7 @@ async fn rewrite_argument_value_for_openai_files(
async fn build_uploaded_local_argument_value(
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
native_integrity_state: Option<NativeIntegrityStateContext>,
http_state: Option<HttpStateContext>,
field_name: &str,
index: Option<usize>,
file_path: &str,
@@ -128,7 +128,7 @@ async fn build_uploaded_local_argument_value(
let upload_auth = codex_model_provider::with_native_integrity_state(
codex_model_provider::auth_provider_from_auth(auth),
Some(auth),
native_integrity_state,
http_state,
);
let uploaded = upload_local_file(
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
@@ -244,7 +244,7 @@ mod tests {
let rewritten = build_uploaded_local_argument_value(
&turn_context,
Some(&auth),
/*native_integrity_state*/ None,
/*http_state*/ None,
"file",
/*index*/ None,
"file_report.csv",
@@ -328,7 +328,7 @@ mod tests {
let rewritten = rewrite_argument_value_for_openai_files(
&turn_context,
Some(&auth),
/*native_integrity_state*/ None,
/*http_state*/ None,
"file",
&serde_json::json!("file_report.csv"),
)
@@ -446,7 +446,7 @@ mod tests {
let rewritten = rewrite_argument_value_for_openai_files(
&turn_context,
Some(&auth),
/*native_integrity_state*/ None,
/*http_state*/ None,
"files",
&serde_json::json!(["one.csv", "two.csv"]),
)

View File

@@ -19,6 +19,7 @@ codex-agent-identity = { workspace = true }
codex-aws-auth = { workspace = true }
codex-client = { workspace = true }
codex-feedback = { workspace = true }
codex-http-state = { workspace = true }
codex-login = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-models-manager = { workspace = true }

View File

@@ -5,7 +5,7 @@ use codex_agent_identity::AgentTaskAuthorizationTarget;
use codex_agent_identity::authorization_header_for_agent_task;
use codex_api::AuthProvider;
use codex_api::SharedAuthProvider;
use codex_client::NativeIntegrityStateContext;
use codex_http_state::HttpStateContext;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
@@ -125,7 +125,7 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider {
pub fn with_native_integrity_state(
auth_provider: SharedAuthProvider,
auth: Option<&CodexAuth>,
state: Option<NativeIntegrityStateContext>,
state: Option<HttpStateContext>,
) -> SharedAuthProvider {
let Some(state) = state else {
return auth_provider;

View File

@@ -2,9 +2,9 @@ use codex_api::AuthProvider;
use codex_api::SharedAuthProvider;
use codex_client::INTEGRITY_STATE_HEADER_NAME;
use codex_client::INTEGRITY_STATE_UPDATE_HEADER_NAME;
use codex_client::NativeIntegrityStateContext;
use codex_client::NativeIntegritySurface;
use codex_client::is_allowed_chatgpt_request_url;
use codex_http_state::HttpStateContext;
use codex_http_state::HttpStateSurface;
use http::HeaderMap;
use http::HeaderValue;
@@ -55,12 +55,12 @@ impl AuthProvider for BearerAuthProvider {
#[derive(Clone)]
pub(crate) struct NativeIntegrityAuthProvider {
auth: SharedAuthProvider,
state: NativeIntegrityStateContext,
surface: NativeIntegritySurface,
state: HttpStateContext,
surface: HttpStateSurface,
}
impl NativeIntegrityAuthProvider {
pub(crate) fn new(auth: SharedAuthProvider, state: NativeIntegrityStateContext) -> Self {
pub(crate) fn new(auth: SharedAuthProvider, state: HttpStateContext) -> Self {
let surface = state.surface();
Self {
auth,
@@ -81,9 +81,9 @@ impl AuthProvider for NativeIntegrityAuthProvider {
return;
}
match self.state.load_for_surface(self.surface) {
Ok(Some(state_file)) => {
if let Ok(header) = HeaderValue::from_str(&state_file.state) {
match self.state.get_for_surface(self.surface) {
Ok(Some(state)) => {
if let Ok(header) = HeaderValue::from_str(&state) {
let _ = headers.insert(INTEGRITY_STATE_HEADER_NAME, header);
}
}
@@ -119,7 +119,7 @@ impl AuthProvider for NativeIntegrityAuthProvider {
return;
};
if let Err(error) = self.state.compare_and_store_for_surface(
if let Err(error) = self.state.compare_and_set_for_surface(
self.surface,
expected_state,
next_state.to_string(),
@@ -132,8 +132,7 @@ impl AuthProvider for NativeIntegrityAuthProvider {
#[cfg(test)]
mod tests {
use super::*;
use codex_client::NativeIntegrityStateFile;
use codex_client::NativeIntegrityStateStore;
use codex_http_state::HttpStateStore;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::TempDir;
@@ -198,14 +197,14 @@ mod tests {
#[test]
fn native_integrity_provider_scopes_and_rotates_surface_state() {
let codex_home = TempDir::new().expect("tempdir");
let context = NativeIntegrityStateContext::new(
let context = HttpStateContext::new(
codex_home.path().to_path_buf(),
NativeIntegritySurface::CodexDesktop,
HttpStateSurface::CodexDesktop,
);
let store = NativeIntegrityStateStore::new(codex_home.path().to_path_buf());
let store = HttpStateStore::new(codex_home.path().to_path_buf());
store
.replace(
NativeIntegritySurface::CodexDesktop,
.set(
HttpStateSurface::CodexDesktop,
"ois1.initial.nonce.ciphertext".to_string(),
)
.expect("state should store");
@@ -213,10 +212,10 @@ mod tests {
Arc::new(BearerAuthProvider::new("access-token".to_string())),
context.clone(),
);
context.set_surface(NativeIntegritySurface::CodexCli);
context.set_surface(HttpStateSurface::CodexCli);
store
.replace(
NativeIntegritySurface::CodexCli,
.set(
HttpStateSurface::CodexCli,
"ois1.cli.nonce.ciphertext".to_string(),
)
.expect("CLI state should store");
@@ -245,21 +244,17 @@ mod tests {
);
assert_eq!(
store
.load(NativeIntegritySurface::CodexDesktop)
.get(HttpStateSurface::CodexDesktop)
.expect("state should load")
.expect("state should exist"),
NativeIntegrityStateFile {
state: "ois1.rotated.nonce.ciphertext".to_string(),
}
"ois1.rotated.nonce.ciphertext"
);
assert_eq!(
store
.load(NativeIntegritySurface::CodexCli)
.get(HttpStateSurface::CodexCli)
.expect("CLI state should load")
.expect("CLI state should exist"),
NativeIntegrityStateFile {
state: "ois1.cli.nonce.ciphertext".to_string(),
}
"ois1.cli.nonce.ciphertext"
);
let mut external_headers = HeaderMap::new();