test_set_config_value.py
1 """Tests for set_config_value — verifying secrets route to .env and config to config.yaml.""" 2 3 import argparse 4 import os 5 from pathlib import Path 6 from unittest.mock import patch, call 7 8 import pytest 9 10 from hermes_cli.config import set_config_value, config_command 11 12 13 @pytest.fixture(autouse=True) 14 def _isolated_hermes_home(tmp_path): 15 """Point HERMES_HOME at a temp dir so tests never touch real config.""" 16 env_file = tmp_path / ".env" 17 env_file.touch() 18 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 19 yield tmp_path 20 21 22 def _read_env(tmp_path): 23 return (tmp_path / ".env").read_text() 24 25 26 def _read_config(tmp_path): 27 config_path = tmp_path / "config.yaml" 28 return config_path.read_text() if config_path.exists() else "" 29 30 31 # --------------------------------------------------------------------------- 32 # Explicit allowlist keys → .env 33 # --------------------------------------------------------------------------- 34 35 class TestExplicitAllowlist: 36 """Keys in the hardcoded allowlist should always go to .env.""" 37 38 @pytest.mark.parametrize("key", [ 39 "OPENROUTER_API_KEY", 40 "OPENAI_API_KEY", 41 "ANTHROPIC_API_KEY", 42 "WANDB_API_KEY", 43 "TINKER_API_KEY", 44 "HONCHO_API_KEY", 45 "FIRECRAWL_API_KEY", 46 "BROWSERBASE_API_KEY", 47 "FAL_KEY", 48 "SUDO_PASSWORD", 49 "GITHUB_TOKEN", 50 "TELEGRAM_BOT_TOKEN", 51 "DISCORD_BOT_TOKEN", 52 "SLACK_BOT_TOKEN", 53 "SLACK_APP_TOKEN", 54 ]) 55 def test_explicit_key_routes_to_env(self, key, _isolated_hermes_home): 56 set_config_value(key, "test-value-123") 57 env_content = _read_env(_isolated_hermes_home) 58 assert f"{key}=test-value-123" in env_content 59 # Must NOT appear in config.yaml 60 assert key not in _read_config(_isolated_hermes_home) 61 62 63 # --------------------------------------------------------------------------- 64 # Catch-all patterns → .env 65 # --------------------------------------------------------------------------- 66 67 class TestCatchAllPatterns: 68 """Any key ending in _API_KEY or _TOKEN should route to .env.""" 69 70 @pytest.mark.parametrize("key", [ 71 "DAYTONA_API_KEY", 72 "ELEVENLABS_API_KEY", 73 "SOME_FUTURE_SERVICE_API_KEY", 74 "MY_CUSTOM_TOKEN", 75 "WHATSAPP_BOT_TOKEN", 76 ]) 77 def test_api_key_suffix_routes_to_env(self, key, _isolated_hermes_home): 78 set_config_value(key, "secret-456") 79 env_content = _read_env(_isolated_hermes_home) 80 assert f"{key}=secret-456" in env_content 81 assert key not in _read_config(_isolated_hermes_home) 82 83 def test_case_insensitive(self, _isolated_hermes_home): 84 """Keys should be uppercased regardless of input casing.""" 85 set_config_value("openai_api_key", "sk-test") 86 env_content = _read_env(_isolated_hermes_home) 87 assert "OPENAI_API_KEY=sk-test" in env_content 88 89 def test_terminal_ssh_prefix_routes_to_env(self, _isolated_hermes_home): 90 set_config_value("TERMINAL_SSH_PORT", "2222") 91 env_content = _read_env(_isolated_hermes_home) 92 assert "TERMINAL_SSH_PORT=2222" in env_content 93 94 95 # --------------------------------------------------------------------------- 96 # Non-secret keys → config.yaml 97 # --------------------------------------------------------------------------- 98 99 class TestConfigYamlRouting: 100 """Regular config keys should go to config.yaml, NOT .env.""" 101 102 def test_simple_key(self, _isolated_hermes_home): 103 set_config_value("model", "gpt-4o") 104 config = _read_config(_isolated_hermes_home) 105 assert "gpt-4o" in config 106 assert "model" not in _read_env(_isolated_hermes_home) 107 108 def test_nested_key(self, _isolated_hermes_home): 109 set_config_value("terminal.backend", "docker") 110 config = _read_config(_isolated_hermes_home) 111 assert "docker" in config 112 assert "terminal" not in _read_env(_isolated_hermes_home) 113 114 def test_terminal_image_goes_to_config(self, _isolated_hermes_home): 115 """TERMINAL_DOCKER_IMAGE doesn't match _API_KEY or _TOKEN, so config.yaml.""" 116 set_config_value("terminal.docker_image", "python:3.12") 117 config = _read_config(_isolated_hermes_home) 118 assert "python:3.12" in config 119 120 def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_hermes_home): 121 set_config_value("terminal.docker_mount_cwd_to_workspace", "true") 122 config = _read_config(_isolated_hermes_home) 123 env_content = _read_env(_isolated_hermes_home) 124 assert "docker_mount_cwd_to_workspace: 'true'" in config or "docker_mount_cwd_to_workspace: true" in config 125 assert ( 126 "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=true" in env_content 127 or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content 128 ) 129 130 def test_terminal_vercel_runtime_goes_to_config_and_env(self, _isolated_hermes_home): 131 set_config_value("terminal.vercel_runtime", "python3.13") 132 config = _read_config(_isolated_hermes_home) 133 env_content = _read_env(_isolated_hermes_home) 134 assert "vercel_runtime: python3.13" in config 135 assert "TERMINAL_VERCEL_RUNTIME=python3.13" in env_content 136 137 138 # --------------------------------------------------------------------------- 139 # Empty / falsy values — regression tests for #4277 140 # --------------------------------------------------------------------------- 141 142 class TestFalsyValues: 143 """config set should accept empty strings and falsy values like '0'.""" 144 145 def test_empty_string_routes_to_env(self, _isolated_hermes_home): 146 """Blanking an API key should write an empty value to .env.""" 147 set_config_value("OPENROUTER_API_KEY", "") 148 env_content = _read_env(_isolated_hermes_home) 149 assert "OPENROUTER_API_KEY=" in env_content 150 151 def test_empty_string_routes_to_config(self, _isolated_hermes_home): 152 """Blanking a config key should write an empty string to config.yaml.""" 153 set_config_value("model", "") 154 config = _read_config(_isolated_hermes_home) 155 assert "model: ''" in config or "model: \"\"" in config 156 157 def test_zero_routes_to_config(self, _isolated_hermes_home): 158 """Setting a config key to '0' should write 0 to config.yaml.""" 159 set_config_value("verbose", "0") 160 config = _read_config(_isolated_hermes_home) 161 assert "verbose: 0" in config 162 163 def test_config_command_rejects_missing_value(self): 164 """config set with no value arg (None) should still exit.""" 165 args = argparse.Namespace(config_command="set", key="model", value=None) 166 with pytest.raises(SystemExit): 167 config_command(args) 168 169 def test_config_command_accepts_empty_string(self, _isolated_hermes_home): 170 """config set KEY '' should not exit — it should set the value.""" 171 args = argparse.Namespace(config_command="set", key="model", value="") 172 config_command(args) 173 config = _read_config(_isolated_hermes_home) 174 assert "model" in config 175 176 177 # --------------------------------------------------------------------------- 178 # List navigation — regression tests for #17876 179 # --------------------------------------------------------------------------- 180 181 class TestListNavigation: 182 """hermes config set must preserve YAML list fields when using numeric 183 indices. Before #17876, _set_nested would silently replace the entire 184 list with a dict, destroying every sibling entry. 185 """ 186 187 def _write_config(self, tmp_path, body): 188 (tmp_path / "config.yaml").write_text(body) 189 190 def test_indexed_set_preserves_sibling_list_entries(self, _isolated_hermes_home): 191 """Setting custom_providers.0.api_key must not destroy entry 1.""" 192 self._write_config(_isolated_hermes_home, ( 193 "custom_providers:\n" 194 "- name: provider-a\n" 195 " api_key: old-a\n" 196 " base_url: https://a.example.com\n" 197 "- name: provider-b\n" 198 " api_key: old-b\n" 199 " base_url: https://b.example.com\n" 200 )) 201 202 set_config_value("custom_providers.0.api_key", "new-a") 203 204 import yaml 205 reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) 206 # The list must still be a list 207 assert isinstance(reloaded["custom_providers"], list) 208 assert len(reloaded["custom_providers"]) == 2 209 # Entry 0 was updated 210 assert reloaded["custom_providers"][0]["api_key"] == "new-a" 211 assert reloaded["custom_providers"][0]["name"] == "provider-a" 212 assert reloaded["custom_providers"][0]["base_url"] == "https://a.example.com" 213 # Entry 1 is untouched 214 assert reloaded["custom_providers"][1]["name"] == "provider-b" 215 assert reloaded["custom_providers"][1]["api_key"] == "old-b" 216 assert reloaded["custom_providers"][1]["base_url"] == "https://b.example.com" 217 218 def test_indexed_set_preserves_non_targeted_fields(self, _isolated_hermes_home): 219 """Setting one field in a list entry must not drop other fields.""" 220 self._write_config(_isolated_hermes_home, ( 221 "custom_providers:\n" 222 "- name: provider-a\n" 223 " api_key: old\n" 224 " base_url: https://a.example.com\n" 225 " models:\n" 226 " foo: {}\n" 227 " bar: {}\n" 228 )) 229 230 set_config_value("custom_providers.0.api_key", "rotated") 231 232 import yaml 233 reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) 234 entry = reloaded["custom_providers"][0] 235 assert entry["api_key"] == "rotated" 236 assert entry["name"] == "provider-a" 237 assert entry["base_url"] == "https://a.example.com" 238 assert set(entry["models"].keys()) == {"foo", "bar"} 239 240 def test_deeper_nesting_through_list(self, _isolated_hermes_home): 241 """Navigation path mixing dict → list → dict → scalar.""" 242 self._write_config(_isolated_hermes_home, ( 243 "platforms:\n" 244 " telegram:\n" 245 " allowlist:\n" 246 " - name: alice\n" 247 " role: admin\n" 248 " - name: bob\n" 249 " role: user\n" 250 )) 251 252 set_config_value("platforms.telegram.allowlist.1.role", "admin") 253 254 import yaml 255 reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) 256 allowlist = reloaded["platforms"]["telegram"]["allowlist"] 257 assert isinstance(allowlist, list) 258 assert allowlist[0] == {"name": "alice", "role": "admin"} 259 assert allowlist[1] == {"name": "bob", "role": "admin"}