"""Tests for hermes_cli configuration management.""" import os from pathlib import Path from unittest.mock import patch import pytest import yaml from hermes_cli.config import ( DEFAULT_CONFIG, check_config_version, get_hermes_home, ensure_hermes_home, get_compatible_custom_providers, _explicit_config_paths, _normalize_max_turns_config, is_provider_enabled, load_config, load_env, migrate_config, read_raw_config, remove_env_value, save_config, save_env_value, save_env_value_secure, sanitize_env_file, set_config_value, write_platform_config_field, _sanitize_env_lines, ) class TestGetHermesHome: def test_default_path(self): with patch.dict(os.environ, {}, clear=False): os.environ.pop("HERMES_HOME", None) home = get_hermes_home() assert home == Path.home() / ".hermes" class TestEnsureHermesHome: def test_creates_default_soul_md_if_missing(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): ensure_hermes_home() soul_path = tmp_path / "SOUL.md" assert soul_path.exists() assert soul_path.read_text(encoding="utf-8").strip() != "" def test_upgrades_legacy_template_soul_md(self, tmp_path): # Older installers seeded a comment-only scaffold that shadowed the # runtime default. A SOUL.md still matching that scaffold carries no # user persona and should be upgraded in place to DEFAULT_SOUL_MD. from hermes_cli.default_soul import DEFAULT_SOUL_MD, _LEGACY_TEMPLATE_SOULS with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): soul_path = tmp_path / "SOUL.md" soul_path.write_text(_LEGACY_TEMPLATE_SOULS[0] + "\n", encoding="utf-8") ensure_hermes_home() assert soul_path.read_text(encoding="utf-8") == DEFAULT_SOUL_MD class TestLoadConfigDefaults: def test_returns_defaults_when_no_file(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config = load_config() assert config["model"] == DEFAULT_CONFIG["model"] assert config["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"] assert "max_turns" not in config assert "terminal" in config assert config["terminal"]["backend"] == "local" assert config["display"]["interim_assistant_messages"] is True def test_legacy_root_level_max_turns_migrates_to_agent_config(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config_path = tmp_path / "config.yaml" config_path.write_text("max_turns: 42\n") config = load_config() assert config["agent"]["max_turns"] == 42 assert "max_turns" not in config class TestLoadConfigParseFailure: """A YAML parse failure must NOT silently fall back to defaults. Before issue #23570 this was a single ``print(...)`` that scrolled past on the first invocation — users saw aux-fallback misbehavior with no clue their config.yaml was being ignored. The helper must: * log at WARNING (so ``hermes logs`` surfaces it) * also write to stderr (so it's visible at startup even before ``setup_logging()`` has wired up file handlers) * dedup on (path, mtime_ns, size) so concurrent loads don't spam * re-warn after the user edits the file (different mtime) """ def test_corrupt_config_is_backed_up(self, tmp_path, capsys): """A broken config.yaml is snapshotted to a timestamped .bak so the user's recoverable overrides survive a later wizard/config-set rewrite. Ported from google-gemini/gemini-cli#21541 (policy-file TOML recovery), adapted: we back up but deliberately do NOT reset config.yaml. """ from hermes_cli import config as cfg_mod cfg_mod._CONFIG_PARSE_WARNED.clear() with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): broken = "\tmodel: test/custom\nbroken indent:\n" (tmp_path / "config.yaml").write_text(broken) load_config() err = capsys.readouterr().err baks = list(tmp_path.glob("config.yaml.corrupt.*.bak")) assert len(baks) == 1, f"expected one backup, got {baks}" # Backup preserves the original broken content verbatim assert baks[0].read_text() == broken # Original config.yaml is left untouched (not reset to clean state) assert (tmp_path / "config.yaml").read_text() == broken # User is told where the backup landed assert str(baks[0]) in err def test_last_known_good_retained_within_process(self, tmp_path, capsys): """Port of openai/codex#31188's invariant: a parse failure must not silently replace the effective config (policy included) with defaults when the process already loaded a good config. Scenario: long-running gateway, user mid-edits config.yaml into broken YAML. Before this fix the next load_config() dropped every override — including ``approvals.deny`` security rules. Now the last successfully loaded config keeps being served until the file parses again. """ import time from hermes_cli import config as cfg_mod cfg_mod._CONFIG_PARSE_WARNED.clear() with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): cfg = tmp_path / "config.yaml" cfg.write_text( "model:\n default: test/custom-model\n" "approvals:\n deny:\n - 'curl*evil.com*'\n" ) good = load_config() assert good["model"]["default"] == "test/custom-model" assert good["approvals"]["deny"] == ["curl*evil.com*"] capsys.readouterr() # Corrupt the file (mtime must change to bust the cache) time.sleep(0.05) cfg.write_text("approvals:\n deny: [unclosed\n :::bad {{{\n") after = load_config() # Last-known-good retained — NOT defaults assert after["model"]["default"] == "test/custom-model" assert after["approvals"]["deny"] == ["curl*evil.com*"] # Warning says we kept the previous config, not defaults err = capsys.readouterr().err assert "previously loaded config" in err class TestEmptyConfigSections: """Empty section keys (``terminal:`` with no value) parse as YAML None and must not replace the default dict for that section (#58277).""" def test_null_section_keeps_defaults_in_load_config(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): (tmp_path / "config.yaml").write_text( "model:\n default: test/custom\n" "terminal:\n" "display:\n" ) config = load_config() assert config["model"]["default"] == "test/custom" assert isinstance(config["terminal"], dict) assert config["terminal"] == DEFAULT_CONFIG["terminal"] assert isinstance(config["display"], dict) def test_null_override_of_non_dict_default_still_applies(self, tmp_path): """None only shields dict defaults — explicit null for a scalar key remains an override (unchanged behavior).""" from hermes_cli.config import _deep_merge merged = _deep_merge({"scalar": 5, "section": {"a": 1}}, {"scalar": None, "section": None}) assert merged["scalar"] is None assert merged["section"] == {"a": 1} class TestSaveAndLoadRoundtrip: @staticmethod def _deny_config_reads(config_path): real_open = open def fake_open(file, mode="r", *args, **kwargs): if Path(file) == config_path and "r" in mode: raise PermissionError("denied") return real_open(file, mode, *args, **kwargs) return fake_open def test_roundtrip(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config = load_config() config["model"] = "test/custom-model" config["agent"]["max_turns"] = 42 save_config(config) reloaded = load_config() assert reloaded["model"] == "test/custom-model" assert reloaded["agent"]["max_turns"] == 42 saved = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert saved["agent"]["max_turns"] == 42 assert "max_turns" not in saved def test_save_config_refuses_to_overwrite_unreadable_existing_config(self, tmp_path): config_path = tmp_path / "config.yaml" original = "model: test/original\n" config_path.write_text(original, encoding="utf-8") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): with patch("builtins.open", side_effect=self._deny_config_reads(config_path)): with pytest.raises(RuntimeError, match="Refusing to overwrite"): save_config({"model": "test/replacement"}) assert config_path.read_text(encoding="utf-8") == original class TestSaveEnvValueSecure: def test_secure_save_returns_metadata_only(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): result = save_env_value_secure("GITHUB_TOKEN", "ghp_test_secret") assert result == { "success": True, "stored_as": "GITHUB_TOKEN", "validated": False, } assert "secret" not in str(result).lower() def test_save_env_value_preserves_existing_file_mode_on_posix(self, tmp_path): """Regression for #31518: pre-existing .env mode (e.g. 0640 for a Docker bind-mount that the operator chose) survives subsequent writes. Previously _secure_file ran unconditionally after the mode-restore branch and re-tightened to 0600. """ if os.name == "nt": return env_path = tmp_path / ".env" env_path.write_text("EXISTING=value\n") os.chmod(env_path, 0o640) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): save_env_value("TENOR_API_KEY", "sk-test-secret") env_mode = env_path.stat().st_mode & 0o777 assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}" def test_save_env_value_quotes_values_containing_hash(self, tmp_path): """Regression test for #30355.""" from dotenv import dotenv_values with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False): os.environ.pop("ANTHROPIC_TOKEN", None) token = "sk-ant-oat01-abc#xyz#more" save_env_value("ANTHROPIC_TOKEN", token) content = (tmp_path / ".env").read_text(encoding="utf-8") assert f'ANTHROPIC_TOKEN="{token}"' in content parsed = dotenv_values(str(tmp_path / ".env")) assert parsed["ANTHROPIC_TOKEN"] == token assert load_env()["ANTHROPIC_TOKEN"] == token def test_save_env_value_already_quoted_input_is_not_double_wrapped_idempotently( self, tmp_path ): """Callers pass raw values; if a value literally contains quote characters, escaping+wrap is the dialect (#57249). Re-saving the same raw value is stable (no quote growth). load_env round-trips. """ # User-typed value that already includes surrounding quotes as data. raw = '"/Users/me/Application Support/key"' with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False): os.environ.pop("TERMINAL_SSH_KEY", None) save_env_value("TERMINAL_SSH_KEY", raw) first = (tmp_path / ".env").read_text(encoding="utf-8") save_env_value("TERMINAL_SSH_KEY", raw) second = (tmp_path / ".env").read_text(encoding="utf-8") assert first == second # One outer wrap layer only (escaped inner quotes, not nested wraps). line = [ ln for ln in first.splitlines() if ln.startswith("TERMINAL_SSH_KEY=") ][0] assert line.startswith('TERMINAL_SSH_KEY="') assert line.endswith('"') assert line.count('TERMINAL_SSH_KEY="') == 1 # Escaping dialect end-to-end: load sees the raw input, not stripped quotes. assert load_env()["TERMINAL_SSH_KEY"] == raw class TestRemoveEnvValue: def test_removes_key_from_env_file(self, tmp_path): env_path = tmp_path / ".env" env_path.write_text("KEY_A=value_a\nKEY_B=value_b\nKEY_C=value_c\n") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "KEY_B": "value_b"}): result = remove_env_value("KEY_B") assert result is True content = env_path.read_text() assert "KEY_B" not in content assert "KEY_A=value_a" in content assert "KEY_C=value_c" in content def test_clears_os_environ_even_when_not_in_file(self, tmp_path): env_path = tmp_path / ".env" env_path.write_text("OTHER=stuff\n") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "ORPHAN_KEY": "orphan"}): remove_env_value("ORPHAN_KEY") assert "ORPHAN_KEY" not in os.environ def test_remove_env_value_preserves_existing_file_mode_on_posix(self, tmp_path): """Regression: pre-existing .env mode (e.g. 0640 for a Docker bind-mount the operator chose) survives a remove just as it does a save. Previously _secure_file ran unconditionally after the mode-restore branch and re-tightened to 0600 — the same bug fixed in save_env_value (#33699), in the sibling remove path. """ if os.name == "nt": return env_path = tmp_path / ".env" env_path.write_text("KEEP=value\nDROP=gone\n") os.chmod(env_path, 0o640) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "DROP": "gone"}): removed = remove_env_value("DROP") assert removed is True assert "DROP" not in env_path.read_text() env_mode = env_path.stat().st_mode & 0o777 assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}" class TestSaveConfigAtomicity: """Verify save_config uses atomic writes (tempfile + os.replace).""" def test_no_partial_write_on_crash(self, tmp_path): """If save_config crashes mid-write, the previous file stays intact.""" with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): # Write an initial config config = load_config() config["model"] = "original-model" save_config(config) config_path = tmp_path / "config.yaml" assert config_path.exists() # Simulate a crash during yaml.dump by making atomic_yaml_write's # yaml.dump raise after the temp file is created but before replace. with patch("utils.yaml.dump", side_effect=OSError("disk full")): try: config["model"] = "should-not-persist" save_config(config) except OSError: pass # Original file must still be intact reloaded = load_config() assert reloaded["model"] == "original-model" def test_no_leftover_temp_files(self, tmp_path): """Failed writes must clean up their temp files.""" with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config = load_config() save_config(config) with patch("utils.yaml.dump", side_effect=OSError("disk full")): try: save_config(config) except OSError: pass # No .tmp files should remain tmp_files = list(tmp_path.glob(".*config*.tmp")) assert tmp_files == [] def test_atomic_write_creates_valid_yaml(self, tmp_path): """The written file must be valid YAML matching the input.""" with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config = load_config() config["model"] = "test/atomic-model" config["agent"]["max_turns"] = 77 save_config(config) # Read raw YAML to verify it's valid and correct config_path = tmp_path / "config.yaml" with open(config_path, encoding="utf-8") as f: raw = yaml.safe_load(f) assert raw["model"] == "test/atomic-model" assert raw["agent"]["max_turns"] == 77 class TestSanitizeEnvLines: """Tests for semantics-preserving .env line normalization.""" def test_migrate_reports_normalized_line_formatting(self, capsys): latest_version = DEFAULT_CONFIG["_config_version"] with ( patch("hermes_cli.config.sanitize_env_file", return_value=2), patch( "hermes_cli.config.check_config_version", return_value=(latest_version, latest_version), ), patch("hermes_cli.config.read_raw_config", return_value={}), patch("hermes_cli.config.get_missing_env_vars", return_value=[]), patch("hermes_cli.config.get_missing_config_fields", return_value=[]), patch("hermes_cli.config.get_missing_skill_config_vars", return_value=[]), ): migrate_config(interactive=False) assert capsys.readouterr().out == ( " ✓ Normalized .env line formatting (2 line(s) changed)\n" ) def test_glm_suffix_collision_not_split(self): """GLM_API_KEY / GLM_BASE_URL must not be mangled by LM_API_KEY / LM_BASE_URL suffixes (#17138).""" lines = [ "GLM_API_KEY=glm-secret\n", "GLM_BASE_URL=https://api.z.ai/api/paas/v4\n", ] result = _sanitize_env_lines(lines) assert result == lines, f"GLM_* lines were corrupted by suffix collision: {result}" def test_sanitize_env_file_does_not_rewrite_value_semantics(self, tmp_path): env_file = tmp_path / ".env" env_file.write_text( "FAL_KEY=good\n" "OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n" ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): fixes = sanitize_env_file() assert fixes == 0 content = env_file.read_text() assert content == ( "FAL_KEY=good\n" "OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n" ) def test_sanitize_env_file_noop_on_clean_file(self, tmp_path): """No changes when file is already clean.""" env_file = tmp_path / ".env" env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): fixes = sanitize_env_file() assert fixes == 0 class TestOptionalEnvVarsRegistry: """Verify that key env vars are registered in OPTIONAL_ENV_VARS.""" def test_tavily_api_key_registered(self): """TAVILY_API_KEY is listed in OPTIONAL_ENV_VARS.""" from hermes_cli.config import OPTIONAL_ENV_VARS assert "TAVILY_API_KEY" in OPTIONAL_ENV_VARS def test_tavily_api_key_has_url(self): """TAVILY_API_KEY has a URL.""" from hermes_cli.config import OPTIONAL_ENV_VARS assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["url"] == "https://app.tavily.com/home" def test_tavily_in_env_vars_by_version(self): """TAVILY_API_KEY is listed in ENV_VARS_BY_VERSION.""" from hermes_cli.config import ENV_VARS_BY_VERSION all_vars = [] for vars_list in ENV_VARS_BY_VERSION.values(): all_vars.extend(vars_list) assert "TAVILY_API_KEY" in all_vars def test_max_iterations_not_offered_as_env_var(self): """HERMES_MAX_ITERATIONS must NOT be in OPTIONAL_ENV_VARS (issue #17534). Offering it as an editable env var (dashboard, `hermes setup`) lets a user write it to .env, recreating the stale ghost that shadows config.yaml's agent.max_turns. The iteration budget is configured ONLY via config.yaml; HERMES_MAX_ITERATIONS remains a read-only backward-compat fallback in the gateway/CLI, never a promoted write target. """ from hermes_cli.config import OPTIONAL_ENV_VARS assert "HERMES_MAX_ITERATIONS" not in OPTIONAL_ENV_VARS class TestMemoryProviderEnvVarsRegistry: """Every memory provider that reads an API key from the environment must have that key catalogued in OPTIONAL_ENV_VARS so the dashboard Keys page and `hermes setup` surface it (previously only Honcho was listed, leaving Hindsight/Supermemory/Mem0/RetainDB/ByteRover/OpenViking invisible). This is a behavior contract, not a snapshot: it asserts each provider's primary credential key is present, tool-categorised, and password-masked — not a frozen count of entries. """ # provider primary-credential env key -> the tool-call name it powers. MEMORY_PROVIDER_KEYS = { "HONCHO_API_KEY": "honcho_context", "HINDSIGHT_API_KEY": "hindsight_recall", "SUPERMEMORY_API_KEY": "supermemory_search", "MEM0_API_KEY": "mem0_search", "RETAINDB_API_KEY": "retaindb_search", "BRV_API_KEY": "brv_query", "OPENVIKING_API_KEY": "viking_search", } def test_memory_provider_keys_are_catalogued(self): from hermes_cli.config import OPTIONAL_ENV_VARS missing = [k for k in self.MEMORY_PROVIDER_KEYS if k not in OPTIONAL_ENV_VARS] assert not missing, f"memory provider keys missing from OPTIONAL_ENV_VARS: {missing}" def test_memory_provider_keys_advertise_their_tool(self): from hermes_cli.config import OPTIONAL_ENV_VARS for key, tool in self.MEMORY_PROVIDER_KEYS.items(): assert tool in OPTIONAL_ENV_VARS[key].get("tools", []), key class TestConfigMigrationSecretPrompts: def test_required_secret_env_prompt_uses_masked_prompt(self, tmp_path, monkeypatch): from hermes_cli import config as cfg_mod saved = {} monkeypatch.setattr(cfg_mod, "sanitize_env_file", lambda: 0) monkeypatch.setattr(cfg_mod, "check_config_version", lambda: (999, 999)) monkeypatch.setattr(cfg_mod, "get_missing_config_fields", lambda: []) monkeypatch.setattr(cfg_mod, "get_missing_skill_config_vars", lambda: []) monkeypatch.setattr( cfg_mod, "get_missing_env_vars", lambda required_only=True: [ { "name": "TEST_API_KEY", "description": "Test key", "prompt": "Test API key", "password": True, } ] if required_only else [], ) def fake_masked_secret_prompt(prompt): saved["prompt"] = prompt return "secret" monkeypatch.setattr(cfg_mod, "masked_secret_prompt", fake_masked_secret_prompt) monkeypatch.setattr( cfg_mod, "save_env_value", lambda name, value: saved.update({name: value}), ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): results = cfg_mod.migrate_config(interactive=True, quiet=True) assert saved["prompt"] == " Test API key: " assert saved["TEST_API_KEY"] == "secret" assert results["env_added"] == ["TEST_API_KEY"] class TestConfigVersionDetection: def test_check_config_version_uses_raw_on_disk_version(self, tmp_path): config_path = tmp_path / "config.yaml" config_path.write_text("model: {}\n", encoding="utf-8") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): assert load_config()["_config_version"] == DEFAULT_CONFIG["_config_version"] assert check_config_version() == (0, DEFAULT_CONFIG["_config_version"]) class TestConfigSupportFloor: """Auto-migration support floor (v12). Configs below ``SUPPORT_FLOOR_VERSION`` are refused: the file stays byte-for-byte untouched, a clear actionable message is surfaced (stdout when not quiet + stderr always + results['warnings']), and the process continues without crashing — matching the fail-safe posture for unparseable configs. Configs at or above the floor migrate exactly as before the floor was introduced (parity fixtures below). """ def _write_config(self, tmp_path, data): config_path = tmp_path / "config.yaml" text = yaml.safe_dump(data) config_path.write_text(text, encoding="utf-8") return config_path, text def test_v11_config_is_refused_and_untouched(self, tmp_path, capsys): config_path, original = self._write_config( tmp_path, { "_config_version": 11, "custom_providers": [ {"name": "Old", "base_url": "http://localhost:1234/v1"} ], }, ) (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): results = migrate_config(interactive=False, quiet=False) # File untouched — no migration, no version bump, no rewrite. assert config_path.read_text(encoding="utf-8") == original # .env untouched too (the retired <12 steps used to clear tokens). assert load_env().get("ANTHROPIC_TOKEN") == "old-token" captured = capsys.readouterr() expected_fragment = ( "This config predates version 12 (~2 years old) and can no " "longer be auto-migrated." ) assert expected_fragment in captured.out assert expected_fragment in captured.err assert "run `hermes setup` to regenerate" in captured.out assert "_config_version: 12" in captured.out assert any(expected_fragment in w for w in results["warnings"]) # No 'Config version: X → Y' line — nothing was migrated. assert "Config version:" not in captured.out def test_v11_quiet_still_warns_on_stderr_only(self, tmp_path, capsys): config_path, original = self._write_config( tmp_path, {"_config_version": 11} ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): results = migrate_config(interactive=False, quiet=True) assert config_path.read_text(encoding="utf-8") == original captured = capsys.readouterr() assert "can no longer be auto-migrated" in captured.err assert captured.out == "" assert results["warnings"] def test_floor_message_uses_display_hermes_home(self): from hermes_cli.config_migrations import support_floor_message from hermes_constants import display_hermes_home msg = support_floor_message() assert f"{display_hermes_home()}/config.yaml" in msg def test_registry_has_no_targets_below_floor(self): from hermes_cli.config_migrations import ( MIGRATIONS, SUPPORT_FLOOR_VERSION, ) assert SUPPORT_FLOOR_VERSION == 12 assert all(target >= SUPPORT_FLOOR_VERSION for target, _ in MIGRATIONS) # v12's own step is retained: a config AT v11 is refused, but a # config AT v12 must still receive every remaining migration. assert MIGRATIONS[0][0] == 12 # ── Parity fixtures ────────────────────────────────────────────── # Expected outputs captured by running migrate_config from origin/main # (commit 28524adb0e, pre-floor) in a subprocess against these exact # fixtures. The floor must not change behavior for v12+ configs. _V12_FIXTURE = { "_config_version": 12, "model": {"default": "openai/gpt-5.4", "provider": "openrouter"}, "display": {"tool_progress_overrides": {"telegram": "verbose"}}, "stt": {"model": "base", "provider": "local"}, "compression": {"summary_model": "gpt-x", "summary_provider": "auto"}, "model_catalog": {"ttl_hours": 24}, "memory": {"write_mode": "approve"}, "delegation": {"max_async_children": 8}, "agent": {"verify_on_stop": True}, } _V12_EXPECTED = { "_config_version": 33, "agent": {"verify_on_stop": False}, "auxiliary": {"compression": {"model": "gpt-x"}}, "compression": {}, "delegation": {"max_concurrent_children": 8}, "display": { "platforms": {"telegram": {"tool_progress": "verbose"}}, "tool_progress_overrides": {"telegram": "verbose"}, }, "memory": {"write_approval": True}, "model": {"default": "openai/gpt-5.4", "provider": "openrouter"}, "model_catalog": {"ttl_hours": 1}, "plugins": {"enabled": []}, "stt": {"provider": "local"}, } _V20_FIXTURE = { "_config_version": 20, "model": {"default": "anthropic/claude-fable-5", "provider": "nous"}, "plugins": {"disabled": ["foo"]}, "skills": {"write_mode": "on"}, "model_catalog": {"ttl_hours": 24}, "agent": {}, } _V20_EXPECTED = { "_config_version": 33, "agent": {"verify_on_stop": False}, "model": {"default": "anthropic/claude-fable-5", "provider": "nous"}, "model_catalog": {"ttl_hours": 1}, "plugins": {"disabled": ["foo"], "enabled": []}, } _ENV_FIXTURE = ( "LLM_MODEL=old-model\nOPENAI_MODEL=old-openai\nOPENROUTER_API_KEY=test\n" ) @pytest.mark.parametrize( "fixture,expected,expected_env", [ ( _V12_FIXTURE, _V12_EXPECTED, # v12→13 clears LLM_MODEL/OPENAI_MODEL for configs below 13. "LLM_MODEL=\nOPENAI_MODEL=\nOPENROUTER_API_KEY=test\n", ), (_V20_FIXTURE, _V20_EXPECTED, _ENV_FIXTURE), ], ids=["v12", "v20"], ) def test_at_or_above_floor_migrates_identically_to_pre_floor( self, tmp_path, fixture, expected, expected_env ): config_path, _ = self._write_config(tmp_path, fixture) (tmp_path / ".env").write_text(self._ENV_FIXTURE, encoding="utf-8") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) # Pin the golden version the fixtures were captured at, then compare # the rest against the same-latest expectation. If _config_version has # advanced past 33, only the version key may differ. assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] raw.pop("_config_version") exp = dict(expected) exp.pop("_config_version") if DEFAULT_CONFIG["_config_version"] == 33: assert raw == exp else: # future migrations appended — golden subset must still hold for key, val in exp.items(): assert raw.get(key) == val, f"parity drift on {key!r}" assert (tmp_path / ".env").read_text(encoding="utf-8") == expected_env class TestCustomProviderCompatibility: """Custom provider compatibility across legacy and v12+ config schemas. The v11→12 step (_migrate_to_12) is retained in the registry per the support-floor policy, but migrate_config() refuses sub-v12 configs, so these tests drive run_migrations() directly to keep the step covered. """ @staticmethod def _run_ladder(current_ver: int): from hermes_cli.config_migrations import run_migrations results = {"env_added": [], "config_added": [], "warnings": []} run_migrations(current_ver, results, quiet=True) return results with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): self._run_ladder(11) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) assert raw["providers"]["openai-direct"] == { "api": "https://api.openai.com/v1", "api_key": "test-key", "default_model": "gpt-5-mini", "name": "OpenAI Direct", "transport": "codex_responses", } # custom_providers removed by migration — runtime reads via compat layer assert "custom_providers" not in raw def test_v11_upgrade_preserves_custom_provider_model_metadata(self, tmp_path): config_path = tmp_path / "config.yaml" model_map = { "kimi-k2.6": {"context_length": 262144}, "moonshotai/Kimi-K2.6-ACED": {"context_length": 131072}, } config_path.write_text( yaml.safe_dump( { "_config_version": 11, "custom_providers": [ { "name": "Kimi Coding Plan", "base_url": "https://api.kimi.example.com/coding", "api_key_env": "KIMI_CODING_API_KEY", "api_mode": "anthropic_messages", "model": "kimi-k2.6", "models": model_map, "context_length": 262144, "rate_limit_delay": 0.25, "discover_models": False, "extra_body": { "chat_template_kwargs": {"enable_thinking": False} }, }, { "name": "List Models", "base_url": "https://list.example.com/v1", "models": ["alpha", "beta"], }, ], } ), encoding="utf-8", ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): self._run_ladder(11) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) compatible = get_compatible_custom_providers(raw) assert "custom_providers" not in raw provider = raw["providers"]["kimi-coding-plan"] assert provider["api"] == "https://api.kimi.example.com/coding" assert provider["key_env"] == "KIMI_CODING_API_KEY" assert provider["transport"] == "anthropic_messages" assert provider["default_model"] == "kimi-k2.6" assert provider["models"] == model_map assert provider["context_length"] == 262144 assert provider["rate_limit_delay"] == 0.25 assert provider["discover_models"] is False assert provider["extra_body"] == { "chat_template_kwargs": {"enable_thinking": False} } assert raw["providers"]["list-models"]["models"] == { "alpha": {}, "beta": {}, } compatible_provider = next( entry for entry in compatible if entry["provider_key"] == "kimi-coding-plan" ) assert compatible_provider["models"] == model_map assert compatible_provider["key_env"] == "KIMI_CODING_API_KEY" def test_providers_dict_resolves_at_runtime(self, tmp_path): """After migration deleted custom_providers, get_compatible_custom_providers still finds entries from the providers dict.""" config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump( { "_config_version": 17, "providers": { "openai-direct": { "api": "https://api.openai.com/v1", "api_key": "test-key", "default_model": "gpt-5-mini", "name": "OpenAI Direct", "transport": "codex_responses", } }, } ), encoding="utf-8", ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): compatible = get_compatible_custom_providers() assert len(compatible) == 1 assert compatible[0]["name"] == "OpenAI Direct" assert compatible[0]["base_url"] == "https://api.openai.com/v1" assert compatible[0]["provider_key"] == "openai-direct" assert compatible[0]["api_mode"] == "codex_responses" def test_compatible_custom_providers_prefers_base_url_then_url_then_api(self, tmp_path): """URL field precedence is base_url > url > api (PR #9332).""" config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump( { "_config_version": 17, "providers": { "my-provider": { "name": "My Provider", "api": "https://api.example.com/v1", "url": "https://url.example.com/v1", "base_url": "https://base.example.com/v1", } }, } ), encoding="utf-8", ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): compatible = get_compatible_custom_providers() assert compatible == [ { "name": "My Provider", "base_url": "https://base.example.com/v1", "provider_key": "my-provider", } ] class TestInterimAssistantMessageConfig: """Test the explicit gateway interim-message config gate.""" def test_default_config_enables_interim_assistant_messages(self): assert DEFAULT_CONFIG["display"]["interim_assistant_messages"] is True def test_migrate_to_v15_adds_interim_assistant_message_gate(self, tmp_path): config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump({"_config_version": 14, "display": {"tool_progress": "off"}}), encoding="utf-8", ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) loaded = load_config() from hermes_cli.config import DEFAULT_CONFIG assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] # The user's explicit non-default value is preserved on disk. assert raw["display"]["tool_progress"] == "off" # interim_assistant_messages defaults to True and merges in transparently # at read time, so the migration must NOT materialise it to disk (that # was the config-bloat bug). It is still effective via load_config(). assert "interim_assistant_messages" not in raw.get("display", {}) assert loaded["display"]["interim_assistant_messages"] is True class TestCliRefreshIntervalConfig: """Test the CLI refresh_interval config default (#45592 / #48309).""" def test_default_config_enables_cli_refresh_interval(self): """cli_refresh_interval defaults to 1.0 so the idle status-bar clock keeps ticking and the bottom chrome stays alive during idle (#45592). Users on emulators where the periodic redraw fights auto-scroll can set it to 0 (#48309).""" assert DEFAULT_CONFIG["display"]["cli_refresh_interval"] == 1.0 class TestDiscordChannelPromptsConfig: def test_migrate_preserves_custom_providers_and_no_defaults_dump(self, tmp_path): """Migration must not expand config.yaml to a defaults dump (#40821). Before the fix, migrations used load_config() which deep-merges DEFAULT_CONFIG, then save_config() wrote the full ~13KB expanded result — destroying comments and structure. Using read_raw_config() keeps the file small and preserves only the user's actual config. """ config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump({ "_config_version": 11, "model": {"default": "test-model", "provider": "openrouter"}, "custom_providers": [ {"name": "local-llm", "base_url": "http://localhost:8080/v1", "models": {"test": {}}} ], }), encoding="utf-8", ) results = {"env_added": [], "config_added": [], "warnings": []} from hermes_cli.config_migrations import run_migrations with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): # Drive the ladder directly: migrate_config() refuses sub-v12 # configs since the support floor, but the write-invariant this # test guards (#40821) lives in the steps themselves. run_migrations(11, results, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) # custom_providers migrated to providers dict (by design, v11->v12) assert "custom_providers" not in raw assert "providers" in raw assert "local-llm" in raw["providers"] assert raw["providers"]["local-llm"]["api"] == "http://localhost:8080/v1" # File must NOT be a defaults dump — assert specific DEFAULT_CONFIG # top-level keys are absent (they should only appear via load_config's # deep-merge, not be written to the user's file by migration). for default_key in ("tts", "compression", "security", "whatsapp", "bedrock"): assert default_key not in raw, ( f"{default_key} should not be in migrated config file — " f"migration should use read_raw_config() to avoid defaults dump" ) class TestEnvWriteDenylist: """``save_env_value`` refuses to persist env-var names that influence how subprocesses execute — ``LD_PRELOAD``, ``PYTHONPATH``, ``PATH``, ``EDITOR``, etc. — or any ``HERMES_*`` runtime flag. The dashboard exposes ``PUT /api/env`` to any authed caller (and the session token lives in the SPA's HTML where any future plugin XSS or local process could exfiltrate it). Without this gate, an attacker who steals the token could plant ``LD_PRELOAD=/tmp/evil.so`` in ``.env`` and own the next Hermes process on next startup via the dotenv → ``os.environ`` chain in ``hermes_cli/env_loader.py``. Regression test for the dashboard pentest finding filed alongside the ``web-pentest`` skill (PR #32265 / issue #32267). """ @pytest.fixture(autouse=True) def _hermes_home(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) ensure_hermes_home() @pytest.mark.parametrize( "allowed_key", [ "HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_SPOTIFY_CLIENT_ID", "HERMES_QWEN_BASE_URL", "HERMES_MAX_ITERATIONS", ], ) def test_hermes_integration_keys_still_writable(self, allowed_key): """``HERMES_*`` overall is NOT blocked — only the four runtime location names (HOME/PROFILE/CONFIG/ENV) are. Integration credentials following the ``HERMES_*`` convention must keep working or we'd regress every provider setup wizard that currently writes one of these (auth.py, Spotify, Langfuse, …).""" save_env_value(allowed_key, "test-value-123") env = load_env() assert env[allowed_key] == "test-value-123" def test_save_env_value_secure_inherits_denylist(self): """The ``_secure`` variant goes through ``save_env_value`` so it inherits the gate — verify, don't assume.""" with pytest.raises(ValueError, match="denylist"): save_env_value_secure("LD_PRELOAD", "/tmp/evil.so") class TestWriteApprovalMigration: """Version 28→29 renames memory/skills write_mode → write_approval (bool). Only an explicit ``approve`` carried gating intent and maps to ``True``; ``on``/``off``/unset map to ``False`` (gate off). The old ``write_mode`` key is removed. Only a persisted key is rewritten — never invented. """ def _write(self, tmp_path, body: str): (tmp_path / "config.yaml").write_text(body) def test_approve_maps_to_true(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): self._write(tmp_path, "_config_version: 28\nmemory:\n write_mode: approve\n" "skills:\n write_mode: approve\n") migrate_config(interactive=False, quiet=True) raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert raw["memory"]["write_approval"] is True assert raw["skills"]["write_approval"] is True assert "write_mode" not in raw["memory"] assert "write_mode" not in raw["skills"] def test_on_and_off_map_to_false(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): # YAML 1.1 parses bare on/off as bools — write_mode could be either # the string or the bool; both legacy "not gating" values → False. self._write(tmp_path, "_config_version: 28\nmemory:\n write_mode: 'on'\n" "skills:\n write_mode: 'off'\n") migrate_config(interactive=False, quiet=True) raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) loaded = load_config() # write_approval=False equals the schema default, so it is NOT # materialised to disk (lean-config invariant) — the legacy # write_mode key is gone and the effective value resolves to False # via load_config()'s deep-merge. assert "write_mode" not in raw.get("memory", {}) assert "write_mode" not in raw.get("skills", {}) assert loaded["memory"]["write_approval"] is False assert loaded["skills"]["write_approval"] is False class TestMigrationWriteInvariant: """Architectural guard: every migration write routes through the single _persist_migration() chokepoint, which strips schema defaults so a lean config is never bloated into a DEFAULT_CONFIG dump on a version bump. These lock the centralised invariant so a future migration that calls save_config(...) directly (re-introducing the config-bloat bug class) is caught immediately. """ @pytest.mark.parametrize("start_version", [12, "latest_minus_one"]) def test_version_bump_keeps_config_lean(self, tmp_path, start_version): """A lean config migrated to the latest version must never be rewritten into a defaults dump — neither across the whole supported range (start=12, the auto-migration floor, where per-version seeds also fire) nor on a bare one-version bump (where only the catch-all finalizer runs). In both cases no default-only top-level section the user never wrote may land on disk, the merged view still exposes every default, and the user's explicit non-default value survives. """ latest = DEFAULT_CONFIG["_config_version"] start = latest - 1 if start_version == "latest_minus_one" else start_version config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump({ "_config_version": start, "model": {"default": "test-model", "provider": "openrouter"}, "matrix": {"require_mention": False}, }, sort_keys=False), encoding="utf-8", ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) loaded = load_config() assert raw["_config_version"] == latest # User's explicit non-default value preserved (not reset to True default). assert raw["matrix"]["require_mention"] is False assert loaded["matrix"]["require_mention"] is False # No default-only top-level section the user never wrote lands on disk — # neither from per-version seeds nor the catch-all finalizer. for default_key in ( "timezone", "curator", "auxiliary", "tts", "compression", "whatsapp", "bedrock", ): assert default_key not in raw, ( f"{default_key} was materialised into a lean config by the " f"version bump — the default-dump regression returned" ) # Defaults still take effect transparently via the read-time merge. assert loaded["curator"]["enabled"] == DEFAULT_CONFIG["curator"]["enabled"] assert loaded["display"]["compact"] == DEFAULT_CONFIG["display"]["compact"] class TestSaveConfigPartialWritePreservation: """Regression for #62723: partial migration writes must not drop unrelated sections.""" def test_merge_existing_preserves_platforms_on_partial_write(self, tmp_path): body = """_config_version: 30 model: default: deepseek-v4-pro provider: deepseek agent: max_turns: 60 platforms: feishu: enabled: true extra: app_id: cli_xxx app_secret: xxx feishu: require_mention: true """ (tmp_path / "config.yaml").write_text(body, encoding="utf-8") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): save_config( { "_config_version": 30, "model": {"default": "deepseek-v4-pro", "provider": "deepseek"}, "agent": {"max_turns": 60, "verify_on_stop": False}, }, merge_existing=True, ) raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx" assert raw["feishu"]["require_mention"] is True assert raw["agent"]["verify_on_stop"] is False def test_persist_migration_writes_full_read_raw_config(self, tmp_path): from hermes_cli.config import _persist_migration, read_raw_config body = """_config_version: 30 model: default: deepseek-v4-pro provider: deepseek agent: max_turns: 60 platforms: feishu: enabled: true extra: app_id: cli_xxx app_secret: xxx """ (tmp_path / "config.yaml").write_text(body, encoding="utf-8") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config = read_raw_config() config.setdefault("agent", {})["verify_on_stop"] = False config["_config_version"] = 32 _persist_migration(config) raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx" assert raw["agent"]["verify_on_stop"] is False assert raw["agent"]["max_turns"] == 60 assert raw["_config_version"] == 32 def test_v30_to_latest_migration_keeps_platforms(self, tmp_path): """End-to-end: reporter's v30 feishu profile survives version bump.""" body = """_config_version: 30 model: default: deepseek-v4-pro provider: deepseek agent: max_turns: 60 platforms: feishu: enabled: true extra: app_id: cli_xxx app_secret: xxx feishu: require_mention: true """ (tmp_path / "config.yaml").write_text(body, encoding="utf-8") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): migrate_config(interactive=False, quiet=True) raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx" assert raw["feishu"]["require_mention"] is True class TestVerifyOnStopMigration: """v30 → v31: switch verify_on_stop OFF once, preserving explicit choices.""" def _write(self, tmp_path, body): (tmp_path / "config.yaml").write_text(body, encoding="utf-8") class TestDelegationCapUnificationMigration: """v32 → v33: fold deprecated max_async_children into max_concurrent_children.""" def _write(self, tmp_path, body): (tmp_path / "config.yaml").write_text(body, encoding="utf-8") def test_no_delegation_section_is_noop(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): self._write(tmp_path, "_config_version: 32\nmodel:\n provider: openrouter\n") migrate_config(interactive=False, quiet=True) raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) # Migration must not materialize a delegation section it never had. assert "delegation" not in raw class TestConfigNormalizationDoesNotOverwriteUserValues: """Regression tests for #27354.""" def test_save_config_does_not_inject_max_turns_when_unset(self, tmp_path): config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump( { "_config_version": DEFAULT_CONFIG["_config_version"], "memory": {"user_char_limit": 2200}, } ), encoding="utf-8", ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): save_config(load_config()) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) assert "max_turns" not in raw.get("agent", {}) assert raw["memory"]["user_char_limit"] == 2200 def test_normalize_max_turns_does_not_inject_default(self): result = _normalize_max_turns_config( {"_config_version": DEFAULT_CONFIG["_config_version"]} ) assert "max_turns" not in result.get("agent", {}) class TestCodexAppServerAutoConfig: """codex_app_server_auto ships a default and survives migration untouched.""" def _write(self, tmp_path, body): (tmp_path / "config.yaml").write_text(body, encoding="utf-8") def test_default_config_has_native_mode(self): assert DEFAULT_CONFIG["compression"]["codex_app_server_auto"] == "native" assert DEFAULT_CONFIG["compression"]["codex_gpt55_autoraise"] is True def test_preserves_existing_codex_app_server_auto_value(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): self._write( tmp_path, "_config_version: 31\n" "compression:\n" " codex_app_server_auto: hermes\n", ) migrate_config(interactive=False, quiet=True) raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert raw["compression"]["codex_app_server_auto"] == "hermes" class TestIsProviderEnabled: """``is_provider_enabled`` gates ``providers.`` blocks for the model picker, ``/models`` listings and the runtime resolver. Default must be ``True`` so existing configs keep working untouched.""" def test_missing_flag_defaults_to_enabled(self): assert is_provider_enabled({"name": "Anthropic"}) is True @pytest.mark.parametrize("raw", ["true", "True", "yes", "on", "1", "anything-else"]) def test_yaml_string_truthy_values_keep_it_enabled(self, raw): assert is_provider_enabled({"enabled": raw}) is True def test_non_dict_input_defaults_to_enabled(self): # Malformed entries (None, list, string) don't disappear silently — # the gate stays open and the existing validation paths will flag # them. assert is_provider_enabled(None) is True assert is_provider_enabled([]) is True assert is_provider_enabled("oops") is True class TestProviderEnabledRuntimeGate: """Verify ``resolve_runtime_provider`` honours ``enabled: false`` for both custom-defined and built-in provider names. Smoke test only — full runtime resolution has its own fixture-heavy tests; here we only assert the early-exit raises a typed error.""" def test_disabled_custom_provider_raises_valueerror(self, tmp_path, monkeypatch): cfg = { "model": {"default": "claude-sonnet-4-6", "provider": "claude-agent-sdk"}, "providers": { "my-fork": { "name": "my-fork", "base_url": "http://127.0.0.1:9999", "api_key": "not-needed", "enabled": False, }, }, } config_path = tmp_path / "config.yaml" config_path.write_text(yaml.safe_dump(cfg)) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # Bust the in-process config cache so the override picks up. from hermes_cli import config as cfg_mod cfg_mod._cached_config = None # type: ignore[attr-defined] from hermes_cli.runtime_provider import resolve_runtime_provider with pytest.raises(ValueError, match="disabled"): resolve_runtime_provider(requested="my-fork") # --------------------------------------------------------------------------- # DEFAULT_CONFIG must not carry a duplicate "kanban" key # --------------------------------------------------------------------------- def test_default_config_kanban_block_not_dropped_by_duplicate_key(): """DEFAULT_CONFIG previously declared ``"kanban"`` twice, so Python kept only the second literal and silently dropped the first — losing the ``auto_subscribe_on_create`` default. Both sets of defaults must survive. """ kanban = DEFAULT_CONFIG["kanban"] # From the first (dropped) block: assert kanban.get("auto_subscribe_on_create") is True # From the second block: assert "dispatch_in_gateway" in kanban assert "auto_decompose" in kanban def test_default_config_has_no_duplicate_top_level_keys(): """Guard against any duplicate key silently shadowing a default.""" import ast import hermes_cli.config as cfg_mod src = open(cfg_mod.__file__, encoding="utf-8").read() tree = ast.parse(src) for node in ast.walk(tree): if isinstance(node, ast.Dict): keys = [k.value for k in node.keys if isinstance(k, ast.Constant)] if "model" in keys and "kanban" in keys: # the DEFAULT_CONFIG literal dupes = {k for k in keys if keys.count(k) > 1} assert not dupes, f"duplicate DEFAULT_CONFIG keys: {sorted(dupes)}"