/ tests / hermes_cli / test_set_config_value.py
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"}