NousResearch/hermes-agent

ENV_VAR substitution support in config.yaml

Cluster 2677 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #2684 on 23 Mar 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #2684. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #2684. That is separate from the fix event.
Still open
Nothing open on GitHub.

A feature request and two corresponding implementation pull requests to support ${ENV_VAR} environment variable substitution within config.yaml, particularly for API keys.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #2684 feat(config): support ${ENV_VAR} substitution in config.yaml

    merged to main

    files: cli.py, gateway/run.py, hermes_cli/config.py, tests/test_config_env_expansion.py

    Show the diff
    • cli.py
      @@ -301,7 +301,11 @@ def load_cli_config() -> Dict[str, Any]:
                       defaults["agent"]["max_turns"] = file_config["max_turns"]
               except Exception as e:
                   logger.warning("Failed to load cli-config.yaml: %s", e)
      -    
      +
      +    # Expand ${ENV_VAR} references in config values before bridging to env vars.
      +    from hermes_cli.config import _expand_env_vars
      +    defaults = _expand_env_vars(defaults)
      +
           # Apply terminal config to environment variables (so terminal_tool picks them up)
           terminal_config = defaults.get("terminal", {})
           
    • gateway/run.py
      @@ -93,6 +93,9 @@ def _ensure_ssl_certs() -> None:
               import yaml as _yaml
               with open(_config_path, encoding="utf-8") as _f:
                   _cfg = _yaml.safe_load(_f) or {}
      +        # Expand ${ENV_VAR} references before bridging to env vars.
      +        from hermes_cli.config import _expand_env_vars
      +        _cfg = _expand_env_vars(_cfg)
               # Top-level simple values (fallback only — don't override .env)
               for _key, _val in _cfg.items():
                   if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
    • hermes_cli/config.py
      @@ -1172,6 +1172,26 @@ def _deep_merge(base: dict, override: dict) -> dict:
           return result
       
       
      +def _expand_env_vars(obj):
      +    """Recursively expand ``${VAR}`` references in config values.
      +
      +    Only string values are processed; dict keys, numbers, booleans, and
      +    None are left untouched.  Unresolved references (variable not in
      +    ``os.environ``) are kept verbatim so callers can detect them.
      +    """
      +    if isinstance(obj, str):
      +        return re.sub(
      +            r"\${([^}]+)}",
      +            lambda m: os.environ.get(m.group(1), m.group(0)),
      +            obj,
      +        )
      +    if isinstance(obj, dict):
      +        return {k: _expand_env_vars(v) for k, v in obj.items()}
      +    if isinstance(obj, list):
      +        return [_expand_env_vars(item) for item in obj]
      +    return obj
      +
      +
       def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
           """Normalize legacy root-level max_turns into agent.max_turns."""
           config = dict(config)
      @@ -1213,7 +1233,7 @@ def load_config() -> Dict[str, Any]:
               except Exception as e:
                   print(f"Warning: Failed to load config: {e}")
           
      -    return _normalize_max_turns_config(config)
      +    return _expand_env_vars(_normalize_max_turns_config(config))
       
       
       _SECURITY_COMMENT = """
    • tests/test_config_env_expansion.py
      @@ -0,0 +1,132 @@
      +"""Tests for ${ENV_VAR} substitution in config.yaml values."""
      +
      +import os
      +import pytest
      +from hermes_cli.config import _expand_env_vars, load_config
      +from unittest.mock import patch as mock_patch
      +
      +
      +class TestExpandEnvVars:
      +    def test_simple_substitution(self):
      +        with pytest.MonkeyPatch().context() as mp:
      +            mp.setenv("MY_KEY", "secret123")
      +            assert _expand_env_vars("${MY_KEY}") == "secret123"
      +
      +    def test_missing_var_kept_verbatim(self):
      +        with pytest.MonkeyPatch().context() as mp:
      +            mp.delenv("UNDEFINED_VAR_XYZ", raising=False)
      +            assert _expand_env_vars("${UNDEFINED_VAR_XYZ}") == "${UNDEFINED_VAR_XYZ}"
      +
      +    def test_no_placeholder_unchanged(self):
      +        assert _expand_env_vars("plain-value") == "plain-value"
      +
      +    def test_dict_recursive(self):
      +        with pytest.MonkeyPatch().context() as mp:
      +            mp.setenv("TOKEN", "tok-abc")
      +            result = _expand_env_vars({"key": "${TOKEN}", "other": "literal"})
      +            assert result == {"key": "tok-abc", "other": "literal"}
      +
      +    def test_nested_dict(self):
      +        with pytest.MonkeyPatch().context() as mp:
      +            mp.setenv("API_KEY", "sk-xyz")
      +            result = _expand_env_vars({"model": {"api_key": "${API_KEY}"}})
      +            assert result["model"]["api_key"] == "sk-xyz"
      +
      +    def test_list_items(self):
      +        with pytest.MonkeyPatch().context() as mp:
      +            mp.setenv("VAL", "hello")
      +            result = _expand_env_vars(["${VAL}", "literal", 42])
      +            assert result == ["hello", "literal", 42]
      +
      +    def test_non_string_values_untouched(self):
      +        assert _expand_env_vars(42) == 42
      +        assert _expand_env_vars(3.14) == 3.14
      +        assert _expand_env_vars(True) is True
      +        assert _expand_env_vars(None) is None
      +
      +    def test_multiple_placeholders_in_one_string(self):
      +        with pytest.MonkeyPatch().context() as mp:
      +            mp.setenv("HOST", "localhost")
      +            mp.setenv("PORT", "5432")
      +            assert _expand_env_vars("${HOST}:${PORT}") == "localhost:5432"
      +
      +    def test_dict_keys_not_expanded(self):
      +        with pytest.MonkeyPatch().context() as mp:
      +            mp.setenv("KEY", "value")
      +            result = _expand_env_vars({"${KEY}": "no-expand-key"})
      +            assert "${KEY}" in result
      +
      +
      +class TestLoadConfigExpansion:
      +    def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):
      +        config_yaml = (
      +            "model:\n"
      +            "  api_key: ${GOOGLE_API_KEY}\n"
      +            "platforms:\n"
      +            "  telegram:\n"
      +            "    token: ${TELEGRAM_BOT_TOKEN}\n"
      +            "plain: no-substitution\n"
      +        )
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(config_yaml)
      +
      +        monkeypatch.setenv("GOOGLE_API_KEY", "gsk-test-key")
      +        monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567:ABC-token")
      +        monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
      +
      +        config = load_config()
      +
      +        assert config["model"]["api_key"] == "gsk-test-key"
      +        assert config["platforms"]["telegram"]["token"] == "1234567:ABC-token"
      +        assert config["plain"] == "no-substitution"
      +
      +    def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
      +        config_yaml = "model:\n  api_key: ${NOT_SET_XYZ_123}\n"
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(config_yaml)
      +
      +        monkeypatch.delenv("NOT_SET_XYZ_123", raising=False)
      +        monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
      +
      +        config = load_config()
      +
      +        assert config["model"]["api_key"] == "${NOT_SET_XYZ_123}"
      +
      +
      +class TestLoadCliConfigExpansion:
      +    """Verify that load_cli_config() also expands ${VAR} references."""
      +
      +    def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):
      +        config_yaml = (
      +            "auxiliary:\n"
      +            "  vision:\n"
      +            "    api_key: ${TEST_VISION_KEY_XYZ}\n"
      +        )
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(config_yaml)
      +
      +        monkeypatch.setenv("TEST_VISION_KEY_XYZ", "vis-key-123")
      +        # Patch the hermes home so load_cli_config finds our test config
      +        monkeypatch.setattr("cli._hermes_home", tmp_path)
      +
      +        from cli import load_cli_config
      +        config = load_cli_config()
      +
      +        assert config["auxiliary"]["vision"]["api_key"] == "vis-key-123"
      +
      +    def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
      +        config_yaml = (
      +            "auxiliary:\n"
      +            "  vision:\n"
      +            "    api_key: ${UNSET_CLI_VAR_ABC}\n"
      +        )
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(config_yaml)
      +
      +        monkeypatch.delenv("UNSET_CLI_VAR_ABC", raising=False)
      +        monkeypatch.setattr("cli._hermes_home", tmp_path)
      +
      +        from cli import load_cli_config
      +        config = load_cli_config()
      +
      +        assert config["auxiliary"]["vision"]["api_key"] == "${UNSET_CLI_VAR_ABC}"

1 PR closed without merging

From the close comments: merged via another change 1

  • #2680 feat(config): support ${ENV_VAR} substitution in config.yaml · merged via another change → #2684
Closed issues · 1
  • Closed#2677Feature request: Support ${ENV_VAR} substitution in config.yaml for API keys
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#2684
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
23 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.