diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8164eb5289c..d38a4dd4354 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -497,6 +497,14 @@ pub struct ConfigReadResponse { pub struct ConfigRequirements { pub allowed_approval_policies: Option>, pub allowed_sandbox_modes: Option>, + pub enforce_residency: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum ResidencyRequirement { + Us, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 76d1131a2e3..8a63ba323fe 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -103,7 +103,7 @@ Example (from OpenAI's official VSCode extension): - `config/read` — fetch the effective config on disk after resolving config layering. - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk. -- `configRequirements/read` — fetch the loaded requirements allow-lists from `requirements.toml` and/or MDM (or `null` if none are configured). +- `configRequirements/read` — fetch the loaded requirements allow-lists and `enforceResidency` from `requirements.toml` and/or MDM (or `null` if none are configured). ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 69f2c10df4b..5a43d6e5282 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -15,6 +15,7 @@ use codex_core::config::ConfigServiceError; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::LoaderOverrides; +use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement; use serde_json::json; use std::path::PathBuf; @@ -91,6 +92,9 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .filter_map(map_sandbox_mode_requirement_to_api) .collect() }), + enforce_residency: requirements + .enforce_residency + .map(map_residency_requirement_to_api), } } @@ -103,6 +107,14 @@ fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Opti } } +fn map_residency_requirement_to_api( + residency: CoreResidencyRequirement, +) -> codex_app_server_protocol::ResidencyRequirement { + match residency { + CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us, + } +} + fn map_error(err: ConfigServiceError) -> JSONRPCErrorError { if let Some(code) = err.write_error_code() { return config_write_error(code, err.to_string()); @@ -144,6 +156,7 @@ mod tests { ]), mcp_servers: None, rules: None, + enforce_residency: Some(CoreResidencyRequirement::Us), }; let mapped = map_requirements_toml_to_api(requirements); @@ -159,5 +172,9 @@ mod tests { mapped.allowed_sandbox_modes, Some(vec![SandboxMode::ReadOnly]), ); + assert_eq!( + mapped.enforce_residency, + Some(codex_app_server_protocol::ResidencyRequirement::Us), + ); } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 21b9a7a562f..b1c899bf1db 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -37,6 +37,7 @@ use codex_core::config_loader::LoaderOverrides; use codex_core::default_client::SetOriginatorError; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; +use codex_core::default_client::set_default_client_residency_requirement; use codex_core::default_client::set_default_originator; use codex_feedback::CodexFeedback; use codex_protocol::ThreadId; @@ -104,6 +105,7 @@ pub(crate) struct MessageProcessor { outgoing: Arc, codex_message_processor: CodexMessageProcessor, config_api: ConfigApi, + config: Arc, initialized: bool, config_warnings: Vec, } @@ -169,6 +171,7 @@ impl MessageProcessor { outgoing, codex_message_processor, config_api, + config, initialized: false, config_warnings, } @@ -241,6 +244,7 @@ impl MessageProcessor { } } } + set_default_client_residency_requirement(self.config.enforce_residency.value()); let user_agent_suffix = format!("{name}; {version}"); if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { *suffix = Some(user_agent_suffix); diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index ed1bb051d5e..00a2814cbb1 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -315,6 +315,7 @@ mod tests { allowed_sandbox_modes: None, mcp_servers: None, rules: None, + enforce_residency: None, }) ); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 761e5307b47..6af91109dcf 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -24,6 +24,7 @@ use crate::config_loader::ConfigRequirements; use crate::config_loader::LoaderOverrides; use crate::config_loader::McpServerIdentity; use crate::config_loader::McpServerRequirement; +use crate::config_loader::ResidencyRequirement; use crate::config_loader::Sourced; use crate::config_loader::load_config_layers_state; use crate::features::Feature; @@ -140,6 +141,11 @@ pub struct Config { pub sandbox_policy: Constrained, + /// enforce_residency means web traffic cannot be routed outside of a + /// particular geography. HTTP clients should direct their requests + /// using backend-specific headers or URLs to enforce this. + pub enforce_residency: Constrained>, + /// True if the user passed in an override or set a value in config.toml /// for either of approval_policy or sandbox_mode. pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, @@ -1516,6 +1522,7 @@ impl Config { sandbox_policy: mut constrained_sandbox_policy, mcp_servers, exec_policy: _, + enforce_residency, } = requirements; constrained_approval_policy @@ -1538,6 +1545,7 @@ impl Config { cwd: resolved_cwd, approval_policy: constrained_approval_policy, sandbox_policy: constrained_sandbox_policy, + enforce_residency, did_user_set_custom_approval_policy_or_sandbox_mode, forced_auto_mode_downgraded_on_windows, shell_environment_policy, @@ -3771,6 +3779,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3855,6 +3864,7 @@ model_verbosity = "high" model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3954,6 +3964,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -4039,6 +4050,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index 6b9184a5391..478c74193e0 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -52,6 +52,7 @@ pub struct ConfigRequirements { pub sandbox_policy: Constrained, pub mcp_servers: Option>>, pub(crate) exec_policy: Option>, + pub enforce_residency: Constrained>, } impl Default for ConfigRequirements { @@ -61,6 +62,7 @@ impl Default for ConfigRequirements { sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly), mcp_servers: None, exec_policy: None, + enforce_residency: Constrained::allow_any(None), } } } @@ -84,6 +86,7 @@ pub struct ConfigRequirementsToml { pub allowed_sandbox_modes: Option>, pub mcp_servers: Option>, pub rules: Option, + pub enforce_residency: Option, } /// Value paired with the requirement source it came from, for better error @@ -114,6 +117,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_sandbox_modes: Option>>, pub mcp_servers: Option>>, pub rules: Option>, + pub enforce_residency: Option>, } impl ConfigRequirementsWithSources { @@ -146,6 +150,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes, mcp_servers, rules, + enforce_residency, } ); } @@ -156,12 +161,14 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes, mcp_servers, rules, + enforce_residency, } = self; ConfigRequirementsToml { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), rules: rules.map(|sourced| sourced.value), + enforce_residency: enforce_residency.map(|sourced| sourced.value), } } } @@ -193,12 +200,19 @@ impl From for SandboxModeRequirement { } } +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ResidencyRequirement { + Us, +} + impl ConfigRequirementsToml { pub fn is_empty(&self) -> bool { self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none() && self.mcp_servers.is_none() && self.rules.is_none() + && self.enforce_residency.is_none() } } @@ -211,6 +225,7 @@ impl TryFrom for ConfigRequirements { allowed_sandbox_modes, mcp_servers, rules, + enforce_residency, } = toml; let approval_policy: Constrained = match allowed_approval_policies { @@ -298,11 +313,33 @@ impl TryFrom for ConfigRequirements { None => None, }; + let enforce_residency: Constrained> = match enforce_residency { + Some(Sourced { + value: residency, + source: requirement_source, + }) => { + let required = Some(residency); + Constrained::new(required, move |candidate| { + if candidate == &required { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "enforce_residency", + candidate: format!("{candidate:?}"), + allowed: format!("{required:?}"), + requirement_source: requirement_source.clone(), + }) + } + })? + } + None => Constrained::allow_any(None), + }; Ok(ConfigRequirements { approval_policy, sandbox_policy, mcp_servers, exec_policy, + enforce_residency, }) } } @@ -329,6 +366,7 @@ mod tests { allowed_sandbox_modes, mcp_servers, rules, + enforce_residency, } = toml; ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies @@ -337,6 +375,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), + enforce_residency: enforce_residency + .map(|value| Sourced::new(value, RequirementSource::Unknown)), } } @@ -350,6 +390,8 @@ mod tests { SandboxModeRequirement::WorkspaceWrite, SandboxModeRequirement::DangerFullAccess, ]; + let enforce_residency = ResidencyRequirement::Us; + let enforce_source = source.clone(); // Intentionally constructed without `..Default::default()` so adding a new field to // `ConfigRequirementsToml` forces this test to be updated. @@ -358,6 +400,7 @@ mod tests { allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), mcp_servers: None, rules: None, + enforce_residency: Some(enforce_residency), }; target.merge_unset_fields(source.clone(), other); @@ -372,6 +415,7 @@ mod tests { allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)), mcp_servers: None, rules: None, + enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), } ); } @@ -401,6 +445,7 @@ mod tests { allowed_sandbox_modes: None, mcp_servers: None, rules: None, + enforce_residency: None, } ); Ok(()) @@ -438,6 +483,7 @@ mod tests { allowed_sandbox_modes: None, mcp_servers: None, rules: None, + enforce_residency: None, } ); Ok(()) diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index ce220a51e53..d48bda18f65 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -37,6 +37,7 @@ pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::McpServerIdentity; pub use config_requirements::McpServerRequirement; pub use config_requirements::RequirementSource; +pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; pub use config_requirements::Sourced; pub use diagnostics::ConfigError; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index d2b0cf25f29..cbd49f131c8 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -451,6 +451,7 @@ async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Resul &requirements_file, r#" allowed_approval_policies = ["never", "on-request"] +enforce_residency = "us" "#, ) .await?; @@ -465,7 +466,6 @@ allowed_approval_policies = ["never", "on-request"] .cloned(), Some(vec![AskForApproval::Never, AskForApproval::OnRequest]) ); - let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( config_requirements.approval_policy.value(), @@ -480,6 +480,10 @@ allowed_approval_policies = ["never", "on-request"] .can_set(&AskForApproval::OnFailure) .is_err() ); + assert_eq!( + config_requirements.enforce_residency.value(), + Some(crate::config_loader::ResidencyRequirement::Us) + ); Ok(()) } @@ -503,6 +507,7 @@ allowed_approval_policies = ["on-request"] allowed_sandbox_modes: None, mcp_servers: None, rules: None, + enforce_residency: None, }, ); load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; @@ -537,6 +542,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_sandbox_modes: None, mcp_servers: None, rules: None, + enforce_residency: None, }; let expected = requirements.clone(); let cloud_requirements = CloudRequirementsLoader::new(async move { Some(requirements) }); diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 67a5daf1b17..94ecd8fcecc 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -1,6 +1,8 @@ +use crate::config_loader::ResidencyRequirement; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use codex_client::CodexHttpClient; pub use codex_client::CodexRequestBuilder; +use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; use std::sync::LazyLock; use std::sync::Mutex; @@ -24,6 +26,7 @@ use std::sync::RwLock; pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| Mutex::new(None)); pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; +pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency"; #[derive(Debug, Clone)] pub struct Originator { @@ -31,6 +34,8 @@ pub struct Originator { pub header_value: HeaderValue, } static ORIGINATOR: LazyLock>> = LazyLock::new(|| RwLock::new(None)); +static REQUIREMENTS_RESIDENCY: LazyLock>> = + LazyLock::new(|| RwLock::new(None)); #[derive(Debug)] pub enum SetOriginatorError { @@ -74,6 +79,14 @@ pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> { Ok(()) } +pub fn set_default_client_residency_requirement(enforce_residency: Option) { + let Ok(mut guard) = REQUIREMENTS_RESIDENCY.write() else { + tracing::warn!("Failed to acquire requirements residency lock"); + return; + }; + *guard = enforce_residency; +} + pub fn originator() -> Originator { if let Ok(guard) = ORIGINATOR.read() && let Some(originator) = guard.as_ref() @@ -166,10 +179,17 @@ pub fn create_client() -> CodexHttpClient { } pub fn build_reqwest_client() -> reqwest::Client { - use reqwest::header::HeaderMap; - let mut headers = HeaderMap::new(); headers.insert("originator", originator().header_value); + if let Ok(guard) = REQUIREMENTS_RESIDENCY.read() + && let Some(requirement) = guard.as_ref() + && !headers.contains_key(RESIDENCY_HEADER_NAME) + { + let value = match requirement { + ResidencyRequirement::Us => HeaderValue::from_static("us"), + }; + headers.insert(RESIDENCY_HEADER_NAME, value); + } let ua = get_codex_user_agent(); let mut builder = reqwest::Client::builder() @@ -214,6 +234,8 @@ mod tests { async fn test_create_client_sets_default_headers() { skip_if_no_network!(); + set_default_client_residency_requirement(Some(ResidencyRequirement::Us)); + use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -256,6 +278,13 @@ mod tests { .get("user-agent") .expect("user-agent header missing"); assert_eq!(ua_header.to_str().unwrap(), expected_ua); + + let residency_header = headers + .get(RESIDENCY_HEADER_NAME) + .expect("residency header missing"); + assert_eq!(residency_header.to_str().unwrap(), "us"); + + set_default_client_residency_requirement(None); } #[test] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 396cde190b6..e0f39beb230 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -66,6 +66,7 @@ use uuid::Uuid; use crate::cli::Command as ExecCommand; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; +use codex_core::default_client::set_default_client_residency_requirement; use codex_core::default_client::set_default_originator; use codex_core::find_thread_path_by_id_str; use codex_core::find_thread_path_by_name_str; @@ -265,6 +266,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .cloud_requirements(cloud_requirements) .build() .await?; + set_default_client_residency_requirement(config.enforce_residency.value()); if let Err(err) = enforce_login_restrictions(&config) { eprintln!("{err}"); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1f34ed6049f..82a06a70324 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -27,6 +27,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::format_config_error_with_source; +use codex_core::default_client::set_default_client_residency_requirement; use codex_core::find_thread_path_by_id_str; use codex_core::find_thread_path_by_name_str; use codex_core::path_utils; @@ -276,6 +277,7 @@ pub async fn run_main( cloud_requirements.clone(), ) .await; + set_default_client_residency_requirement(config.enforce_residency.value()); if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) { #[allow(clippy::print_stderr)]