{"repo":"NousResearch/hermes-agent","public_id":"14457","display_label":"ENV_VAR placeholder expansion in config and gateway","summary":"Multiple bugs and fixes related to ${VAR} environment variable references not being expanded in config.yaml, including provider base_url, gateway mode, fallback providers, and auxiliary blocks. Several approaches address validation timing issues when placeholders appear before expansion.","subsystem":"agent","counts":{"members":16,"open_issues":2,"open_issues_distinct":2,"open_prs":3,"closed_issues":2,"merged_prs":2,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53584},"resolution":{"state":"partially_resolved","by":"pr-53584,pr-59139"},"freshness":{"state":"verified","last_verified_at":"2026-07-05T21:41:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10899999999999999},"fix_edges":[{"issue_number":14457,"pr_number":53584,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":58514,"pr_number":59139,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":14457,"title":"[Bug]: custom_providers.base_url  placeholders can be validated before expansion and get skipped as invalid URLs","url":null,"state":"closed"},{"kind":"issue","number":20974,"title":"fallback_model.api_key in config.yaml is not expanded for ${VAR} references in gateway mode","url":null,"state":"open"},{"kind":"issue","number":21275,"title":"[Bug]: Gateway environment variable not expanded for custom model providers path","url":null,"state":"open"},{"kind":"issue","number":58514,"title":"aux config: ${VAR} env refs not expanded in auxiliary.* block (works in providers.*)","url":null,"state":"closed"},{"kind":"pr","number":14485,"title":"fix(config): expand provider base_url templates before validation","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":14524,"title":"fix(config): expand ${ENV_VAR} placeholders in gateway config loading (#14457)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"16192103f","number":0},{"kind":"pr","value":"#53584","number":53584}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14628,"title":"fix(config): defer URL validation for base_url containing env var placeholder","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"16192103f","number":0},{"kind":"pr","value":"#53584","number":53584},{"kind":"pr","value":"#15378","number":15378}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15378,"title":"fix(cli): accept placeholder URLs in custom provider config without validation (#14457)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"16192103f","number":0},{"kind":"pr","value":"#53584","number":53584}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":20980,"title":"fix(gateway): expand fallback env vars in gateway mode","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"7245bc77eb3b92b311c622806821375709bbe4d2","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21029,"title":"fix(gateway): expand fallback env references","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21304,"title":"fix(gateway): expand env vars in _load_gateway_config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"16192103f4","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22572,"title":"fix(gateway): expand ${VAR} env refs in _load_gateway_config()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21304","number":21304}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":25005,"title":"fix(gateway): expand env vars in fallback_providers/fallback_model config","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#20974","number":20974},{"kind":"pr","value":"#20980","number":20980},{"kind":"pr","value":"#21029","number":21029}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53584,"title":"fix(config): accept placeholder base_url in custom provider validation (#14457)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/hermes_cli/test_provider_config_validation.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -4269,6 +4269,14 @@ def _normalize_custom_provider_entry(\n         raw_url = entry.get(url_key)\n         if isinstance(raw_url, str) and raw_url.strip():\n             candidate = raw_url.strip()\n+            # Accept URLs containing unresolved placeholder tokens — both\n+            # ``${ENV_VAR}`` env-refs and bare ``{region}``-style templates —\n+            # without URL validation. They are expanded at runtime, so a\n+            # caller reaching this normalizer with raw (un-expanded) config\n+            # would otherwise see the provider silently dropped (#14457).\n+            if re.search(r\"\\{[^}]+\\}\", candidate):\n+                base_url = candidate\n+                break\n             parsed = urlparse(candidate)\n             if parsed.scheme and parsed.netloc:\n                 base_url = candidate"]},{"file":"tests/hermes_cli/test_provider_config_validation.py","hunks":["@@ -191,3 +191,49 @@ def test_models_empty_list_omitted(self):\n         result = _normalize_custom_provider_entry(entry)\n         assert result is not None\n         assert \"models\" not in result\n+\n+    def test_env_var_placeholder_in_base_url_not_rejected(self):\n+        \"\"\"A base_url that is an un-expanded ${ENV_VAR} placeholder must not be\n+        rejected as an invalid URL — it is expanded at runtime, so a caller\n+        reaching this normalizer with raw config would otherwise see the\n+        provider silently dropped. Regression test for #14457.\"\"\"\n+        entry = {\n+            \"name\": \"PROVIDER_A\",\n+            \"base_url\": \"${PROVIDER_A_BASE_URL}\",\n+            \"key_env\": \"PROVIDER_A_API_KEY\",\n+        }\n+        result = _normalize_custom_provider_entry(entry, provider_key=\"PROVIDER_A\")\n+        assert result is not None\n+        assert result[\"base_url\"] == \"${PROVIDER_A_BASE_URL}\"\n+\n+    def test_multiple_env_vars_in_base_url(self):\n+        \"\"\"base_url with multiple ${VAR} placeholders is accepted verbatim.\"\"\"\n+        entry = {\n+            \"name\": \"multi-var-provider\",\n+            \"base_url\": \"${SCHEME}://${HOST}:${PORT}/v1\",\n+        }\n+        result = _normalize_custom_provider_entry(entry)\n+        assert result is not None\n+        assert result[\"base_url\"] == \"${SCHEME}://${HOST}:${PORT}/v1\"\n+\n+    def test_bare_brace_region_placeholder_accepted(self):\n+        \"\"\"A bare {region}-style template token (not an env-ref) is also\n+        accepted without validation, supporting region-substitution URLs.\"\"\"\n+        entry = {\n+            \"name\": \"regional\",\n+            \"base_url\": \"https://{region}.api.example.com/v1\",\n+        }\n+        result = _normalize_custom_provider_entry(entry, provider_key=\"regional\")\n+        assert result is not None\n+        assert result[\"base_url\"] == \"https://{region}.api.example.com/v1\"\n+\n+    def test_invalid_url_without_placeholder_still_rejected(self):\n+        \"\"\"A malformed URL with no scheme/host AND no placeholder token is\n+        still rejected — the placeholder bypass must not weaken validation of\n+        ordinary literal URLs.\"\"\"\n+        entry = {\n+            \"name\": \"bad\",\n+            \"base_url\": \"not-a-url\",\n+        }\n+        result = _normalize_custom_provider_entry(entry, provider_key=\"bad\")\n+        assert result is None\n"]}]},{"kind":"pr","number":58519,"title":"fix(config): invalidate load_config cache when referenced ${VAR} env values change","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e3203e4d8","number":0},{"kind":"pr","value":"#59139","number":59139}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59139,"title":"fix(config): invalidate load_config cache when referenced ${VAR} env values change","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","scripts/release.py","tests/hermes_cli/test_config_env_expansion.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -224,9 +224,11 @@ def _reject_denylisted_env_var(key: str) -> None:\n # produces a fresh inode, so stat() sees a new mtime_ns and the next\n # load repopulates automatically — no explicit invalidation hook.\n # Cached tuple is (user_mtime_ns, user_size, managed_mtime_ns, managed_size,\n-# merged_value) — the managed-file signature is folded in so editing the\n-# managed-scope config.yaml invalidates the cache (see managed_scope).\n-_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any]]] = {}\n+# merged_value, env_ref_snapshot) — the managed-file signature is folded in so\n+# editing the managed-scope config.yaml invalidates the cache (see\n+# managed_scope), and the env snapshot invalidates it when a referenced ${VAR}\n+# changes value (late .env load, in-process rotation — #58514).\n+_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any], Dict[str, Optional[str]]]] = {}\n # (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as\n # _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want\n # the user's on-disk values without defaults merged in.","@@ -6147,6 +6149,31 @@ def _expand_env_vars(obj):\n     return obj\n \n \n+def _env_ref_snapshot(obj, snapshot=None):\n+    \"\"\"Map every ``${VAR}`` name referenced in config values to its current\n+    ``os.environ`` value (``None`` when unset).\n+\n+    Stored alongside cached ``load_config()`` results so a cache hit can\n+    detect that the cached expansion was made against a *different*\n+    environment — e.g. a ``load_config()`` that ran before\n+    ``load_hermes_dotenv()`` populated the process env, or an env var\n+    rotated in-process after the first load. File mtime/size alone cannot\n+    see either case (#58514).\n+    \"\"\"\n+    if snapshot is None:\n+        snapshot = {}\n+    if isinstance(obj, str):\n+        for name in re.findall(r\"\\${([^}]+)}\", obj):\n+            snapshot[name] = os.environ.get(name)\n+    elif isinstance(obj, dict):\n+        for value in obj.values():\n+            _env_ref_snapshot(value, snapshot)\n+    elif isinstance(obj, list):\n+        for item in obj:\n+            _env_ref_snapshot(item, snapshot)\n+    return snapshot\n+\n+\n def _items_by_unique_name(items):\n     \"\"\"Return a name-indexed dict only when all items have unique string names.\"\"\"\n     if not isinstance(items, list):","@@ -6746,7 +6773,14 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:\n \n         cached = _LOAD_CONFIG_CACHE.get(path_key)\n         if cached is not None and cache_sig is not None and cached[:4] == cache_sig:\n-            return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]\n+            # File signatures match, but the cached expansion is only valid if\n+            # every ${VAR} it was expanded against still has the same value.\n+            # Without this, a load_config() that ran before load_hermes_dotenv()\n+            # pins unexpanded literals (e.g. auxiliary.<task>.api_key) for the\n+            # life of the process (#58514).\n+            env_snapshot = cached[5] if len(cached) > 5 else {}\n+            if all(os.environ.get(k) == v for k, v in env_snapshot.items()):\n+                return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]\n \n         config = copy.deepcopy(DEFAULT_CONFIG)\n ","@@ -6783,9 +6817,15 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:\n             # (deepcopy=True) callers can mutate freely without affecting the\n             # cached value, and ``load_config_readonly()`` (deepcopy=False)\n             # callers all see the same stable cached object. The cached tuple is\n-            # (user_mtime, user_size, managed_mtime, managed_size, value).\n+            # (user_mtime, user_size, managed_mtime, managed_size, value,\n+            # env_ref_snapshot). The snapshot records the environment values\n+            # this expansion was made against so later loads can detect env\n+            # drift (late .env load, in-process rotation) — see cache hit above.\n             cached_copy = copy.deepcopy(expanded)\n-            _LOAD_CONFIG_CACHE[path_key] = (*cache_sig, cached_copy)\n+            env_snapshot = _env_ref_snapshot(normalized)\n+            if managed_config:\n+                _env_ref_snapshot(managed_config, env_snapshot)\n+            _LOAD_CONFIG_CACHE[path_key] = (*cache_sig, cached_copy, env_snapshot)\n             # On the readonly path return the same cached object subsequent\n             # calls will see — keeps \"two readonly calls return the same\n             # object\" invariant that callers may rely on for identity checks."]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,7 @@\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"devatnull\",  # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction)\n     \"[redacted:email]\": \"tjp2021\",  # PR #4097 salvage (copilot: per-turn x-initiator header so user prompts bill as premium requests; #3040)\n+    \"[redacted:email]\": \"falkoro\",  # PRs #58519/#58520 salvage (config: env-ref-aware load_config cache invalidation; auxiliary: honor auxiliary.<task>.base_url/api_key with explicit provider arg)\n     \"[redacted:email]\": \"kevinrajaram\",  # PR #3850 salvage (gateway: add POSIX system dirs to PATH so launchctl/systemctl resolve under UV's minimal-PATH bundled Python; #3849)\n     \"[redacted:email]\": \"lord-dubious\",  # PR #58453 salvage (preserve static custom provider models declared as dict rows)\n     \"[redacted:email]\": \"JAlmanzarMint\",  # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890"]},{"file":"tests/hermes_cli/test_config_env_expansion.py","hunks":["@@ -94,6 +94,59 @@ def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):\n         assert config[\"model\"][\"api_key\"] == \"${NOT_SET_XYZ_123}\"\n \n \n+class TestLoadConfigCacheEnvStaleness:\n+    \"\"\"The load_config() cache must not pin expansions made against a stale\n+    environment (#58514): a load before load_hermes_dotenv() runs, or an env\n+    var rotated in-process, must not keep serving the old expansion.\"\"\"\n+\n+    def test_env_var_appearing_after_first_load_invalidates_cache(self, tmp_path, monkeypatch):\n+        config_yaml = \"auxiliary:\\n  vision:\\n    api_key: [redacted:secret-assignment]\"\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(config_yaml)\n+\n+        monkeypatch.delenv(\"LATE_DOTENV_KEY_58514\", raising=False)\n+        monkeypatch.setitem(load_config.__globals__, \"get_config_path\", lambda: config_file)\n+\n+        # First load happens before the var exists (pre-dotenv): literal kept.\n+        assert load_config()[\"auxiliary\"][\"vision\"][\"api_key\"] == \"${LATE_DOTENV_KEY_58514}\"\n+\n+        # .env load brings the var in — same file mtime/size, env changed.\n+        monkeypatch.setenv(\"LATE_DOTENV_KEY_58514\", \"nvapi-real\")\n+        assert load_config()[\"auxiliary\"][\"vision\"][\"api_key\"] == \"nvapi-real\"\n+\n+    def test_env_var_rotation_invalidates_cache(self, tmp_path, monkeypatch):\n+        config_yaml = \"providers:\\n  mistral:\\n    api_key: [redacted:secret-assignment]\"\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(config_yaml)\n+\n+        monkeypatch.setenv(\"ROTATED_KEY_58514\", \"key-v1\")\n+        monkeypatch.setitem(load_config.__globals__, \"get_config_path\", lambda: config_file)\n+\n+        assert load_config()[\"providers\"][\"mistral\"][\"api_key\"] == \"key-v1\"\n+\n+        monkeypatch.setenv(\"ROTATED_KEY_58514\", \"key-v2\")\n+        assert load_config()[\"providers\"][\"mistral\"][\"api_key\"] == \"key-v2\"\n+\n+    def test_unchanged_env_still_serves_cache(self, tmp_path, monkeypatch):\n+        config_yaml = \"providers:\\n  mistral:\\n    api_key: [redacted:secret-assignment]\"\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(config_yaml)\n+\n+        monkeypatch.setenv(\"STABLE_KEY_58514\", \"key-stable\")\n+        monkeypatch.setitem(load_config.__globals__, \"get_config_path\", lambda: config_file)\n+\n+        load_config()\n+        # load_config_readonly() returns the cached object itself, so object\n+        # identity across calls proves the cache-hit path was taken (a rebuild\n+        # would produce a fresh dict).\n+        readonly = load_config.__globals__[\"load_config_readonly\"]\n+        first = readonly()\n+        second = readonly()\n+\n+        assert first is second\n+        assert first[\"providers\"][\"mistral\"][\"api_key\"] == \"key-stable\"\n+\n+\n class TestLoadCliConfigExpansion:\n     \"\"\"Verify that load_cli_config() also expands ${VAR} references.\"\"\"\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}