"""Tests for set_config_value — verifying secrets route to .env and config to config.yaml.""" import argparse import json import os from unittest.mock import patch import pytest from hermes_cli.config import ( config_command, cron_model_drift_guard_enabled, set_config_value, ) @pytest.fixture(autouse=True) def _isolated_hermes_home(tmp_path): """Point HERMES_HOME at a temp dir so tests never touch real config.""" env_file = tmp_path / ".env" env_file.touch() with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): yield tmp_path def _read_env(tmp_path): return (tmp_path / ".env").read_text() def _read_config(tmp_path): config_path = tmp_path / "config.yaml" return config_path.read_text() if config_path.exists() else "" # --------------------------------------------------------------------------- # Explicit allowlist keys → .env # --------------------------------------------------------------------------- class TestExplicitAllowlist: """Keys in the hardcoded allowlist should always go to .env.""" @pytest.mark.parametrize("key", [ "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "HONCHO_API_KEY", "FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "FAL_KEY", "SUDO_PASSWORD", "GITHUB_TOKEN", "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", ]) def test_explicit_key_routes_to_env(self, key, _isolated_hermes_home): set_config_value(key, "test-value-123") env_content = _read_env(_isolated_hermes_home) assert f"{key}=test-value-123" in env_content # Must NOT appear in config.yaml assert key not in _read_config(_isolated_hermes_home) # --------------------------------------------------------------------------- # Catch-all patterns → .env # --------------------------------------------------------------------------- class TestCatchAllPatterns: """Any key ending in _API_KEY or _TOKEN should route to .env.""" @pytest.mark.parametrize("key", [ "DAYTONA_API_KEY", "ELEVENLABS_API_KEY", "SOME_FUTURE_SERVICE_API_KEY", "MY_CUSTOM_TOKEN", "WHATSAPP_BOT_TOKEN", ]) def test_api_key_suffix_routes_to_env(self, key, _isolated_hermes_home): set_config_value(key, "secret-456") env_content = _read_env(_isolated_hermes_home) assert f"{key}=secret-456" in env_content assert key not in _read_config(_isolated_hermes_home) # --------------------------------------------------------------------------- # Non-secret keys → config.yaml # --------------------------------------------------------------------------- class TestConfigYamlRouting: """Regular config keys should go to config.yaml, NOT .env.""" def test_simple_key(self, _isolated_hermes_home): set_config_value("model", "gpt-4o") config = _read_config(_isolated_hermes_home) assert "gpt-4o" in config assert "model" not in _read_env(_isolated_hermes_home) def test_terminal_image_goes_to_config(self, _isolated_hermes_home): """TERMINAL_DOCKER_IMAGE doesn't match _API_KEY or _TOKEN, so config.yaml.""" set_config_value("terminal.docker_image", "python:3.12") config = _read_config(_isolated_hermes_home) assert "python:3.12" in config def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_hermes_home): set_config_value("terminal.docker_mount_cwd_to_workspace", "true") config = _read_config(_isolated_hermes_home) env_content = _read_env(_isolated_hermes_home) assert "docker_mount_cwd_to_workspace: 'true'" in config or "docker_mount_cwd_to_workspace: true" in config assert ( "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=true" in env_content or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content ) # --------------------------------------------------------------------------- # Empty / falsy values — regression tests for #4277 # --------------------------------------------------------------------------- class TestFalsyValues: """config set should accept empty strings and falsy values like '0'.""" def test_config_command_rejects_missing_value(self): """config set with no value arg (None) should still exit.""" args = argparse.Namespace(config_command="set", key="model", value=None) with pytest.raises(SystemExit): config_command(args) def test_config_command_accepts_empty_string(self, _isolated_hermes_home): """config set KEY '' should not exit — it should set the value.""" args = argparse.Namespace(config_command="set", key="model", value="") config_command(args) config = _read_config(_isolated_hermes_home) assert "model" in config class TestConfigGetUnset: """config get/unset should mirror config set for scriptable workflows.""" def test_config_get_prints_resolved_nested_value(self, _isolated_hermes_home, capsys): set_config_value("terminal.timeout", "120") capsys.readouterr() args = argparse.Namespace(config_command="get", key="terminal.timeout", json=False) config_command(args) assert capsys.readouterr().out.strip() == "120" def test_config_unset_removes_yaml_key_and_synced_env(self, _isolated_hermes_home, capsys): set_config_value("terminal.backend", "docker") assert "TERMINAL_ENV=docker" in _read_env(_isolated_hermes_home) capsys.readouterr() args = argparse.Namespace(config_command="unset", key="terminal.backend") config_command(args) import yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) or {} assert reloaded == {} assert "TERMINAL_ENV=" not in _read_env(_isolated_hermes_home) assert "Unset terminal.backend" in capsys.readouterr().out def test_config_unset_removes_dotted_token_yaml_key(self, _isolated_hermes_home, capsys): (_isolated_hermes_home / "config.yaml").write_text( "platforms:\n" " teams:\n" " extra:\n" " access_token: yaml-token\n" " tenant_id: tenant\n" ) args = argparse.Namespace(config_command="unset", key="platforms.teams.extra.access_token") config_command(args) import yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) assert "access_token" not in reloaded["platforms"]["teams"]["extra"] assert reloaded["platforms"]["teams"]["extra"]["tenant_id"] == "tenant" assert "Unset platforms.teams.extra.access_token" in capsys.readouterr().out # --------------------------------------------------------------------------- # List navigation — regression tests for #17876 # --------------------------------------------------------------------------- class TestListNavigation: """hermes config set must preserve YAML list fields when using numeric indices. Before #17876, _set_nested would silently replace the entire list with a dict, destroying every sibling entry. """ def _write_config(self, tmp_path, body): (tmp_path / "config.yaml").write_text(body) def test_indexed_set_preserves_sibling_list_entries(self, _isolated_hermes_home): """Setting custom_providers.0.api_key must not destroy entry 1.""" self._write_config(_isolated_hermes_home, ( "custom_providers:\n" "- name: provider-a\n" " api_key: old-a\n" " base_url: https://a.example.com\n" "- name: provider-b\n" " api_key: old-b\n" " base_url: https://b.example.com\n" )) set_config_value("custom_providers.0.api_key", "new-a") import yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) # The list must still be a list assert isinstance(reloaded["custom_providers"], list) assert len(reloaded["custom_providers"]) == 2 # Entry 0 was updated assert reloaded["custom_providers"][0]["api_key"] == "new-a" assert reloaded["custom_providers"][0]["name"] == "provider-a" assert reloaded["custom_providers"][0]["base_url"] == "https://a.example.com" # Entry 1 is untouched assert reloaded["custom_providers"][1]["name"] == "provider-b" assert reloaded["custom_providers"][1]["api_key"] == "old-b" assert reloaded["custom_providers"][1]["base_url"] == "https://b.example.com" def test_indexed_set_preserves_non_targeted_fields(self, _isolated_hermes_home): """Setting one field in a list entry must not drop other fields.""" self._write_config(_isolated_hermes_home, ( "custom_providers:\n" "- name: provider-a\n" " api_key: old\n" " base_url: https://a.example.com\n" " models:\n" " foo: {}\n" " bar: {}\n" )) set_config_value("custom_providers.0.api_key", "rotated") import yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) entry = reloaded["custom_providers"][0] assert entry["api_key"] == "rotated" assert entry["name"] == "provider-a" assert entry["base_url"] == "https://a.example.com" assert set(entry["models"].keys()) == {"foo", "bar"} def test_deeper_nesting_through_list(self, _isolated_hermes_home): """Navigation path mixing dict → list → dict → scalar.""" self._write_config(_isolated_hermes_home, ( "telegram:\n" " allowlist:\n" " - name: alice\n" " role: admin\n" " - name: bob\n" " role: user\n" )) # NOTE: original test path was ``platforms.telegram.allowlist.1.role``, # which #34067 schema validation correctly rejects (platform configs # live at the top level, not under a ``platforms`` namespace). Use # the canonical path. set_config_value("telegram.allowlist.1.role", "admin") import yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) allowlist = reloaded["telegram"]["allowlist"] assert isinstance(allowlist, list) assert allowlist[0] == {"name": "alice", "role": "admin"} assert allowlist[1] == {"name": "bob", "role": "admin"} # --------------------------------------------------------------------------- # Cron drift guard warning — regression tests for #59031 # --------------------------------------------------------------------------- def _write_cron_jobs(tmp_path, jobs): cron_dir = tmp_path / "cron" cron_dir.mkdir(parents=True, exist_ok=True) (cron_dir / "jobs.json").write_text( json.dumps({"jobs": jobs}), encoding="utf-8", ) class TestCronModelDriftConfigWarning: """Warn operators before unpinned snapshot-bearing cron jobs fail closed.""" def test_explicit_opt_out_suppresses_warning( self, _isolated_hermes_home, capsys, ): _write_cron_jobs( _isolated_hermes_home, [ { "id": "model-drift-job", "enabled": True, "model": None, "model_snapshot": "old-model", } ], ) set_config_value("cron.model_drift_guard", "false") capsys.readouterr() set_config_value("model.default", "new-model") import yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) captured = capsys.readouterr() assert reloaded["cron"]["model_drift_guard"] is False assert "Set model.default = new-model" in captured.out assert "fail closed" not in captured.out @pytest.mark.parametrize( ("configured_value", "expected"), [ (False, False), (True, True), ("false", True), (0, True), (None, True), ], ) def test_only_literal_false_disables_guard(self, configured_value, expected): config = {"cron": {"model_drift_guard": configured_value}} assert cron_model_drift_guard_enabled(config) is expected # --------------------------------------------------------------------------- # String-typed config values — regression tests for #47515 # --------------------------------------------------------------------------- class TestStringTypedConfigValues: @pytest.mark.parametrize("value", ["off", "on", "yes", "no", "true", "false", "01"]) def test_string_typed_values_are_not_coerced(self, _isolated_hermes_home, value): """Values stay strings when DEFAULT_CONFIG declares the leaf as a string.""" set_config_value("approvals.mode", value) import yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["approvals"]["mode"] == value assert isinstance(saved["approvals"]["mode"], str) @pytest.mark.parametrize("key, value, expected", [ ("terminal.persistent_shell", "off", False), ("approvals.timeout", "30", 30), ]) def test_non_string_defaults_keep_existing_coercion( self, _isolated_hermes_home, key, value, expected ): set_config_value(key, value) import yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) node = saved for part in key.split("."): node = node[part] assert node == expected assert type(node) is type(expected) def test_unknown_keys_keep_existing_coercion(self, _isolated_hermes_home): # ``custom`` is not a known top-level key, so it now requires --force # (schema validation, #34067); coercion behavior is unchanged. set_config_value("custom.enabled", "off", force=True) import yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["custom"]["enabled"] is False # --------------------------------------------------------------------------- # Secret redaction in display output (issue #50245) # --------------------------------------------------------------------------- class TestSecretRedactionInDisplay: """`config set`/`config show` must not echo credential values in plaintext.""" def test_redact_config_value_masks_nested_api_key(self): from hermes_cli.config import redact_config_value secret = "cfut_SUPERSECRETTOKEN1234567890abcdef" model = {"default": "@cf/foo", "provider": "custom", "api_key": secret} out = redact_config_value(model) assert out["api_key"] != secret assert secret not in str(out) # Non-secret fields pass through unchanged. assert out["default"] == "@cf/foo" assert out["provider"] == "custom" def test_redact_config_value_walks_lists(self): from hermes_cli.config import redact_config_value secret = "sk-deadbeefdeadbeefdeadbeef" cfg = {"custom_providers": [{"name": "p", "api_key": secret}]} out = redact_config_value(cfg) assert secret not in str(out) assert out["custom_providers"][0]["name"] == "p" def test_redact_config_value_ignores_benign_keys(self): from hermes_cli.config import redact_config_value cfg = {"token_count": 1234, "secret_santa": "alice", "max_turns": 90} out = redact_config_value(cfg) # Exact-match only — substrings like token_count must NOT be masked. assert out == cfg def test_set_echo_masks_secret_value(self, _isolated_hermes_home, capsys): secret = "cfut_ANOTHERSECRET0987654321zyxwvu" set_config_value("model.api_key", secret) captured = capsys.readouterr() assert secret not in captured.out assert "Set model.api_key" in captured.out def test_set_echo_keeps_nonsecret_value(self, _isolated_hermes_home, capsys): set_config_value("model.reasoning_effort", "high") captured = capsys.readouterr() assert "Set model.reasoning_effort = high" in captured.out # #34067: Schema validation for unknown keys # --------------------------------------------------------------------------- class TestSchemaValidation: """#34067: ``hermes config set`` must not report bare success for unrecognized keys. The key IS written (arbitrary keys are supported — top-level scalars bridge into os.environ for skills/external apps), but a post-write notice warns that Hermes may never read it and suggests the likely-intended path. Headline case: the plausible-but-wrong ``gateway.discord.gateway_restart_notification`` (correct path: ``discord.gateway_restart_notification``). """ def test_desktop_macos_signing_identity_is_accepted(self, _isolated_hermes_home, capsys): """The documented TCC signing identity setting is part of the schema.""" set_config_value("desktop.macos_signing_identity", "Hermes Local Signing") import yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["desktop"]["macos_signing_identity"] == "Hermes Local Signing" assert "not a recognized config key" not in capsys.readouterr().out def test_force_suppresses_notice(self, _isolated_hermes_home, capsys): """``--force`` writes unknown keys without the notice (scripted forward-compat writes).""" set_config_value("brand_new_future_key", "value", force=True) out = capsys.readouterr().out assert "not a recognized config key" not in out # And the value WAS written. content = _read_config(_isolated_hermes_home) assert "brand_new_future_key" in content class TestValidateConfigKey: """Unit tests for the validator itself.""" @pytest.mark.parametrize("key", [ "model", "terminal.backend", "agent.max_turns", "discord.gateway_restart_notification", "telegram.bot_token", "mcp_servers.foo.command", "providers.openrouter.api_key", "gateway.strict", "platforms.discord.enabled", "gateway.platforms.my_platform.extra.token", "approvals.mode", ]) def test_known_keys_pass(self, key): from hermes_cli.config import _validate_config_key is_known, _ = _validate_config_key(key) assert is_known, f"Expected {key!r} to validate as known" @pytest.mark.parametrize("key,expected_in_suggestion", [ ("gateway.discord.gateway_restart_notification", None), # no close suggestion ("disco", "discord"), ("agent.max_turn", "agent.max_turns"), ]) def test_unknown_keys_with_suggestion(self, key, expected_in_suggestion): from hermes_cli.config import _validate_config_key is_known, suggestion = _validate_config_key(key) assert not is_known, f"Expected {key!r} to validate as unknown" if expected_in_suggestion is not None: assert suggestion is not None and expected_in_suggestion in suggestion, \ f"Expected suggestion to contain {expected_in_suggestion!r}, got {suggestion!r}" def test_underscore_only_first_segment_escapes(self): """The underscore escape only applies to the FIRST segment. A real typo in a sub-key (e.g. agent._max_turns) is still caught.""" from hermes_cli.config import _validate_config_key is_known, suggestion = _validate_config_key("agent._max_turns") assert not is_known, "Sub-key typo under a known top-level key must still be flagged" # --------------------------------------------------------------------------- # display.skin → touch the skin file (live re-affirm broadcast) # --------------------------------------------------------------------------- class TestDisplaySkinTouch: """Setting display.skin must bump the named skin file's mtime. The gateway's skin watcher broadcasts ``skin.changed`` on a signature move of (active name, skin-file mtime). Re-affirming the already-configured skin (`hermes config set display.skin X` while it is already X — the recovery path when a surface missed the original activation) moves NEITHER part, so without the touch the explicit apply is invisible to every live surface. """ def test_reaffirming_same_skin_moves_the_watcher_signature(self, _isolated_hermes_home): import os as _os skins = _isolated_hermes_home / "skins" skins.mkdir() skin_file = skins / "synthwave.yaml" skin_file.write_text("name: synthwave\ncolors:\n background: '#1a1030'\n") # Age the file so an mtime bump is unambiguous even on coarse clocks. _os.utime(skin_file, (1_000_000_000, 1_000_000_000)) set_config_value("display.skin", "synthwave") first = skin_file.stat().st_mtime assert first > 1_000_000_000 _os.utime(skin_file, (1_000_000_000, 1_000_000_000)) set_config_value("display.skin", "synthwave") # same name, re-affirmed assert skin_file.stat().st_mtime > 1_000_000_000 def test_builtin_or_missing_skin_file_is_fine(self, _isolated_hermes_home): """Built-ins have no user file — the set must still succeed cleanly.""" set_config_value("display.skin", "mono") assert "skin: mono" in _read_config(_isolated_hermes_home) def test_touch_preserves_skin_file_contents(self, _isolated_hermes_home): skins = _isolated_hermes_home / "skins" skins.mkdir() body = "name: neon\ncolors:\n ui_accent: '#ff33aa'\n" (skins / "neon.yaml").write_text(body) set_config_value("display.skin", "neon") assert (skins / "neon.yaml").read_text() == body