{"repo":"NousResearch/hermes-agent","public_id":"58514","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":null,"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":59139,"merged_at":"2026-07-05T21:41:41+00:00"},"resolution":{"state":"resolved","by":"pr-59139"},"freshness":{"state":"verified","last_verified_at":"2026-07-05T21:41:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":58514,"pr_number":59139,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-05T21:41:41+00:00"}],"members":[{"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":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"},"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+    \"39274208+falkoro@users.noreply.github.com\": \"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     \"3723267+kevinrajaram@users.noreply.github.com\": \"kevinrajaram\",  # PR #3850 salvage (gateway: add POSIX system dirs to PATH so launchctl/systemctl resolve under UV's minimal-PATH bundled Python; #3849)\n     \"lord-dubious@users.noreply.github.com\": \"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: ${LATE_DOTENV_KEY_58514}\\n\"\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: ${ROTATED_KEY_58514}\\n\"\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: ${STABLE_KEY_58514}\\n\"\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}}