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
Open PRs0
Fixes on record
-
#2684 feat(config): support ${ENV_VAR} substitution in config.yaml
files:
cli.py,gateway/run.py,hermes_cli/config.py,tests/test_config_env_expansion.pyShow 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.00means 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 LabAt 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.