diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 28f9089489..6b36bf4554 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1416,6 +1416,11 @@ class CredentialPool: entries_to_prune: List[str] = [] available: List[PooledCredential] = [] for entry in self._entries: + # Borrowed credentials persist as metadata-only references and are + # hydrated from their live source on load. A stale duplicate row + # can remain unhydrated; never lease or select it as an empty key. + if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key: + continue # For anthropic claude_code entries, sync from the credentials file # before any status/refresh checks. This picks up tokens refreshed # by other processes (Claude Code CLI, other Hermes profiles). @@ -1732,11 +1737,15 @@ class CredentialPool: def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool: - existing_idx = None + matching_indices = [] for idx, entry in enumerate(entries): if entry.source == source: - existing_idx = idx - break + matching_indices.append(idx) + + existing_idx = matching_indices[0] if matching_indices else None + duplicate_indices = set(matching_indices[1:]) + if duplicate_indices: + entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices] if existing_idx is None: payload.setdefault("id", uuid.uuid4().hex[:6]) @@ -1768,8 +1777,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p # Runtime-only borrowed secret updates should refresh the in-memory # entry without forcing auth.json churn when the disk-safe payload is # unchanged (for example env keys with the same fingerprint). - return existing.to_dict() != updated.to_dict() - return False + return bool(duplicate_indices) or existing.to_dict() != updated.to_dict() + return bool(duplicate_indices) def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool: diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index ae28580d85..68a923355e 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -831,6 +831,70 @@ def test_load_pool_does_not_persist_env_seeded_secret_value(tmp_path, monkeypatc assert persisted["secret_fingerprint"].startswith("sha256:") +def test_load_pool_collapses_duplicate_env_rows_to_active_key(tmp_path, monkeypatch): + """One env source is one credential, even if auth.json contains stale duplicates.""" + key = "sk-or-active-main-key" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("OPENROUTER_API_KEY", key) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openrouter": [ + { + "id": "current-row", + "label": "OPENROUTER_API_KEY", + "auth_type": "api_key", + "priority": 0, + "source": "env:OPENROUTER_API_KEY", + }, + { + "id": "stale-duplicate", + "label": "OPENROUTER_API_KEY", + "auth_type": "api_key", + "priority": 1, + "source": "env:OPENROUTER_API_KEY", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("openrouter") + + assert [(entry.id, entry.runtime_api_key) for entry in pool.entries()] == [ + ("current-row", key) + ] + persisted = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + assert [entry["id"] for entry in persisted["credential_pool"]["openrouter"]] == [ + "current-row" + ] + + +def test_credential_pool_never_selects_empty_borrowed_entry(): + from agent.credential_pool import CredentialPool, PooledCredential + + pool = CredentialPool( + "openrouter", + [ + PooledCredential( + provider="openrouter", + id="metadata-only", + label="OPENROUTER_API_KEY", + auth_type="api_key", + priority=0, + source="env:OPENROUTER_API_KEY", + access_token="", + ) + ], + ) + + assert pool.select() is None + assert pool.acquire_lease() is None + def test_load_pool_persists_bitwarden_origin_metadata_without_secret(tmp_path, monkeypatch): """Bitwarden-injected env vars retain source metadata but not raw values."""