diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index d47eb46ba..2e9a305b5 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -608,6 +608,36 @@ fn legacy_custom_inference_provider_string(config: &Config) -> Option { .map(|entry| cloud_entry_provider_string(entry, config)) } +/// Resolve the slug of the cloud-provider entry that represents the legacy +/// direct-inference route — the entry whose endpoint matches the configured +/// custom `inference_url`. +/// +/// Top-level `config.api_key` was historically paired with `inference_url` +/// for direct endpoint routing, so it is scoped to this single provider. The +/// `lookup_key_for_slug` fallback uses this to avoid leaking the global key to +/// any other provider slug whose auth-profile lookup returned empty. +fn legacy_inference_slug(config: &Config) -> Option<&str> { + let inference_url = config + .inference_url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty())?; + + if looks_like_openhuman_backend(inference_url) { + return None; + } + + let normalized_inference = normalize_endpoint_for_compare(inference_url); + config + .cloud_providers + .iter() + .find(|entry| { + !is_openhuman_cloud_entry(entry) + && normalize_endpoint_for_compare(&entry.endpoint) == normalized_inference + }) + .map(|entry| entry.slug.as_str()) +} + fn cloud_entry_provider_string( entry: &crate::openhuman::config::schema::cloud_providers::CloudProviderCreds, config: &Config, @@ -949,6 +979,28 @@ pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result Config { + let mut custom = openai_entry("p_custom", "custom"); + custom.endpoint = "https://inference.example.com/v1".to_string(); + let config = config_with_providers_in_tempdir( + tmp, + vec![custom, anthropic_entry("p_anthropic", "anthropic")], + ); + let mut config = config; + config.api_key = Some("global-key".to_string()); + config.inference_url = Some("https://inference.example.com/v1".to_string()); + config +} + +/// The legacy direct-inference slug — the provider whose endpoint matches +/// `config.inference_url` — inherits the global `config.api_key`. +#[test] +fn config_api_key_fallback_applies_to_legacy_inference_slug() { + let tmp = TempDir::new().expect("tempdir"); + let config = config_for_api_key_fallback(&tmp); + assert_eq!( + lookup_key_for_slug("custom", &config).expect("lookup must succeed"), + "global-key", + "legacy direct-inference slug must inherit config.api_key fallback", + ); +} + +/// Load-bearing negative assertion: a provider whose endpoint does NOT match +/// `config.inference_url` must NOT inherit the global `config.api_key`. +/// Without this guard the fallback would leak one provider's credential to +/// every other provider (cross-provider credential leak, PR #2724). +#[test] +fn config_api_key_fallback_does_not_leak_to_other_slugs() { + let tmp = TempDir::new().expect("tempdir"); + let config = config_for_api_key_fallback(&tmp); + assert_eq!( + lookup_key_for_slug("anthropic", &config).expect("lookup must succeed"), + "", + "non-matching slug must NOT inherit config.api_key — would leak credentials", + ); +} + +/// When `inference_url` itself is unset, the `config.api_key` fallback never +/// fires (no legacy direct-inference slug to scope to), so no slug inherits it. +#[test] +fn config_api_key_fallback_inert_without_inference_url() { + let tmp = TempDir::new().expect("tempdir"); + let mut config = config_for_api_key_fallback(&tmp); + config.inference_url = None; + assert_eq!( + lookup_key_for_slug("custom", &config).expect("lookup must succeed"), + "", + "without inference_url there is no legacy slug — fallback must stay inert", + ); +}