Credential pool provider isolation fixes and misc patches

NousResearch/hermes-agent · cluster 25727 · agent

Data as of

Nothing open on GitHub. Fix merged in #56903. Check it covers your case.

The short answer

0open issues
0open PRs
20items grouped
Known?
Yes. 20 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #56903. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #56903, #63048. That is separate from the fix event.
Still open
Nothing open on GitHub.

The majority of members address a bug where credential pools are not scoped to their provider, causing cross-provider credential mismatches during fallback and primary restore. A smaller number of unrelated fixes cover subprocess timeouts, event loop blocking, deprecated datetime usage, and async exception handling.

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.

  • #56903 fix(agent): provider-match credential pool on primary restore (salvage #56392)

    merged to main

    files: agent/agent_runtime_helpers.py, tests/run_agent/test_primary_runtime_restore.py

    Show the diff
    • agent/agent_runtime_helpers.py
      @@ -1184,11 +1184,38 @@ def restore_primary_runtime(agent) -> bool:
               if pool is not None and pool.has_available():
                   entry = pool.select()
                   if entry is not None:
      +                entry_provider = str(getattr(entry, "provider", "") or "").strip().lower()
      +                primary_provider = str(rt.get("provider") or "").strip().lower()
      +                entry_matches_primary = entry_provider == primary_provider
      +                # Custom endpoints all carry the generic ``custom`` provider on
      +                # the agent while the pool entry is keyed ``custom:<name>`` (see
      +                # CUSTOM_POOL_PREFIX). Resolve the primary's base_url to its
      +                # ``custom:<name>`` key via the canonical helper and compare
      +                # against the entry's key — this mirrors the sibling guard in
      +                # ``recover_with_credential_pool`` (see above) and correctly
      +                # disambiguates multiple custom providers that share one gateway
      +                # base_url. Fixes #56885.
      +                from agent.credential_pool import CUSTOM_POOL_PREFIX
      +                if (
      +                    primary_provider == "custom"
      +                    and entry_provider.startswith(CUSTOM_POOL_PREFIX)
      +                ):
      +                    entry_matches_primary = False
      +                    try:
      +                        from agent.credential_pool import get_custom_provider_pool_key
      +                        primary_base_url = str(rt.get("base_url") or "").strip()
      +                        primary_key = (
      +                            get_custom_provider_pool_key(primary_base_url) or ""
      +                        ).strip().lower()
      +                        entry_matches_primary = bool(primary_key) and primary_key == entry_provider
      +                    except Exception:
      +                        entry_matches_primary = False
      +
                       entry_key = (
                           getattr(entry, "runtime_api_key", None)
                           or getattr(entry, "access_token", "")
                       )
      -                if entry_key:
      +                if entry_key and entry_matches_primary:
                           # ``_swap_credential`` rebuilds the OpenAI/Anthropic client,
                           # reapplies base-url-scoped headers, and carries the
                           # accumulated base_url / OAuth-detection fixes (#33163).
      @@ -1198,6 +1225,14 @@ def restore_primary_runtime(agent) -> bool:
                               getattr(entry, "id", "?"),
                               getattr(entry, "label", "?"),
                           )
      +                elif entry_key:
      +                    logger.info(
      +                        "Restore skipped pool entry %s (%s): provider %s does not match primary provider %s",
      +                        getattr(entry, "id", "?"),
      +                        getattr(entry, "label", "?"),
      +                        entry_provider or "?",
      +                        primary_provider or "?",
      +                    )
       
               # ── Reset fallback chain for the new turn ──
               agent._fallback_activated = False
    • tests/run_agent/test_primary_runtime_restore.py
      @@ -201,6 +201,179 @@ def test_restores_prompt_caching_flag(self):
       
               assert agent._use_prompt_caching == original_caching
       
      +    def test_restore_skips_cross_provider_pool_entry(self):
      +        """Restore must not swap in a fallback provider credential for the primary runtime."""
      +
      +        class _Entry:
      +            provider = "openrouter"
      +            id = "fallback-entry"
      +            label = "fallback"
      +            runtime_api_key = "[redacted:secret-assignment]"
      +            runtime_base_url = "https://openrouter.ai/api/v1"
      +            access_token = "[redacted:secret-assignment]"
      +
      +        class _Pool:
      +            provider = "openrouter"
      +
      +            def has_available(self):
      +                return True
      +
      +            def select(self):
      +                return _Entry()
      +
      +        agent = _make_agent(
      +            provider="custom",
      +            base_url="https://primary.example.com/v1",
      +            fallback_model={"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
      +        )
      +        original_base_url = agent.base_url
      +        mock_client = _mock_resolve()
      +        with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)):
      +            agent._try_activate_fallback()
      +        agent._credential_pool = [redacted:secret-assignment]
      +        agent._swap_credential = [redacted:secret-assignment]
      +
      +        with patch("run_agent.OpenAI", return_value=MagicMock()):
      +            result = agent._restore_primary_runtime()
      +
      +        assert result is True
      +        assert agent.provider == "custom"
      +        assert agent.base_url == original_base_url
      +        agent._swap_credential.assert_not_called()
      +
      +    def test_restore_keeps_primary_base_url_when_fallback_pool_attached(self):
      +        """Issue #56885: plain-provider primary must not inherit a fallback
      +        provider's base_url via the restore-path pool reselect.
      +
      +        Repro: primary is openai-api/gpt-5.5, a transient failure falls back to
      +        deepseek and attaches deepseek's credential pool. On the next turn the
      +        restore reselect must NOT swap in the deepseek entry — otherwise the
      +        request goes out as model=gpt-5.5 to base_url=api.deepseek.com → 404.
      +        """
      +
      +        class _DeepseekEntry:
      +            provider = "deepseek"
      +            id = "dsk-1"
      +            label = "deepseek-key"
      +            runtime_api_key = "[redacted:secret-assignment]"
      +            runtime_base_url = "https://api.deepseek.com/v1"
      +            base_url = "https://api.deepseek.com/v1"
      +            access_token = "[redacted:secret-assignment]"
      +
      +        class _DeepseekPool:
      +            provider = "deepseek"
      +
      +            def has_available(self):
      +                return True
      +
      +            def select(self):
      +                return _DeepseekEntry()
      +
      +        agent = _make_agent(
      +            provider="openai-api",
      +            base_url="https://api.openai.com/v1",
      +            fallback_model={"provider": "deepseek", "model": "deepseek-v4-flash"},
      +        )
      +        primary_base_url = agent.base_url
      +        primary_provider = agent.provider
      +        mock_client = _mock_resolve(base_url="https://api.deepseek.com/v1")
      +        with patch(
      +            "agent.auxiliary_client.resolve_provider_client",
      +            return_value=(mock_client, None),
      +        ):
      +            agent._try_activate_fallback()
      +        # Fallback attached deepseek's pool; simulate it surviving into the next turn.
      +        agent._credential_pool = [redacted:secret-assignment]
      +        agent._swap_credential = [redacted:secret-assignment]
      +
      +        with patch("run_agent.OpenAI", return_value=MagicMock()):
      +            result = agent._restore_primary_runtime()
      +
      +        assert result is True
      +        assert agent.provider == primary_provider
      +        assert agent.base_url == primary_base_url
      +        assert "deepseek" not in str(agent.base_url)
      +        agent._swap_credential.assert_not_called()
      +
      +    def test_restore_swaps_matching_custom_pool_entry(self):
      +        """Custom primary + custom:<name> entry whose base_url resolves to the
      +        SAME custom key must swap (legitimate same-endpoint rotation)."""
      +
      +        class _Entry:
      +            provider = "custom:myllm"
      +            id = "custom-entry"
      +            label = "myllm"
      +            runtime_api_key = "[redacted:secret-assignment]"
      +            runtime_base_url = "https://my-llm.example.com/v1"
      +            access_token = "[redacted:secret-assignment]"
      +
      +        class _Pool:
      +            provider = "custom:myllm"
      +
      +            def has_available(self):
      +                return True
      +
      +            def select(self):
      +                return _Entry()
      +
      +        agent = _make_agent(provider="custom", base_url="https://my-llm.example.com/v1")
      +        agent._fallback_activated = True
      +        agent._credential_pool = [redacted:secret-assignment]
      +        agent._swap_credential = [redacted:secret-assignment]
      +
      +        with (
      +            patch(
      +                "agent.credential_pool.get_custom_provider_pool_key",
      +                return_value="custom:myllm",
      +            ),
      +            patch("run_agent.OpenAI", return_value=MagicMock()),
      +        ):
      +            result = agent._restore_primary_runtime()
      +
      +        assert result is True
      +        agent._swap_credential.assert_called_once()
      +
      +    def test_restore_skips_cross_endpoint_custom_pool_entry(self):
      +        """Custom primary + custom:<name> entry whose base_url resolves to a
      +        DIFFERENT custom key must skip — two named custom providers sharing a
      +        gateway must not cross-contaminate."""
      +
      +        class _Entry:
      +            provider = "custom:otherllm"
      +            id = "other-entry"
      +            label = "otherllm"
      +            runtime_api_key = "[redacted:secret-assignment]"
      +            runtime_base_url = "https://my-llm.example.com/v1"
      +            access_token = "[redacted:secret-assignment]"
      +
      +        class _Pool:
      +            provider = "custom:otherllm"
      +
      +            def has_available(self):
      +                return True
      +
      +            def select(self):
      +                return _Entry()
      +
      +        agent = _make_agent(provider="custom", base_url="https://my-llm.example.com/v1")
      +        agent._fallback_activated = True
      +        original_base_url = agent.base_url
      +        agent._credential_pool = [redacted:secret-assignment]
      +        agent._swap_credential = [redacted:secret-assignment]
      +
      +        with (
      +            patch(
      +                "agent.credential_pool.get_custom_provider_pool_key",
      +                return_value="custom:myllm",  # primary resolves to a DIFFERENT key
      +            ),
      +            patch("run_agent.OpenAI", return_value=MagicMock()),
      +        ):
      +            result = agent._restore_primary_runtime()
      +
      +        assert result is True
      +        assert agent.base_url == original_base_url
      +        agent._swap_credential.assert_not_called()
      +
           def test_restore_survives_exception(self):
               """If client rebuild fails, the method returns False gracefully."""
               agent = _make_agent()
  • #63048 fix(auth): enforce credential pool provider boundaries

    merged to main

    files: agent/agent_init.py, agent/credential_pool.py, hermes_cli/runtime_provider.py, tests/agent/test_credential_pool_provider_boundary.py

    Show the diff
    • agent/agent_init.py
      @@ -412,13 +412,25 @@ def init_agent(
           agent.skip_context_files = skip_context_files
           agent.load_soul_identity = load_soul_identity
           agent.pass_session_id = pass_session_id
      -    agent._credential_pool = [redacted:secret-assignment]
           agent.log_prefix_chars = log_prefix_chars
           agent.log_prefix = f"{log_prefix} " if log_prefix else ""
           # Store effective base URL for feature detection (prompt caching, reasoning, etc.)
           agent.base_url = base_url or ""
           provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
           agent.provider = provider_name or ""
      +    if credential_pool is not None:
      +        try:
      +            from agent.credential_pool import credential_pool_matches_provider
      +
      +            if not credential_pool_matches_provider(
      +                credential_pool,
      +                agent.provider,
      +                base_url=agent.base_url,
      +            ):
      +                credential_pool = None
      +        except Exception:
      +            credential_pool = None
      +    agent._credential_pool = [redacted:secret-assignment]
           agent.acp_command = acp_command or command
           agent.acp_args = list(acp_args or args or [])
           if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}:
    • agent/credential_pool.py
      @@ -445,6 +445,44 @@ def get_pool_strategy(provider: str) -> str:
           return STRATEGY_FILL_FIRST
       
       
      +def credential_pool_matches_provider(
      +    pool_or_provider: Any,
      +    provider: Optional[str],
      +    *,
      +    base_url: Optional[str] = None,
      +) -> bool:
      +    """Return whether a pool belongs to the requested runtime provider.
      +
      +    Named custom endpoints intentionally use two identities: the live agent is
      +    ``custom`` while its pool is keyed ``custom:<name>``. Accept that pair only
      +    when the runtime base URL resolves to the exact same custom pool key.
      +    Empty string identities fail closed. Legacy pool adapters without a
      +    ``provider`` attribute remain compatible; production pools are scoped.
      +    """
      +    raw_pool_provider = getattr(pool_or_provider, "provider", None)
      +    if raw_pool_provider is None:
      +        if isinstance(pool_or_provider, str):
      +            raw_pool_provider = pool_or_provider
      +        else:
      +            # Backward compatibility for lightweight/unscoped pool adapters.
      +            # Production CredentialPool instances always carry ``provider``;
      +            # old plugins and tests may expose only select()/has_credentials().
      +            return True
      +    pool_provider = str(raw_pool_provider or "").strip().lower()
      +    provider_norm = str(provider or "").strip().lower()
      +    if not pool_provider or not provider_norm:
      +        return False
      +    if pool_provider == provider_norm:
      +        return True
      +    if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX):
      +        return False
      +    try:
      +        matched_pool = get_custom_provider_pool_key(base_url or "")
      +    except Exception:
      +        return False
      +    return str(matched_pool or "").strip().lower() == pool_provider
      +
      +
       DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
       
       
    • hermes_cli/runtime_provider.py
      @@ -11,7 +11,13 @@
       logger = logging.getLogger(__name__)
       
       from hermes_cli import auth as auth_mod
      -from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
      +from agent.credential_pool import (
      +    CredentialPool,
      +    PooledCredential,
      +    credential_pool_matches_provider,
      +    get_custom_provider_pool_key,
      +    load_pool,
      +)
       from agent.secret_scope import get_secret as _get_secret
       from hermes_cli.auth import (
           AuthError,
      @@ -1731,7 +1737,19 @@ def resolve_runtime_provider(
                       if not pool_api_key or not _agent_key_is_usable(nous_state, min_ttl):
                           logger.debug("Nous pool entry agent_key still unavailable, falling through to runtime resolution")
                           pool_api_key = ""
      -        if entry is not None and pool_api_key:
      +        if (
      +            entry is not None
      +            and pool_api_key
      +            and credential_pool_matches_provider(
      +                pool,
      +                provider,
      +                base_url=(
      +                    getattr(entry, "runtime_base_url", None)
      +                    or getattr(entry, "base_url", None)
      +                    or ""
      +                ),
      +            )
      +        ):
                   return _resolve_runtime_from_pool_entry(
                       provider=provider,
                       entry=entry,
    • tests/agent/test_credential_pool_provider_boundary.py
      @@ -0,0 +1,64 @@
      +"""Credential pools must never cross provider or custom-endpoint boundaries."""
      +
      +from types import SimpleNamespace
      +from unittest.mock import patch
      +
      +from agent.credential_pool import credential_pool_matches_provider
      +from hermes_cli import runtime_provider as rp
      +
      +
      +def test_provider_match_requires_exact_non_custom_identity():
      +    assert credential_pool_matches_provider("deepseek", "deepseek")
      +    assert not credential_pool_matches_provider("openai-codex", "deepseek")
      +    assert not credential_pool_matches_provider("", "deepseek")
      +
      +
      +def test_custom_pool_match_is_scoped_by_endpoint():
      +    with patch(
      +        "agent.credential_pool.get_custom_provider_pool_key",
      +        return_value="custom:lab",
      +    ):
      +        assert credential_pool_matches_provider(
      +            "custom:lab", "custom", base_url="https://lab.example/v1"
      +        )
      +        assert not credential_pool_matches_provider(
      +            "custom:other", "custom", base_url="https://lab.example/v1"
      +        )
      +
      +
      +def test_runtime_ignores_pool_loaded_for_different_provider(monkeypatch):
      +    entry = SimpleNamespace(
      +        provider="openai-codex",
      +        access_token="[redacted:secret-assignment]",
      +        runtime_api_key="[redacted:secret-assignment]",
      +        runtime_base_url="https://chatgpt.com/backend-api/codex",
      +        base_url="https://chatgpt.com/backend-api/codex",
      +    )
      +    pool = SimpleNamespace(
      +        provider="openai-codex",
      +        has_credentials=[redacted:secret-assignment] True,
      +        select=lambda: entry,
      +    )
      +    monkeypatch.setattr(rp, "load_pool", lambda _provider: pool)
      +    monkeypatch.setattr(rp, "resolve_provider", lambda *_a, **_kw: "deepseek")
      +    monkeypatch.setattr(
      +        rp,
      +        "_get_model_config",
      +        lambda: {"provider": "deepseek", "default": "deepseek-chat"},
      +    )
      +    monkeypatch.setattr(
      +        rp,
      +        "resolve_api_key_provider_credentials",
      +        lambda _provider: {
      +            "provider": "deepseek",
      +            "api_key": "deepseek-key",
      +            "base_url": "https://api.deepseek.com/v1",
      +            "source": "env",
      +        },
      +    )
      +
      +    resolved = rp.resolve_runtime_provider(requested="deepseek")
      +
      +    assert resolved["provider"] == "deepseek"
      +    assert resolved["api_key"] == "deepseek-key"
      +    assert resolved["base_url"] == "https://api.deepseek.com/v1"
      \ No newline at end of file

14 PRs closed without merging

From the close comments: superseded by another PR 3 · done elsewhere 2 · merged via another change 1 · stale or abandoned 1 · other 7

  • #25733 fix: keep credential pool aligned across fallback runtime switches · done elsewhere → 2e18160, 3fe16e3, #25727
  • #52799 fix: gate credential pools by provider · superseded by another PR
  • #55703 fix(agent): gate credential pools by provider · other → #52799, #52777
  • #56392 fix(agent): keep primary restore on matching credential pool · merged via another change → 820a052, #56903, #56885
  • #56466 fix(agent): gate restore_primary_runtime credential pool by provider · superseded by another PR → #56392, #56374
Show 9 more
  • #56912 fix(agent): skip credential pool re-selection when pool provider mismatches primary · done elsewhere → 820a052, b837f07, #56374, #56392, #56885, #56562
  • #58283 fix(computer_use): add timeout to subprocess.run in permissions grant · superseded by another PR → b837f07, #58253, #56885
  • #58369 fix(gateway): use asyncio.sleep in start_gateway to avoid blocking event loop · other → #56885, #36163
  • #58723 fix(mcp): close shared stderr log file handle at process exit · stale or abandoned → #56885
  • #58756 fix(bluebubbles): replace deprecated datetime.utcnow() with datetime.now(timezone.utc) · other → #56885
  • #58792 fix(main): add timeout to _run_logged_subprocess to prevent hung desktop build · other → #56885
  • #59555 fix(matrix): add return_exceptions=True to asyncio.gather in _dispatch_sync · other → #56885, #57780, #57643, #56345
  • #59654 fix(bluebubbles): replace deprecated datetime.utcnow() with timezone-aware alternative · other
  • #59863 fix(tools): use module-level logger in session_search_tool · other → #56885
Closed issues · 4
  • Closed#25727[Bug]: Fallback from Oauth provider to API key can cause credential pool desync
  • Closed#52777Bug: Credential pool checkout is provider-blind — causes cross-provider 403s
  • Closed#56374restore_primary_runtime() swaps in a cross-provider credential, sending the wrong model name to the wrong endpoint
  • Closed#56885Credential pool desync persists on v0.18.0 — regression of #25727 (closed as implemented-on-main)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.10: 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

Subsystem
agent
Merged PRs
#56903, #63048
Closed issues
4
Attempts
14 PRs closed without merging
Fix link checked
12 Jul 2026

For your agent

The same facts, typed.