{"repo":"NousResearch/hermes-agent","public_id":"2677","display_label":"ENV_VAR substitution support in config.yaml","summary":"A feature request and two corresponding implementation pull requests to support ${ENV_VAR} environment variable substitution within config.yaml, particularly for API keys.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2684,"merged_at":"2026-03-23T23:02:06+00:00"},"resolution":{"state":"resolved","by":"pr-2684"},"freshness":{"state":"stale","last_verified_at":"2026-03-23T23:02:06+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":2677,"pr_number":2684,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-23T23:02:06+00:00"}],"members":[{"kind":"issue","number":2677,"title":"Feature request: Support ${ENV_VAR} substitution in config.yaml for API keys","url":null,"state":"closed"},{"kind":"pr","number":2680,"title":"feat(config): support ${ENV_VAR} substitution in config.yaml","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2684","number":2684}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2684,"title":"feat(config): support ${ENV_VAR} substitution in config.yaml","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/config.py","tests/test_config_env_expansion.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -301,7 +301,11 @@ def load_cli_config() -> Dict[str, Any]:\n                 defaults[\"agent\"][\"max_turns\"] = file_config[\"max_turns\"]\n         except Exception as e:\n             logger.warning(\"Failed to load cli-config.yaml: %s\", e)\n-    \n+\n+    # Expand ${ENV_VAR} references in config values before bridging to env vars.\n+    from hermes_cli.config import _expand_env_vars\n+    defaults = _expand_env_vars(defaults)\n+\n     # Apply terminal config to environment variables (so terminal_tool picks them up)\n     terminal_config = defaults.get(\"terminal\", {})\n     "]},{"file":"gateway/run.py","hunks":["@@ -93,6 +93,9 @@ def _ensure_ssl_certs() -> None:\n         import yaml as _yaml\n         with open(_config_path, encoding=\"utf-8\") as _f:\n             _cfg = _yaml.safe_load(_f) or {}\n+        # Expand ${ENV_VAR} references before bridging to env vars.\n+        from hermes_cli.config import _expand_env_vars\n+        _cfg = _expand_env_vars(_cfg)\n         # Top-level simple values (fallback only — don't override .env)\n         for _key, _val in _cfg.items():\n             if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:"]},{"file":"hermes_cli/config.py","hunks":["@@ -1172,6 +1172,26 @@ def _deep_merge(base: dict, override: dict) -> dict:\n     return result\n \n \n+def _expand_env_vars(obj):\n+    \"\"\"Recursively expand ``${VAR}`` references in config values.\n+\n+    Only string values are processed; dict keys, numbers, booleans, and\n+    None are left untouched.  Unresolved references (variable not in\n+    ``os.environ``) are kept verbatim so callers can detect them.\n+    \"\"\"\n+    if isinstance(obj, str):\n+        return re.sub(\n+            r\"\\${([^}]+)}\",\n+            lambda m: os.environ.get(m.group(1), m.group(0)),\n+            obj,\n+        )\n+    if isinstance(obj, dict):\n+        return {k: _expand_env_vars(v) for k, v in obj.items()}\n+    if isinstance(obj, list):\n+        return [_expand_env_vars(item) for item in obj]\n+    return obj\n+\n+\n def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n     \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n     config = dict(config)","@@ -1213,7 +1233,7 @@ def load_config() -> Dict[str, Any]:\n         except Exception as e:\n             print(f\"Warning: Failed to load config: {e}\")\n     \n-    return _normalize_max_turns_config(config)\n+    return _expand_env_vars(_normalize_max_turns_config(config))\n \n \n _SECURITY_COMMENT = \"\"\""]},{"file":"tests/test_config_env_expansion.py","hunks":["@@ -0,0 +1,132 @@\n+\"\"\"Tests for ${ENV_VAR} substitution in config.yaml values.\"\"\"\n+\n+import os\n+import pytest\n+from hermes_cli.config import _expand_env_vars, load_config\n+from unittest.mock import patch as mock_patch\n+\n+\n+class TestExpandEnvVars:\n+    def test_simple_substitution(self):\n+        with pytest.MonkeyPatch().context() as mp:\n+            mp.setenv(\"MY_KEY\", \"secret123\")\n+            assert _expand_env_vars(\"${MY_KEY}\") == \"secret123\"\n+\n+    def test_missing_var_kept_verbatim(self):\n+        with pytest.MonkeyPatch().context() as mp:\n+            mp.delenv(\"UNDEFINED_VAR_XYZ\", raising=False)\n+            assert _expand_env_vars(\"${UNDEFINED_VAR_XYZ}\") == \"${UNDEFINED_VAR_XYZ}\"\n+\n+    def test_no_placeholder_unchanged(self):\n+        assert _expand_env_vars(\"plain-value\") == \"plain-value\"\n+\n+    def test_dict_recursive(self):\n+        with pytest.MonkeyPatch().context() as mp:\n+            mp.setenv(\"TOKEN\", \"tok-abc\")\n+            result = _expand_env_vars({\"key\": \"${TOKEN}\", \"other\": \"literal\"})\n+            assert result == {\"key\": \"tok-abc\", \"other\": \"literal\"}\n+\n+    def test_nested_dict(self):\n+        with pytest.MonkeyPatch().context() as mp:\n+            mp.setenv(\"API_KEY\", \"sk-xyz\")\n+            result = _expand_env_vars({\"model\": {\"api_key\": \"${API_KEY}\"}})\n+            assert result[\"model\"][\"api_key\"] == \"sk-xyz\"\n+\n+    def test_list_items(self):\n+        with pytest.MonkeyPatch().context() as mp:\n+            mp.setenv(\"VAL\", \"hello\")\n+            result = _expand_env_vars([\"${VAL}\", \"literal\", 42])\n+            assert result == [\"hello\", \"literal\", 42]\n+\n+    def test_non_string_values_untouched(self):\n+        assert _expand_env_vars(42) == 42\n+        assert _expand_env_vars(3.14) == 3.14\n+        assert _expand_env_vars(True) is True\n+        assert _expand_env_vars(None) is None\n+\n+    def test_multiple_placeholders_in_one_string(self):\n+        with pytest.MonkeyPatch().context() as mp:\n+            mp.setenv(\"HOST\", \"localhost\")\n+            mp.setenv(\"PORT\", \"5432\")\n+            assert _expand_env_vars(\"${HOST}:${PORT}\") == \"localhost:5432\"\n+\n+    def test_dict_keys_not_expanded(self):\n+        with pytest.MonkeyPatch().context() as mp:\n+            mp.setenv(\"KEY\", \"value\")\n+            result = _expand_env_vars({\"${KEY}\": \"no-expand-key\"})\n+            assert \"${KEY}\" in result\n+\n+\n+class TestLoadConfigExpansion:\n+    def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):\n+        config_yaml = (\n+            \"model:\\n\"\n+            \"  api_key: ${GOOGLE_API_KEY}\\n\"\n+            \"platforms:\\n\"\n+            \"  telegram:\\n\"\n+            \"    token: ${TELEGRAM_BOT_TOKEN}\\n\"\n+            \"plain: no-substitution\\n\"\n+        )\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(config_yaml)\n+\n+        monkeypatch.setenv(\"GOOGLE_API_KEY\", \"gsk-test-key\")\n+        monkeypatch.setenv(\"TELEGRAM_BOT_TOKEN\", \"1234567:ABC-token\")\n+        monkeypatch.setattr(\"hermes_cli.config.get_config_path\", lambda: config_file)\n+\n+        config = load_config()\n+\n+        assert config[\"model\"][\"api_key\"] == \"gsk-test-key\"\n+        assert config[\"platforms\"][\"telegram\"][\"token\"] == \"1234567:ABC-token\"\n+        assert config[\"plain\"] == \"no-substitution\"\n+\n+    def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):\n+        config_yaml = \"model:\\n  api_key: ${NOT_SET_XYZ_123}\\n\"\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(config_yaml)\n+\n+        monkeypatch.delenv(\"NOT_SET_XYZ_123\", raising=False)\n+        monkeypatch.setattr(\"hermes_cli.config.get_config_path\", lambda: config_file)\n+\n+        config = load_config()\n+\n+        assert config[\"model\"][\"api_key\"] == \"${NOT_SET_XYZ_123}\"\n+\n+\n+class TestLoadCliConfigExpansion:\n+    \"\"\"Verify that load_cli_config() also expands ${VAR} references.\"\"\"\n+\n+    def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):\n+        config_yaml = (\n+            \"auxiliary:\\n\"\n+            \"  vision:\\n\"\n+            \"    api_key: ${TEST_VISION_KEY_XYZ}\\n\"\n+        )\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(config_yaml)\n+\n+        monkeypatch.setenv(\"TEST_VISION_KEY_XYZ\", \"vis-key-123\")\n+        # Patch the hermes home so load_cli_config finds our test config\n+        monkeypatch.setattr(\"cli._hermes_home\", tmp_path)\n+\n+        from cli import load_cli_config\n+        config = load_cli_config()\n+\n+        assert config[\"auxiliary\"][\"vision\"][\"api_key\"] == \"vis-key-123\"\n+\n+    def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):\n+        config_yaml = (\n+            \"auxiliary:\\n\"\n+            \"  vision:\\n\"\n+            \"    api_key: ${UNSET_CLI_VAR_ABC}\\n\"\n+        )\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(config_yaml)\n+\n+        monkeypatch.delenv(\"UNSET_CLI_VAR_ABC\", raising=False)\n+        monkeypatch.setattr(\"cli._hermes_home\", tmp_path)\n+\n+        from cli import load_cli_config\n+        config = load_cli_config()\n+\n+        assert config[\"auxiliary\"][\"vision\"][\"api_key\"] == \"${UNSET_CLI_VAR_ABC}\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}