NousResearch/hermes-agent

ENV_VAR placeholder expansion in config and gateway

Cluster 58514. Data as of

Nothing open on GitHub. Fix merged in #59139 on 5 Jul 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 #59139. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #59139. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

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.

  • #59139 fix(config): invalidate load_config cache when referenced ${VAR} env values change

    merged to main

    files: hermes_cli/config.py, scripts/release.py, tests/hermes_cli/test_config_env_expansion.py

    Show the diff
    • hermes_cli/config.py
      @@ -224,9 +224,11 @@ def _reject_denylisted_env_var(key: str) -> None:
       # produces a fresh inode, so stat() sees a new mtime_ns and the next
       # load repopulates automatically — no explicit invalidation hook.
       # Cached tuple is (user_mtime_ns, user_size, managed_mtime_ns, managed_size,
      -# merged_value) — the managed-file signature is folded in so editing the
      -# managed-scope config.yaml invalidates the cache (see managed_scope).
      -_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any]]] = {}
      +# merged_value, env_ref_snapshot) — the managed-file signature is folded in so
      +# editing the managed-scope config.yaml invalidates the cache (see
      +# managed_scope), and the env snapshot invalidates it when a referenced ${VAR}
      +# changes value (late .env load, in-process rotation — #58514).
      +_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any], Dict[str, Optional[str]]]] = {}
       # (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
       # _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
       # the user's on-disk values without defaults merged in.
      @@ -6147,6 +6149,31 @@ def _expand_env_vars(obj):
           return obj
       
       
      +def _env_ref_snapshot(obj, snapshot=None):
      +    """Map every ``${VAR}`` name referenced in config values to its current
      +    ``os.environ`` value (``None`` when unset).
      +
      +    Stored alongside cached ``load_config()`` results so a cache hit can
      +    detect that the cached expansion was made against a *different*
      +    environment — e.g. a ``load_config()`` that ran before
      +    ``load_hermes_dotenv()`` populated the process env, or an env var
      +    rotated in-process after the first load. File mtime/size alone cannot
      +    see either case (#58514).
      +    """
      +    if snapshot is None:
      +        snapshot = {}
      +    if isinstance(obj, str):
      +        for name in re.findall(r"\${([^}]+)}", obj):
      +            snapshot[name] = os.environ.get(name)
      +    elif isinstance(obj, dict):
      +        for value in obj.values():
      +            _env_ref_snapshot(value, snapshot)
      +    elif isinstance(obj, list):
      +        for item in obj:
      +            _env_ref_snapshot(item, snapshot)
      +    return snapshot
      +
      +
       def _items_by_unique_name(items):
           """Return a name-indexed dict only when all items have unique string names."""
           if not isinstance(items, list):
      @@ -6746,7 +6773,14 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
       
               cached = _LOAD_CONFIG_CACHE.get(path_key)
               if cached is not None and cache_sig is not None and cached[:4] == cache_sig:
      -            return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]
      +            # File signatures match, but the cached expansion is only valid if
      +            # every ${VAR} it was expanded against still has the same value.
      +            # Without this, a load_config() that ran before load_hermes_dotenv()
      +            # pins unexpanded literals (e.g. auxiliary.<task>.api_key) for the
      +            # life of the process (#58514).
      +            env_snapshot = cached[5] if len(cached) > 5 else {}
      +            if all(os.environ.get(k) == v for k, v in env_snapshot.items()):
      +                return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]
       
               config = copy.deepcopy(DEFAULT_CONFIG)
       
      @@ -6783,9 +6817,15 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
                   # (deepcopy=True) callers can mutate freely without affecting the
                   # cached value, and ``load_config_readonly()`` (deepcopy=False)
                   # callers all see the same stable cached object. The cached tuple is
      -            # (user_mtime, user_size, managed_mtime, managed_size, value).
      +            # (user_mtime, user_size, managed_mtime, managed_size, value,
      +            # env_ref_snapshot). The snapshot records the environment values
      +            # this expansion was made against so later loads can detect env
      +            # drift (late .env load, in-process rotation) — see cache hit above.
                   cached_copy = copy.deepcopy(expanded)
      -            _LOAD_CONFIG_CACHE[path_key] = (*cache_sig, cached_copy)
      +            env_snapshot = _env_ref_snapshot(normalized)
      +            if managed_config:
      +                _env_ref_snapshot(managed_config, env_snapshot)
      +            _LOAD_CONFIG_CACHE[path_key] = (*cache_sig, cached_copy, env_snapshot)
                   # On the readonly path return the same cached object subsequent
                   # calls will see — keeps "two readonly calls return the same
                   # object" invariant that callers may rely on for identity checks.
    • scripts/release.py
      @@ -47,6 +47,7 @@
       AUTHOR_MAP = {
           "[redacted:email]": "devatnull",  # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction)
           "[redacted:email]": "tjp2021",  # PR #4097 salvage (copilot: per-turn x-initiator header so user prompts bill as premium requests; #3040)
      +    "[email protected]": "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)
           "[email protected]": "kevinrajaram",  # PR #3850 salvage (gateway: add POSIX system dirs to PATH so launchctl/systemctl resolve under UV's minimal-PATH bundled Python; #3849)
           "[email protected]": "lord-dubious",  # PR #58453 salvage (preserve static custom provider models declared as dict rows)
           "[redacted:email]": "JAlmanzarMint",  # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890
    • tests/hermes_cli/test_config_env_expansion.py
      @@ -94,6 +94,59 @@ def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
               assert config["model"]["api_key"] == "${NOT_SET_XYZ_123}"
       
       
      +class TestLoadConfigCacheEnvStaleness:
      +    """The load_config() cache must not pin expansions made against a stale
      +    environment (#58514): a load before load_hermes_dotenv() runs, or an env
      +    var rotated in-process, must not keep serving the old expansion."""
      +
      +    def test_env_var_appearing_after_first_load_invalidates_cache(self, tmp_path, monkeypatch):
      +        config_yaml = "auxiliary:\n  vision:\n    api_key: ${LATE_DOTENV_KEY_58514}\n"
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(config_yaml)
      +
      +        monkeypatch.delenv("LATE_DOTENV_KEY_58514", raising=False)
      +        monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
      +
      +        # First load happens before the var exists (pre-dotenv): literal kept.
      +        assert load_config()["auxiliary"]["vision"]["api_key"] == "${LATE_DOTENV_KEY_58514}"
      +
      +        # .env load brings the var in — same file mtime/size, env changed.
      +        monkeypatch.setenv("LATE_DOTENV_KEY_58514", "nvapi-real")
      +        assert load_config()["auxiliary"]["vision"]["api_key"] == "nvapi-real"
      +
      +    def test_env_var_rotation_invalidates_cache(self, tmp_path, monkeypatch):
      +        config_yaml = "providers:\n  mistral:\n    api_key: ${ROTATED_KEY_58514}\n"
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(config_yaml)
      +
      +        monkeypatch.setenv("ROTATED_KEY_58514", "key-v1")
      +        monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
      +
      +        assert load_config()["providers"]["mistral"]["api_key"] == "key-v1"
      +
      +        monkeypatch.setenv("ROTATED_KEY_58514", "key-v2")
      +        assert load_config()["providers"]["mistral"]["api_key"] == "key-v2"
      +
      +    def test_unchanged_env_still_serves_cache(self, tmp_path, monkeypatch):
      +        config_yaml = "providers:\n  mistral:\n    api_key: ${STABLE_KEY_58514}\n"
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(config_yaml)
      +
      +        monkeypatch.setenv("STABLE_KEY_58514", "key-stable")
      +        monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
      +
      +        load_config()
      +        # load_config_readonly() returns the cached object itself, so object
      +        # identity across calls proves the cache-hit path was taken (a rebuild
      +        # would produce a fresh dict).
      +        readonly = load_config.__globals__["load_config_readonly"]
      +        first = readonly()
      +        second = readonly()
      +
      +        assert first is second
      +        assert first["providers"]["mistral"]["api_key"] == "key-stable"
      +
      +
       class TestLoadCliConfigExpansion:
           """Verify that load_cli_config() also expands ${VAR} references."""
       

1 PR closed without merging

From the close comments: merged via another change 1

  • #58519 fix(config): invalidate load_config cache when referenced ${VAR} env values change · merged via another change → e3203e4, #59139
Closed issues · 1
  • Closed#58514aux config: ${VAR} env refs not expanded in auxiliary.* block (works in providers.*)
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

Merged PRs
#59139
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.