diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 663c89a38fe..d139e2e0b14 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1280,6 +1280,15 @@ def _normalize_custom_provider_entry( if not isinstance(entry, dict): return None + # Shallow-copy before the alias normalization below writes into the + # entry: callers (get_compatible_custom_providers, + # providers_dict_to_custom_providers) pass live sub-dicts from + # load_config_readonly()'s shared cache, and mutating those both + # violates the cache's no-mutation contract and leaks duplicated + # alias keys back into config.yaml through any later + # save_config(load_config()) round-trip. + entry = dict(entry) + # Accept camelCase aliases commonly used in hand-written configs. _CAMEL_ALIASES: Dict[str, str] = { "apiKey": "api_key", diff --git a/tests/hermes_cli/test_provider_config_validation.py b/tests/hermes_cli/test_provider_config_validation.py index e8892ae7846..298fc77b0d8 100644 --- a/tests/hermes_cli/test_provider_config_validation.py +++ b/tests/hermes_cli/test_provider_config_validation.py @@ -284,3 +284,56 @@ class TestNormalizeCustomProviderEntry: } result = _normalize_custom_provider_entry(entry, provider_key="bad") assert result is None + + +class TestNormalizeDoesNotMutateInput: + """The normalizer must not write alias keys into the caller's dict. + + get_compatible_custom_providers and providers_dict_to_custom_providers + pass live sub-dicts from load_config_readonly()'s shared cache; an + in-place write violates the cache's no-mutation contract and leaks + duplicated alias keys into config.yaml via save_config(load_config()). + """ + + def test_camel_case_entry_is_not_mutated(self): + entry = { + "name": "x", + "baseUrl": "https://api.example.com/v1", + "apiKeyEnv": "MY_KEY", + } + snapshot = dict(entry) + result = _normalize_custom_provider_entry(entry, provider_key="x") + assert result is not None + assert result["base_url"] == "https://api.example.com/v1" + assert entry == snapshot + + def test_api_key_env_alias_entry_is_not_mutated(self): + entry = { + "name": "x", + "base_url": "https://api.example.com/v1", + "api_key_env": "MY_KEY", + } + snapshot = dict(entry) + result = _normalize_custom_provider_entry(entry, provider_key="x") + assert result is not None + assert result["key_env"] == "MY_KEY" + assert entry == snapshot + + def test_get_compatible_custom_providers_does_not_mutate_config(self): + from hermes_cli.config import get_compatible_custom_providers + + config = { + "custom_providers": [ + { + "name": "ollama", + "baseUrl": "https://ollama.example.com/v1", + "apiKeyEnv": "OLLAMA_KEY", + } + ] + } + import copy + + snapshot = copy.deepcopy(config) + providers = get_compatible_custom_providers(config) + assert providers, "entry should normalize successfully" + assert config == snapshot