Enforce managed residency for model providers (#39645)

## Why

Model provider configuration could override the residency header required by
`enforce_residency`, so provider-backed requests did not reliably honor the
managed setting.

## What changed

- Apply the managed residency header after building a provider, making it
  authoritative for model requests and model discovery.
- Warn when a provider configures the residency header through
  `http_headers` or `env_http_headers`, while preserving the original provider
  configuration and unrelated headers.
- Cover HTTP and WebSocket requests, model discovery, and case-insensitive
  header detection with targeted tests.

GitOrigin-RevId: 461ef8989d5bd06ffdea694aee4265e392e07590
This commit is contained in:
Anton Panasenko
2026-08-20 07:33:27 +00:00
committed by copyberry
parent 663da53823
commit af0e82c562
7 changed files with 371 additions and 3 deletions
@@ -75,6 +75,7 @@ mod remote_thread_store;
mod request_permissions;
mod request_user_input;
mod request_validation;
mod residency;
mod review;
mod rollout_migration;
mod safety_check_downgrade;
@@ -0,0 +1,189 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::write_models_cache;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_login::default_client::RESIDENCY_HEADER_NAME;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
const READ_TIMEOUT: Duration = Duration::from_secs(60);
const PROVIDER_RESIDENCY_ENV_VAR: &str = "CODEX_TEST_RESIDENCY_HEADER";
#[derive(Clone, Copy)]
enum ModelTransport {
Http,
Websocket,
}
fn write_provider_config(
codex_home: &Path,
model_base_url: &str,
transport: ModelTransport,
) -> Result<()> {
let supports_websockets = matches!(transport, ModelTransport::Websocket);
let config = format!(
r#"model = "gpt-5.4"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "custom-openai"
[features]
shell_snapshot = false
[model_providers.custom-openai]
name = "OpenAI"
base_url = "{model_base_url}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
requires_openai_auth = true
supports_websockets = {supports_websockets}
[model_providers.custom-openai.http_headers]
"X-OpenAI-Internal-Codex-Residency" = "eu-static"
"x-provider-header" = "preserved"
[model_providers.custom-openai.env_http_headers]
"x-openai-internal-codex-residency" = "{PROVIDER_RESIDENCY_ENV_VAR}"
"#
);
std::fs::write(codex_home.join("config.toml"), config)?;
write_models_cache(codex_home)?;
Ok(())
}
#[test_case(ModelTransport::Http; "http")]
#[test_case(ModelTransport::Websocket; "websocket")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn managed_residency_overrides_provider_headers(transport: ModelTransport) -> Result<()> {
skip_if_no_network!(Ok(()));
let http_server = responses::start_mock_server().await;
let http_responses = responses::mount_sse_once(
&http_server,
responses::sse(vec![
responses::ev_response_created("http-response"),
responses::ev_assistant_message("http-message", "done"),
responses::ev_completed("http-response"),
]),
)
.await;
let websocket_server = responses::start_websocket_server(vec![vec![
vec![
responses::ev_response_created("prewarm-response"),
responses::ev_completed("prewarm-response"),
],
vec![
responses::ev_response_created("websocket-response"),
responses::ev_assistant_message("websocket-message", "done"),
responses::ev_completed("websocket-response"),
],
]])
.await;
let model_base_url = match transport {
ModelTransport::Http => http_server.uri(),
ModelTransport::Websocket => websocket_server.uri().to_string(),
};
let codex_home = TempDir::new()?;
write_provider_config(codex_home.path(), &model_base_url, transport)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
"enforce_residency = \"us\"\n",
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[
("OPENAI_API_KEY", Some("sk-test")),
("CODEX_ACCESS_TOKEN", None),
(PROVIDER_RESIDENCY_ENV_VAR, Some("eu-environment")),
])
.build_initialized_with_timeout(READ_TIMEOUT)
.await?;
let expected_summary = format!(
"Ignoring `{RESIDENCY_HEADER_NAME}` in `model_providers.custom-openai` because managed residency is required."
);
let notification = timeout(
READ_TIMEOUT,
app_server.read_stream_until_matching_notification(
"managed residency configWarning",
|notification| {
notification.method == "configWarning"
&& notification
.params
.as_ref()
.and_then(|params| params.get("summary"))
.and_then(Value::as_str)
== Some(expected_summary.as_str())
},
),
)
.await??;
let warning: ConfigWarningNotification = serde_json::from_value(
notification
.params
.context("managed residency warning should have parameters")?,
)?;
assert_eq!(
warning,
ConfigWarningNotification {
summary: expected_summary,
details: None,
path: None,
range: None,
}
);
let thread = app_server
.start_thread(ThreadStartParams::default())
.await?;
let completed = timeout(
READ_TIMEOUT,
app_server.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread.thread.id,
input: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
}),
)
.await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
let (residency_header, provider_header) = match transport {
ModelTransport::Http => {
let request = http_responses.single_request();
(
request.header(RESIDENCY_HEADER_NAME),
request.header("x-provider-header"),
)
}
ModelTransport::Websocket => {
let handshake = websocket_server.single_handshake();
(
handshake.header(RESIDENCY_HEADER_NAME),
handshake.header("x-provider-header"),
)
}
};
assert_eq!(residency_header, Some("us".to_string()));
assert_eq!(provider_header, Some("preserved".to_string()));
websocket_server.shutdown().await;
Ok(())
}
+83
View File
@@ -73,6 +73,7 @@ use codex_config::types::WindowsToml;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_features::FeaturesToml;
use codex_login::default_client::RESIDENCY_HEADER_NAME;
use codex_login::test_support::auth_manager_from_optional_auth;
use codex_model_provider::ProviderCapabilities;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
@@ -947,6 +948,88 @@ profile = "codex-bedrock"
);
}
#[tokio::test]
async fn managed_residency_warns_about_provider_header_overrides_without_mutating_config()
-> std::io::Result<()> {
for enforce_residency in [None, Some(ResidencyRequirement::Us)] {
let cfg = toml::from_str::<ConfigToml>(
r#"
model_provider = "custom-openai"
[model_providers.custom-openai]
name = "OpenAI"
http_headers = { "X-OpenAI-Internal-Codex-Residency" = "request-override", "x-provider-header" = "preserved" }
env_http_headers = { "x-openai-internal-codex-residency" = "CODEX_TEST_UNSET_RESIDENCY_HEADER", "x-provider-env-header" = "CODEX_TEST_UNSET_PROVIDER_HEADER" }
"#,
)
.expect("existing provider configuration should remain loadable");
let requirements = ConfigRequirements {
enforce_residency: ConstrainedWithSource::new(
Constrained::allow_only(enforce_residency),
enforce_residency.map(|_| RequirementSource::Unknown),
),
..Default::default()
};
let requirements_toml = ConfigRequirementsToml {
enforce_residency,
..Default::default()
};
let config_layer_stack =
ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
ConfigOverrides::default(),
tempdir()?.abs(),
config_layer_stack,
)
.await?;
let static_headers = config
.model_provider
.http_headers
.as_ref()
.expect("static headers should remain configured");
let environment_headers = config
.model_provider
.env_http_headers
.as_ref()
.expect("environment-backed headers should remain configured");
assert_eq!(
static_headers
.get("X-OpenAI-Internal-Codex-Residency")
.map(String::as_str),
Some("request-override")
);
assert_eq!(
environment_headers
.get(RESIDENCY_HEADER_NAME)
.map(String::as_str),
Some("CODEX_TEST_UNSET_RESIDENCY_HEADER")
);
assert_eq!(
static_headers.get("x-provider-header").map(String::as_str),
Some("preserved")
);
assert_eq!(
environment_headers
.get("x-provider-env-header")
.map(String::as_str),
Some("CODEX_TEST_UNSET_PROVIDER_HEADER")
);
let expected_warning = format!(
"Ignoring `{RESIDENCY_HEADER_NAME}` in `model_providers.custom-openai` because managed residency is required."
);
assert_eq!(
config.startup_warnings.contains(&expected_warning),
enforce_residency.is_some()
);
}
Ok(())
}
#[test]
fn accepts_amazon_bedrock_aws_profile_override() {
let cfg = toml::from_str::<ConfigToml>(
+21
View File
@@ -5,6 +5,7 @@ use codex_config::config_toml::ConfigToml;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::FeedbackConfigToml;
use codex_features::FeatureToml;
use codex_login::default_client::RESIDENCY_HEADER_NAME;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
@@ -47,6 +48,26 @@ pub(super) fn apply_to_config(
requirements.feedback.as_ref(),
startup_warnings,
);
if requirements.enforce_residency.value().is_some() {
for (provider_name, provider) in &config.model_providers {
let has_residency_header = [&provider.http_headers, &provider.env_http_headers]
.into_iter()
.flatten()
.any(|headers| {
headers
.keys()
.any(|name| name.eq_ignore_ascii_case(RESIDENCY_HEADER_NAME))
});
if has_residency_header {
let warning = format!(
"Ignoring `{RESIDENCY_HEADER_NAME}` in `model_providers.{provider_name}` because managed residency is required."
);
tracing::warn!(provider = provider_name, "{warning}");
startup_warnings.push(warning);
}
}
}
if let Some(requirement) = requirements.windows_sandbox_private_desktop.as_ref() {
apply_exact_requirement(
"windows.sandbox_private_desktop",
@@ -104,6 +104,11 @@ pub fn set_default_client_residency_requirement(enforce_residency: Option<Reside
*guard = enforce_residency;
}
/// Returns the current process-wide residency requirement.
pub fn read_default_client_residency_requirement() -> Option<ResidencyRequirement> {
REQUIREMENTS_RESIDENCY.read().ok().and_then(|guard| *guard)
}
pub fn originator() -> Originator {
if let Ok(guard) = ORIGINATOR.read()
&& let Some(originator) = guard.as_ref()
+54 -1
View File
@@ -34,6 +34,7 @@ use tokio::time::timeout;
use crate::auth::agent_identity_telemetry;
use crate::auth::resolve_provider_auth;
use crate::provider::enforce_managed_residency;
const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
const MODELS_ENDPOINT: &str = "/models";
@@ -81,7 +82,8 @@ impl OpenAiModelsEndpoint {
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
let auth = self.auth().await;
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
let api_provider = self.provider_info.to_api_provider(auth_mode)?;
let mut api_provider = self.provider_info.to_api_provider(auth_mode)?;
enforce_managed_residency(&mut api_provider);
let api_auth = resolve_provider_auth(auth.as_ref(), &self.provider_info)?;
let request_url =
ModelsClient::<ReqwestTransport>::request_url(&api_provider, client_version);
@@ -280,13 +282,17 @@ mod tests {
use super::*;
use codex_http_client::OutboundProxyPolicy;
use codex_login::default_client::RESIDENCY_HEADER_NAME;
use codex_login::default_client::ResidencyRequirement;
use codex_login::default_client::create_client;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_protocol::openai_models::ModelsResponse;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
@@ -390,4 +396,51 @@ mod tests {
))
);
}
#[tokio::test]
async fn model_discovery_enforces_managed_residency_over_provider_headers() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header(RESIDENCY_HEADER_NAME, "us"))
.respond_with(
ResponseTemplate::new(200).set_body_json(ModelsResponse { models: Vec::new() }),
)
.expect(1)
.mount(&server)
.await;
let mut provider_info = ModelProviderInfo::create_openai_provider(Some(server.uri()));
provider_info.http_headers = Some(std::collections::HashMap::from([(
RESIDENCY_HEADER_NAME.to_string(),
"eu".to_string(),
)]));
let endpoint = OpenAiModelsEndpoint {
provider_info,
auth_manager: None,
transport_builder: Arc::new(RecordingTransportBuilder {
observed_request: Arc::new(Mutex::new(None)),
}),
};
set_default_client_residency_requirement(Some(ResidencyRequirement::Us));
endpoint
.list_models(
"0.0.0",
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.await
.expect("managed residency model discovery should succeed");
set_default_client_residency_requirement(/*enforce_residency*/ None);
assert_eq!(
endpoint
.provider_info
.http_headers
.as_ref()
.and_then(|headers| headers.get(RESIDENCY_HEADER_NAME))
.map(String::as_str),
Some("eu")
);
}
}
+18 -2
View File
@@ -11,6 +11,9 @@ use codex_api::TransportError;
use codex_api::is_azure_responses_provider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::RESIDENCY_HEADER_NAME;
use codex_login::default_client::ResidencyRequirement;
use codex_login::default_client::read_default_client_residency_requirement;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::cache::ModelsCache;
use codex_models_manager::manager::OpenAiModelsManager;
@@ -19,6 +22,7 @@ use codex_models_manager::manager::StaticModelsManager;
use codex_protocol::account::ProviderAccount;
use codex_protocol::error::CodexErr;
use codex_protocol::openai_models::ModelsResponse;
use http::HeaderValue;
use crate::amazon_bedrock::AmazonBedrockModelProvider;
use crate::auth::ProviderAuthScope;
@@ -28,6 +32,15 @@ use crate::auth::resolve_provider_auth;
use crate::auth::resolve_provider_auth_for_scope;
use crate::models_endpoint::OpenAiModelsEndpoint;
pub(crate) fn enforce_managed_residency(provider: &mut Provider) {
if let Some(requirement) = read_default_client_residency_requirement() {
let value = match requirement {
ResidencyRequirement::Us => HeaderValue::from_static("us"),
};
provider.headers.insert(RESIDENCY_HEADER_NAME, value);
}
}
/// Remote context-compaction protocols supported by a model provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteCompactionSupport {
@@ -203,8 +216,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Provider>> {
Box::pin(async move {
let auth = self.auth().await;
self.info()
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))
let mut provider = self
.info()
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?;
enforce_managed_residency(&mut provider);
Ok(provider)
})
}