diff --git a/cli.py b/cli.py index af1077744..e5abc2c00 100644 --- a/cli.py +++ b/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", {}) diff --git a/gateway/run.py b/gateway/run.py index 7876565b4..95d1f43e9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -93,6 +93,9 @@ if _config_path.exists(): 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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4c6179c5f..6b45ea8f8 100644 --- a/hermes_cli/config.py +++ b/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 = """ diff --git a/tests/test_config_env_expansion.py b/tests/test_config_env_expansion.py new file mode 100644 index 000000000..860129ce8 --- /dev/null +++ b/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}"