Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/app-server/tests/suite/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
189 changes: 189 additions & 0 deletions codex-rs/app-server/tests/suite/v2/residency.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 83 additions & 0 deletions codex-rs/core/src/config/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>(
Expand Down
21 changes: 21 additions & 0 deletions codex-rs/core/src/config/requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/login/src/auth/default_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading