diff --git a/codex-rs/config/src/thread_config.rs b/codex-rs/config/src/thread_config.rs index 1b3ea8fe87..556619357a 100644 --- a/codex-rs/config/src/thread_config.rs +++ b/codex-rs/config/src/thread_config.rs @@ -187,6 +187,34 @@ fn session_thread_config_to_toml( } if !config.model_providers.is_empty() { + // Session-scoped providers must not pair a session-provided base_url with + // process-owned credentials from auth.json or environment variables. + if let Some((provider_id, field)) = config + .model_providers + .iter() + .filter_map(|(id, provider)| { + let field = if provider.requires_openai_auth { + Some("requires_openai_auth") + } else if provider.env_key.is_some() { + Some("env_key") + } else if provider.env_http_headers.is_some() { + Some("env_http_headers") + } else { + None + }; + field.map(|field| (id, field)) + }) + .min_by(|(left, _), (right, _)| left.cmp(right)) + { + return Err(ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Parse, + /*status_code*/ None, + format!( + "session model_providers.{provider_id} cannot set {field}; \ +session-scoped providers must not read process-owned credentials", + ), + )); + } let model_providers = TomlValue::try_from(config.model_providers).map_err(|err| { ThreadConfigLoadError::new( ThreadConfigLoadErrorCode::Parse, @@ -294,6 +322,49 @@ mod tests { ); } + #[tokio::test] + async fn loader_rejects_session_model_provider_process_credentials() { + let mut openai_auth_provider = test_provider("local"); + openai_auth_provider.requires_openai_auth = true; + let mut env_key_provider = test_provider("local"); + env_key_provider.env_key = Some("OPENAI_API_KEY".to_string()); + let mut env_header_provider = test_provider("local"); + env_header_provider.env_http_headers = Some(HashMap::from([( + "Authorization".to_string(), + "OPENAI_API_KEY".to_string(), + )])); + + for (field, provider) in [ + ("requires_openai_auth", openai_auth_provider), + ("env_key", env_key_provider), + ("env_http_headers", env_header_provider), + ] { + let loader = StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session( + SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), provider)]), + features: BTreeMap::new(), + }, + )]); + let err = loader + .load_config_layers(ThreadConfigContext::default()) + .await + .expect_err("session-scoped providers must not read process credentials"); + + assert_eq!( + err, + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Parse, + /*status_code*/ None, + format!( + "session model_providers.local cannot set {field}; \ +session-scoped providers must not read process-owned credentials", + ), + ) + ); + } + } + fn test_provider(name: &str) -> ModelProviderInfo { ModelProviderInfo { name: name.to_string(), diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 3c7f4dbd0f..dabc5e3a41 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -83,6 +83,12 @@ pub(crate) fn resolve_provider_auth( return Ok(Arc::new(auth)); } + let auth = if provider.requires_openai_auth { + auth + } else { + None + }; + Ok(match auth { Some(auth) => auth_provider_from_auth(auth), None => unauthenticated_auth_provider(), diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 1ef7c22962..d3320dd498 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -475,6 +475,25 @@ mod tests { ); } + #[tokio::test] + async fn custom_non_openai_provider_does_not_use_base_auth_manager() { + let provider = create_model_provider( + provider_for("http://localhost:1234/v1".to_string()), + Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key( + "openai-api-key", + ))), + ); + + assert!( + provider + .api_auth() + .await + .expect("auth should resolve") + .to_auth_headers() + .is_empty() + ); + } + #[test] fn amazon_bedrock_provider_returns_bedrock_account_state() { let provider = create_model_provider(