Validate thread providers before resume and input (#57840)

## Why

Existing chats retain their provider configuration when the selected provider changes. Reject resume and input until the configured provider matches the chat, while keeping history readable and allowing recovery after configuration is restored.

## What changed

- Validate saved providers before applying resume overrides, preserving the provider when only model or reasoning settings change.
- Check the current provider selection, including profiles and launch overrides, before accepting input. Reject input when configuration cannot be loaded.
- Return structured `ModelProviderError` data for provider mismatches, missing definitions, and policy conflicts, with generated JSON Schema, TypeScript, and Python types.

## Testing

Add coverage for override bypass prevention, provider restoration, readable history after rejection, and blocked requests sending no inference traffic. Verify schema fields and Python round trips match the error wire format.

GitOrigin-RevId: 051f97c3815c547d8411c71b770df30eae969f68
This commit is contained in:
Abhinav
2026-10-28 21:33:02 +00:00
committed by copyberry
parent c62e9df39f
commit 7ce5fa7bdc
31 changed files with 1100 additions and 91 deletions
+134 -8
View File
@@ -1,3 +1,4 @@
use codex_app_server_protocol::ModelProviderError;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::managed_config_bundle_loader;
use codex_config::CloudConfigBundleLoader;
@@ -38,12 +39,6 @@ use tracing::warn;
#[path = "application_network.rs"]
pub(crate) mod application_network;
#[derive(Debug, thiserror::Error)]
#[error(
"Your organization's required model provider settings changed. Restart Codex to apply them; this request was not sent"
)]
pub(crate) struct ModelProviderRequirementsChanged;
/// Shared app-server entry point for loading effective Codex configuration.
#[derive(Clone)]
pub(crate) struct ConfigManager {
@@ -408,7 +403,12 @@ impl ConfigManager {
if selection_changed || definition_changed {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
ModelProviderRequirementsChanged,
ModelProviderError::ModelProviderPolicyConflict {
model_provider: current.model_provider_id.clone(),
required_model_provider: requirements
.model_provider
.unwrap_or_else(|| current.model_provider_id.clone()),
},
));
}
Ok(())
@@ -430,6 +430,27 @@ impl ConfigManager {
Ok(CloudStartupConfig { bootstrap, config })
}
/// Admits resumed threads and input only on the currently selected provider.
pub(crate) async fn check_thread_model_provider_for_input(
&self,
current: &Config,
) -> std::io::Result<()> {
self.check_thread_model_provider(current).await?;
let configured = self
.load_latest_config(Some(current.cwd.to_path_buf()))
.await?;
if current.model_provider_id != configured.model_provider_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
ModelProviderError::ModelProviderMismatch {
model_provider: current.model_provider_id.clone(),
current_model_provider: configured.model_provider_id,
},
));
}
Ok(())
}
/// Installs local policy before cloud access; failed policy loads block traffic while
/// non-strict startup remains available for configuration repair.
pub(crate) async fn load_startup_config(
@@ -514,6 +535,89 @@ impl ConfigManager {
})
}
/// Validates saved and requested providers before resume overrides or policy can replace them.
pub(crate) async fn load_for_resume(
&self,
saved_provider: Option<&str>,
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
let requested_provider = typesafe_overrides.model_provider.clone().or_else(|| {
request_overrides
.as_ref()
.and_then(|values| values.get("model_provider"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
});
// Resolve the selection before the saved provider is applied as a resume override.
let current_provider = self
.load_latest_config(typesafe_overrides.cwd.clone().or_else(|| cwd.clone()))
.await
.map(|config| config.model_provider_id);
self.load_with_validation(
&self.current_cli_overrides(),
request_overrides,
typesafe_overrides,
cwd,
|config, _overrides, layers| {
let requested_provider = requested_provider
.as_deref()
.or(config.model_provider.as_deref())
.unwrap_or("openai");
// Validate the saved route even when a client sends a provider override.
for provider in [
saved_provider.unwrap_or(requested_provider),
requested_provider,
] {
if let Some(required) = layers.required_model_provider()
&& required != provider
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
ModelProviderError::ModelProviderPolicyConflict {
model_provider: provider.to_owned(),
required_model_provider: required.to_owned(),
},
));
}
if !config.model_providers.contains_key(provider)
&& !built_in_model_providers(config.openai_base_url.clone())
.contains_key(provider)
{
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
ModelProviderError::ModelProviderNotConfigured {
model_provider: provider.to_owned(),
},
));
}
}
let current_provider = current_provider?;
for provider in [
saved_provider.unwrap_or(requested_provider),
requested_provider,
] {
if provider != current_provider {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
ModelProviderError::ModelProviderMismatch {
model_provider: provider.to_owned(),
current_model_provider: current_provider,
},
));
}
}
Ok(())
},
)
.await
.and_then(|config| {
config.validate_windows_mxc_requirement()?;
Ok(config)
})
}
/// Reload sources using the task's session flags before materializing config.
pub(crate) async fn load_permission_config_for_thread(
&self,
@@ -558,11 +662,33 @@ impl ConfigManager {
#[instrument(level = "trace", skip_all)]
pub(crate) async fn load_with_cli_overrides(
&self,
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
fallback_cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
self.load_with_validation(
cli_overrides,
request_overrides,
typesafe_overrides,
fallback_cwd,
|_, _, _| Ok(()),
)
.await
}
async fn load_with_validation(
&self,
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
mut typesafe_overrides: ConfigOverrides,
fallback_cwd: Option<PathBuf>,
validate: impl FnOnce(
&codex_config::config_toml::ConfigToml,
&ConfigOverrides,
&ConfigLayerStack,
) -> std::io::Result<()>,
) -> std::io::Result<Config> {
let mcp_ema_cancellation = self.capture_mcp_ema_cancellation();
let policy_load = self.refresh_application_network_policy().await?;
@@ -593,7 +719,7 @@ impl ConfigManager {
.fallback_cwd(fallback_cwd)
.cloud_config_bundle(policy_load.cloud_config.clone())
.thread_config_loader(Arc::clone(&self.thread_config_loader))
.build()
.build_with_validation(validate)
.await;
let mut config = result?;
self.check_application_policy_load(&policy_load)?;
@@ -1,4 +1,4 @@
//! Checks retained providers against managed policy without reloading session configuration.
//! Checks retained providers against managed policy and the current provider selection.
use super::*;
use anyhow::Result;
@@ -261,3 +261,62 @@ async fn catalog_reload_uses_latest_model_provider() -> Result<()> {
assert_eq!(refreshed.model_provider_id, "openai");
Ok(())
}
/// Invocation overrides and selected profiles still define the current provider.
#[test_case("model_provider", "gateway"; "explicit_provider")]
#[test_case("profile", "gateway-profile"; "selected_profile")]
#[tokio::test]
async fn input_provider_check_uses_effective_selection(key: &str, value: &str) -> Result<()> {
let home = tempdir()?;
std::fs::write(
home.path().join("config.toml"),
r#"
model_provider = "openai"
[model_providers.gateway]
name = "Gateway"
"#,
)?;
let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let cli_overrides = if key == "profile" {
std::fs::write(
home.path().join(format!("{value}.config.toml")),
"model_provider = 'gateway'\n[model_providers.gateway]\nname = 'Gateway'\n",
)?;
loader_overrides.user_config_path = Some(AbsolutePathBuf::from_absolute_path(
home.path().join(format!("{value}.config.toml")),
)?);
loader_overrides.user_config_profile = Some(value.parse()?);
Vec::new()
} else {
vec![(key.into(), TomlValue::String(value.into()))]
};
let manager = ConfigManager::new_for_tests(
home.path().to_path_buf(),
cli_overrides,
loader_overrides,
CloudConfigBundleLoader::default(),
);
let current = manager
.load_latest_config(Some(home.path().to_path_buf()))
.await?;
assert_eq!(current.model_provider_id, "gateway");
manager
.check_thread_model_provider_for_input(&current)
.await?;
let default_manager =
ConfigManager::without_managed_config_for_tests(home.path().to_path_buf());
let error = default_manager
.check_thread_model_provider_for_input(&current)
.await
.unwrap_err();
assert_eq!(
error
.get_ref()
.and_then(|error| error.downcast_ref::<ModelProviderError>()),
Some(&ModelProviderError::ModelProviderMismatch {
model_provider: "gateway".into(),
current_model_provider: "openai".into(),
})
);
Ok(())
}
@@ -266,11 +266,23 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
}
})
.await?;
assert_eq!(error, JSONRPCErrorError {
code: -32600,
message: "failed to load configuration: Your organization's required model provider settings changed. Restart Codex to apply them; this request was not sent".to_string(),
data: None,
});
assert_eq!(
error,
JSONRPCErrorError {
code: -32600,
message: codex_app_server_protocol::ModelProviderError::ModelProviderPolicyConflict {
model_provider: "gateway".into(),
required_model_provider: "openai".into(),
}
.to_string(),
data: Some(serde_json::to_value(
codex_app_server_protocol::ModelProviderError::ModelProviderPolicyConflict {
model_provider: "gateway".into(),
required_model_provider: "openai".into(),
}
)?),
}
);
assert!(server.received_requests().await.unwrap().is_empty());
processor.shutdown_threads().await;
processor.drain_background_tasks().await;
@@ -14,6 +14,19 @@ fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBu
}
pub(crate) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
let mut current = err
.get_ref()
.map(|error| error as &(dyn std::error::Error + 'static));
while let Some(source) = current {
if let Some(provider_error) =
source.downcast_ref::<codex_app_server_protocol::ModelProviderError>()
{
let mut error = invalid_request(provider_error.to_string());
error.data = serde_json::to_value(provider_error).ok();
return error;
}
current = source.source();
}
let data = cloud_config_bundle_load_error(err).map(|cloud_error| {
let mut data = serde_json::json!({
"reason": "cloudConfigBundle",
@@ -162,7 +162,7 @@ impl ThreadGoalRequestProcessor {
&& let Ok(thread) = self.thread_manager.get_thread(thread_id).await
{
self.config_manager
.check_thread_model_provider(thread.config().await.as_ref())
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
}
@@ -247,12 +247,23 @@ fn merge_persisted_resume_metadata(
typesafe_overrides: &mut ConfigOverrides,
persisted_metadata: &ThreadMetadata,
) {
if has_model_resume_override(request_overrides.as_ref(), typesafe_overrides) {
let has_model_override =
has_model_resume_override(request_overrides.as_ref(), typesafe_overrides);
// Model and reasoning overrides do not imply a provider change.
if typesafe_overrides.model_provider.is_none()
&& !request_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("model_provider"))
{
typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone());
}
if has_model_override {
return;
}
typesafe_overrides.model = persisted_metadata.model.clone();
typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone());
if let Some(reasoning_effort) = persisted_metadata.reasoning_effort.as_ref() {
request_overrides.get_or_insert_with(HashMap::new).insert(
@@ -292,7 +303,9 @@ fn has_model_resume_override(
) -> bool {
typesafe_overrides.model.is_some()
|| typesafe_overrides.model_provider.is_some()
|| request_overrides.is_some_and(|overrides| overrides.contains_key("model"))
|| request_overrides.is_some_and(|overrides| {
overrides.contains_key("model") || overrides.contains_key("model_provider")
})
|| request_overrides
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"))
}
@@ -2564,7 +2577,7 @@ impl ThreadRequestProcessor {
let (_, thread) = self.load_thread(&thread_id).await?;
ensure_direct_input_allowed(thread.as_ref()).await?;
self.config_manager
.check_thread_model_provider(thread.config().await.as_ref())
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
self.submit_core_op(request_id, thread.as_ref(), Op::Compact)
@@ -4252,7 +4265,32 @@ impl ThreadRequestProcessor {
drop(_thread_list_state_permit);
let config = self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.load_for_resume(
resume_source_thread
.as_ref()
.map(|thread| thread.model_provider.as_str())
.filter(|provider| !provider.is_empty())
.or_else(|| {
config_state
.persisted_metadata
.as_ref()
.map(|metadata| metadata.model_provider.as_str())
})
.or_else(|| match &thread_history {
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta) => {
meta.meta.model_provider.as_deref()
}
_ => None,
})
}
_ => None,
}),
request_overrides,
typesafe_overrides,
history_cwd,
)
.await
.map_err(|err| config_load_error(&err))?;
*prepared_config = Some(PreparedResumeConfig {
@@ -4710,6 +4748,10 @@ impl ThreadRequestProcessor {
mismatch_details.join("; ")
);
}
self.config_manager
.check_thread_model_provider_for_input(existing_thread.config().await.as_ref())
.await
.map_err(|err| config_load_error(&err))?;
let redact_resume_payloads =
should_redact_thread_resume_payloads(app_server_client_name.as_deref());
let include_turns = !params.exclude_turns;
@@ -858,7 +858,10 @@ mod thread_processor_behavior_tests {
);
assert_eq!(typesafe_overrides.model, Some("gpt-5.2-codex".to_string()));
assert_eq!(typesafe_overrides.model_provider, None);
assert_eq!(
typesafe_overrides.model_provider,
Some("mock_provider".to_string())
);
assert_eq!(
request_overrides,
Some(HashMap::from([(
@@ -870,8 +873,7 @@ mod thread_processor_behavior_tests {
}
#[test]
fn merge_persisted_resume_metadata_skips_persisted_values_when_model_overridden() -> Result<()>
{
fn merge_persisted_resume_metadata_preserves_provider_when_model_overridden() -> Result<()> {
let mut request_overrides = Some(HashMap::from([(
"model".to_string(),
serde_json::Value::String("gpt-5.2-codex".to_string()),
@@ -887,7 +889,10 @@ mod thread_processor_behavior_tests {
);
assert_eq!(typesafe_overrides.model, None);
assert_eq!(typesafe_overrides.model_provider, None);
assert_eq!(
typesafe_overrides.model_provider,
Some("mock_provider".to_string())
);
assert_eq!(
request_overrides,
Some(HashMap::from([(
@@ -922,7 +927,7 @@ mod thread_processor_behavior_tests {
}
#[test]
fn merge_persisted_resume_metadata_skips_persisted_values_when_reasoning_effort_overridden()
fn merge_persisted_resume_metadata_preserves_provider_when_reasoning_effort_overridden()
-> Result<()> {
let mut request_overrides = Some(HashMap::from([(
"model_reasoning_effort".to_string(),
@@ -939,7 +944,10 @@ mod thread_processor_behavior_tests {
);
assert_eq!(typesafe_overrides.model, None);
assert_eq!(typesafe_overrides.model_provider, None);
assert_eq!(
typesafe_overrides.model_provider,
Some("mock_provider".to_string())
);
assert_eq!(
request_overrides,
Some(HashMap::from([(
@@ -193,7 +193,7 @@ impl ThreadQueueRequestProcessor {
let thread = loaded_thread
.ok_or_else(|| invalid_request("resume the thread before starting a queued message"))?;
self.config_manager
.check_thread_model_provider(thread.config().await.as_ref())
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
let submission = self
@@ -338,7 +338,7 @@ impl TurnRequestProcessor {
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
self.config_manager
.check_thread_model_provider(thread.config().await.as_ref())
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
let (review_request, display_text) = Self::review_request_from_target(params.target)?;
@@ -508,7 +508,7 @@ impl TurnRequestProcessor {
self.ensure_direct_input_allowed(&request_id, thread.as_ref())
.await?;
self.config_manager
.check_thread_model_provider(thread.config().await.as_ref())
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
if let Some(tool_output) = &params.tool_output {
@@ -1037,7 +1037,7 @@ impl TurnRequestProcessor {
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
self.config_manager
.check_thread_model_provider(thread.config().await.as_ref())
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;