From 23afade67bf91c53a8c07d314765e58e4ccb47ad Mon Sep 17 00:00:00 2001 From: McClean-Newton Date: Fri, 4 Sep 2026 11:20:56 -0500 Subject: [PATCH] fix(credential-pool): seed env-source entries not in registry tuple Restores the env-source seeding loop in _seed_from_env that was dropped by upstream refactors. Without this, any env-source entry whose env var name isn't in the provider registry's hardcoded api_key_env_vars tuple stays empty forever, gets filtered out of rotation by _available_entries, and round-robin silently degrades to single-key behavior. Forward-port of ba71e00db07c6263ee8d44b27dfbce2a92e6b39c onto current main f1ccf436a27522c1bb5d36383a6f13b950676338. Narrow insertion after final env_vars list in _seed_from_env: scan only existing entries whose source begins with env:, extract/dedupe the named variable, and let the existing seeding path hydrate it via get_env_prefer_dotenv (preserving _Seeder, secret-scope/profile isolation, borrowed-secret sanitization, and suppression behavior). Tests: 10 new tests in test_credential_pool_seed_existing_env_sources.py covering three-key hydration, round_robin cycling, dotenv/secret-scope resolution without os.environ bypass, duplicate dedupe, manual rows untouched, unset remains unavailable, and sanitized persistence. --- agent/credential_pool.py | 11 + ...edential_pool_seed_existing_env_sources.py | 428 ++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 tests/agent/test_credential_pool_seed_existing_env_sources.py diff --git a/agent/credential_pool.py b/agent/credential_pool.py index f59202cdd6..074731c79d 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -2596,6 +2596,17 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool if provider == "anthropic": env_vars = ["ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] + # Also seed any env-source pool entries already on disk that aren't in + # the registry's tuple. A user can put `source: env:OPENCODE_GO_API_KEY2` + # in auth.json expecting it to be picked up from the environment; the + # registry only declares the primary env var, so this loop fills the + # gap and keeps multi-key rotation working without per-provider code. + for entry in entries: + if entry.source.startswith("env:"): + env_name = entry.source.split(":", 1)[1] + if env_name and env_name not in env_vars: + env_vars.append(env_name) + resolve_base_url = _ENV_BASE_URL_RESOLVERS.get(provider) for env_var in env_vars: token = get_env_prefer_dotenv(env_var) diff --git a/tests/agent/test_credential_pool_seed_existing_env_sources.py b/tests/agent/test_credential_pool_seed_existing_env_sources.py new file mode 100644 index 0000000000..fefbcb72bd --- /dev/null +++ b/tests/agent/test_credential_pool_seed_existing_env_sources.py @@ -0,0 +1,428 @@ +"""Port of ba71e00 env-source hydration onto current main. + +When an existing pool entry on disk has source ``env:VAR`` and VAR resolves +through Hermes normal environment/secret-scope resolver, it must be hydrated +exactly as the registry's single declared env source is. This keeps +multi-key round_robin working for OpenCode Go-style pools without +per-provider registry code. + +See task t_c46cb1ec — narrow forward-port of historical fix +ba71e00db07c6263ee8d44b27dfbce2a92e6b39c (source d83e6fe3e4). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + + +# Synthetic values — never real secrets, never key-shaped literals that trip scanners. +SYN_PRIMARY = "syn-primary-" + "a" * 24 +SYN_SECONDARY = "syn-secondary-" + "b" * 24 +SYN_TERTIARY = "syn-tertiary-" + "c" * 24 +SYN_MANUAL = "syn-manual-" + "d" * 24 +SYN_EXTRA = "syn-extra-" + "e" * 24 + + +def _write_env_file(home: Path, **env_vars): + """Write a .env file under HERMES_HOME and invalidate the memo.""" + home.mkdir(parents=True, exist_ok=True) + lines = [f"{k}={v}" for k, v in env_vars.items()] + (home / ".env").write_text("\n".join(lines) + "\n", encoding="utf-8") + from hermes_cli.config import invalidate_env_cache + invalidate_env_cache() + + +def _make_pconfig(provider_id: str, env_vars: list[str]): + """Minimal ProviderConfig for testing.""" + from hermes_cli.auth import ProviderConfig + return ProviderConfig( + id=provider_id, + name=provider_id.title(), + auth_type="api_key", + api_key_env_vars=tuple(env_vars), + ) + + +def _write_auth(home: Path, pool: dict): + """Write auth.json credential_pool payload.""" + home.mkdir(parents=True, exist_ok=True) + (home / "auth.json").write_text(json.dumps({"credential_pool": pool}), encoding="utf-8") + + +def _read_auth(home: Path) -> dict: + return json.loads((home / "auth.json").read_text(encoding="utf-8")) + + +@pytest.fixture +def isolated_hermes_home(tmp_path, monkeypatch): + """Fresh HERMES_HOME with .env cache cleared and credential envs blanked.""" + home = tmp_path / ".hermes" + home.mkdir() + # Override the auto hermetic home — our fixture owns HERMES_HOME for these tests + monkeypatch.setenv("HERMES_HOME", str(home)) + from hermes_cli.config import invalidate_env_cache + invalidate_env_cache() + # Ensure the credential-shaped envs from conftest are blank (they are by default) + for key in [ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", + "ZAI_API_KEY", "DEEPSEEK_API_KEY", "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", "OPENCODE_GO_API_KEY", + "OPENCODE_GO_API_KEY2", "OPENCODE_GO_API_KEY3", "OPENCODE_GO_API_KEY4", + "OPENAI_BASE_URL", + ]: + monkeypatch.delenv(key, raising=False) + # Guarantee no stray secret scope from prior test + try: + from agent.secret_scope import set_secret_scope, set_multiplex_active + # Clear scope, disable multiplex so get_secret fallback is predictable + set_multiplex_active(False) + # ensure no scope installed + from agent.secret_scope import _SECRET_SCOPE + if _SECRET_SCOPE.get() is not None: + tok = set_secret_scope(None) + # reset immediately — leave clean + from agent.secret_scope import reset_secret_scope + reset_secret_scope(tok) + except Exception: + pass + return home + + +class TestSeedFromEnvRespectsExistingPoolEntries: + """Regression: env-source entries already in auth.json must be seeded.""" + + def test_second_env_source_entry_seeded_from_env(self, isolated_hermes_home): + """An env:OPENCODE_GO_API_KEY2 entry already in the pool gets + populated when the env var is set, even though the registry only + declares the primary OPENCODE_GO_API_KEY.""" + from agent.credential_pool import PooledCredential, _seed_from_env + + _write_env_file( + isolated_hermes_home, + OPENCODE_GO_API_KEY=SYN_PRIMARY, + OPENCODE_GO_API_KEY2=SYN_SECONDARY, + ) + + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + + secondary = PooledCredential( + provider="opencode-go", + id="sec1234", + label="secondary", + auth_type="api_key", + priority=1, + source="env:OPENCODE_GO_API_KEY2", + access_token="", + ) + entries = [secondary] + + with patch( + "agent.credential_pool.PROVIDER_REGISTRY", + {"opencode-go": pconfig}, + ): + changed, active_sources = _seed_from_env("opencode-go", entries) + + assert changed is True + assert "env:OPENCODE_GO_API_KEY" in active_sources + assert "env:OPENCODE_GO_API_KEY2" in active_sources + + populated = next((e for e in entries if e.source == "env:OPENCODE_GO_API_KEY2"), None) + assert populated is not None, "secondary entry was dropped from pool" + assert populated.access_token == SYN_SECONDARY + + def test_three_env_source_rows_hydrate_when_only_primary_in_registry(self, isolated_hermes_home): + """All three OpenCode Go-style env:VAR rows hydrate when only the + primary variable is in the registry tuple.""" + from agent.credential_pool import PooledCredential, _seed_from_env + + _write_env_file( + isolated_hermes_home, + OPENCODE_GO_API_KEY=SYN_PRIMARY, + OPENCODE_GO_API_KEY2=SYN_SECONDARY, + OPENCODE_GO_API_KEY3=SYN_TERTIARY, + ) + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + + # Three pre-existing rows on disk, all empty before seeding + e1 = PooledCredential(provider="opencode-go", id="pri1", label="primary", auth_type="api_key", priority=0, source="env:OPENCODE_GO_API_KEY", access_token="") + e2 = PooledCredential(provider="opencode-go", id="sec2", label="secondary", auth_type="api_key", priority=1, source="env:OPENCODE_GO_API_KEY2", access_token="") + e3 = PooledCredential(provider="opencode-go", id="ter3", label="tertiary", auth_type="api_key", priority=2, source="env:OPENCODE_GO_API_KEY3", access_token="") + entries = [e1, e2, e3] + + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed, active_sources = _seed_from_env("opencode-go", entries) + + assert changed is True + assert "env:OPENCODE_GO_API_KEY" in active_sources + assert "env:OPENCODE_GO_API_KEY2" in active_sources + assert "env:OPENCODE_GO_API_KEY3" in active_sources + for src, expected in [("env:OPENCODE_GO_API_KEY", SYN_PRIMARY), ("env:OPENCODE_GO_API_KEY2", SYN_SECONDARY), ("env:OPENCODE_GO_API_KEY3", SYN_TERTIARY)]: + ent = next((e for e in entries if e.source == src), None) + assert ent is not None, f"missing {src}" + assert ent.access_token == expected + assert ent.runtime_api_key == expected + + def test_round_robin_cycles_across_hydrated_rows(self, tmp_path, monkeypatch): + """All hydrated rows are available and round_robin cycles across them.""" + # This test uses load_pool integration + explicit strategy mock, + # so it exercises the full persist/sanitize/selection pipeline. + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + from hermes_cli.config import invalidate_env_cache + invalidate_env_cache() + + # Write .env with three synthetic keys + _write_env_file(home, OPENCODE_GO_API_KEY=SYN_PRIMARY, OPENCODE_GO_API_KEY2=SYN_SECONDARY, OPENCODE_GO_API_KEY3=SYN_TERTIARY) + + # Pre-create auth.json with three empty env-source rows on disk + _write_auth(home, { + "opencode-go": [ + {"id": "pri1", "label": "primary", "auth_type": "api_key", "priority": 0, "source": "env:OPENCODE_GO_API_KEY", "access_token": ""}, + {"id": "sec2", "label": "secondary", "auth_type": "api_key", "priority": 1, "source": "env:OPENCODE_GO_API_KEY2", "access_token": ""}, + {"id": "ter3", "label": "tertiary", "auth_type": "api_key", "priority": 2, "source": "env:OPENCODE_GO_API_KEY3", "access_token": ""}, + ] + }) + + # Force round_robin for opencode-go (simpler than config.yaml file) + monkeypatch.setattr("agent.credential_pool.get_pool_strategy", lambda provider: "round_robin" if provider == "opencode-go" else "fill_first") + + # Use real provider registry for opencode-go (single var) — do not mock, + # so we prove the fix bridges the gap between registry tuple and disk rows. + from agent.credential_pool import load_pool + pool = load_pool("opencode-go") + entries = pool.entries() + # All three should be available (runtime_api_key non-empty) + available = [e for e in entries if e.runtime_api_key] + assert len(available) == 3, f"expected 3 available, got {[e.source for e in available]!r}" + sources = {e.source for e in available} + assert sources == {"env:OPENCODE_GO_API_KEY", "env:OPENCODE_GO_API_KEY2", "env:OPENCODE_GO_API_KEY3"} + tokens = {e.runtime_api_key for e in available} + assert SYN_PRIMARY in tokens and SYN_SECONDARY in tokens and SYN_TERTIARY in tokens + + # Selection must cycle — with 3 available and round_robin, 6 selects + # should return each key at least once and not collapse to a single key. + seen_ids = [] + seen_tokens = set() + for _ in range(6): + ent = pool.select() + assert ent is not None, "select returned None with available entries" + seen_ids.append(ent.id) + seen_tokens.add(ent.runtime_api_key) + # All three keys must have been selected at least once over 6 rounds + assert SYN_PRIMARY in seen_tokens + assert SYN_SECONDARY in seen_tokens + assert SYN_TERTIARY in seen_tokens + # And the id sequence must not be constant (not degraded to single-key) + assert len(set(seen_ids)) > 1 + # Round robin with 3 entries cycles; with 6 picks each appears twice in some order. + # At minimum verify we saw at least 3 distinct ids over the window. + assert len(set(seen_ids)) == 3 + + def test_dotenv_and_secret_scope_resolution_used(self, tmp_path, monkeypatch): + """Normal .env and active secret-scope resolution is used; no os.environ bypass.""" + from agent.credential_pool import PooledCredential, _seed_from_env + from agent.secret_scope import set_secret_scope, set_multiplex_active, get_secret + from hermes_cli.config import invalidate_env_cache + + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + invalidate_env_cache() + + # Part A: .env file path works (no monkeypatched env needed) + _write_env_file(home, OPENCODE_GO_API_KEY2=SYN_SECONDARY) + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + e = PooledCredential(provider="opencode-go", id="sec2", label="secondary", auth_type="api_key", priority=1, source="env:OPENCODE_GO_API_KEY2", access_token="") + entries = [e] + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed, _ = _seed_from_env("opencode-go", entries) + assert changed is True + assert entries[0].access_token == SYN_SECONDARY + + # Part B: secret-scope path — when multiplexing is active, the value + # comes from the installed scope, not from .env/os.environ. + # Start clean: remove .env, clear os.environ var + (home / ".env").write_text("", encoding="utf-8") + invalidate_env_cache() + monkeypatch.delenv("OPENCODE_GO_API_KEY2", raising=False) + # Ensure .env does not contain the key, and os.environ does not either + assert get_secret("OPENCODE_GO_API_KEY2", "") in ("", None) # no scope yet, no env -> empty (multiplex inactive atm) + # Activate multiplex and install a scope that DOES contain the key + set_multiplex_active(True) + scope = {"OPENCODE_GO_API_KEY2": SYN_SECONDARY, "OPENCODE_GO_API_KEY": SYN_PRIMARY} + tok = set_secret_scope(scope) + try: + # Now get_env_prefer_dotenv should resolve via scope + from agent.credential_pool import get_env_prefer_dotenv + assert get_env_prefer_dotenv("OPENCODE_GO_API_KEY2") == SYN_SECONDARY + # Seed again with fresh entry (existing token empty) + e2 = PooledCredential(provider="opencode-go", id="sec2b", label="secondary", auth_type="api_key", priority=1, source="env:OPENCODE_GO_API_KEY2", access_token="") + entries2 = [e2] + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed2, _ = _seed_from_env("opencode-go", entries2) + assert changed2 is True + assert entries2[0].access_token == SYN_SECONDARY + finally: + from agent.secret_scope import reset_secret_scope + reset_secret_scope(tok) + set_multiplex_active(False) + invalidate_env_cache() + + # Part C: no direct os.environ bypass — when multiplexing is active, + # a value that lives ONLY in os.environ (not in scope/.env) must NOT be used. + # This proves we route through get_secret, not raw os.environ.get. + _write_env_file(home, **{}) # empty .env + # Put the secret ONLY in real os.environ (bypass the scope) + os.environ["OPENCODE_GO_API_KEY2"] = SYN_EXTRA + set_multiplex_active(True) + tok2 = set_secret_scope({"OPENCODE_GO_API_KEY": SYN_PRIMARY}) # scope lacks KEY2 + try: + from agent.credential_pool import get_env_prefer_dotenv + # Should NOT see the os.environ value because multiplex-active + scope miss is fail-closed + resolved = get_env_prefer_dotenv("OPENCODE_GO_API_KEY2") + assert resolved == "" or resolved is None or resolved == "", f"expected empty, got {resolved!r} — direct os.environ bypass!" + e3 = PooledCredential(provider="opencode-go", id="sec3", label="secondary", auth_type="api_key", priority=1, source="env:OPENCODE_GO_API_KEY2", access_token="") + entries3 = [e3] + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed3, active3 = _seed_from_env("opencode-go", entries3) + # Secondary must remain empty — proves no direct os.environ bypass. + # Primary may be seeded (it is in scope) even when secondary is not. + assert "env:OPENCODE_GO_API_KEY2" not in active3, f"secondary should not be in active_sources, got {active3!r}" + # Find secondary entry — it should still be empty + sec_entry = next((e for e in entries3 if e.source == "env:OPENCODE_GO_API_KEY2"), None) + assert sec_entry is not None + assert sec_entry.access_token == "", f"secondary leaked via os.environ bypass: {sec_entry.access_token!r}" + finally: + reset_secret_scope(tok2) + set_multiplex_active(False) + os.environ.pop("OPENCODE_GO_API_KEY2", None) + invalidate_env_cache() + + def test_duplicate_registry_source_rows_do_not_multiply(self, isolated_hermes_home): + """If the registry declares a VAR and the pool also has it, no duplicate is created.""" + from agent.credential_pool import PooledCredential, _seed_from_env + _write_env_file(isolated_hermes_home, OPENCODE_GO_API_KEY=SYN_PRIMARY, OPENCODE_GO_API_KEY2=SYN_SECONDARY) + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + existing = PooledCredential(provider="opencode-go", id="prim1234", label="primary", auth_type="api_key", priority=0, source="env:OPENCODE_GO_API_KEY", access_token="") + e2 = PooledCredential(provider="opencode-go", id="sec1234", label="secondary", auth_type="api_key", priority=1, source="env:OPENCODE_GO_API_KEY2", access_token="") + # Also add a duplicate of the secondary source (simulating a corrupted pool with two same-source rows) + dup = PooledCredential(provider="opencode-go", id="dup999", label="duplicate-secondary", auth_type="api_key", priority=2, source="env:OPENCODE_GO_API_KEY2", access_token="") + entries = [existing, e2, dup] + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed, active_sources = _seed_from_env("opencode-go", entries) + assert changed is True + assert "env:OPENCODE_GO_API_KEY" in active_sources + assert "env:OPENCODE_GO_API_KEY2" in active_sources + # No extra env var entries — env:OPENCODE_GO_API_KEY2 appears once in active_sources + # and the pool should have been de-duplicated to one entry per source by _upsert logic. + # Count entries per source after seeding + deduped_sources = [e.source for e in entries] + # _upsert_entry de-duplicates same-source rows, keeping first; second duplicate should have been removed on first upsert that touched that source + # So we expect at most 2 entries (primary + secondary), not 3 + assert deduped_sources.count("env:OPENCODE_GO_API_KEY2") == 1, f"duplicate source not deduped: {deduped_sources!r}" + assert deduped_sources.count("env:OPENCODE_GO_API_KEY") == 1 + + def test_manual_source_entry_not_touched_by_seed(self, isolated_hermes_home): + """Manual entries in auth.json must not be re-seeded from env vars.""" + from agent.credential_pool import PooledCredential, _seed_from_env + _write_env_file(isolated_hermes_home, OPENCODE_GO_API_KEY=SYN_PRIMARY) + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + manual = PooledCredential(provider="opencode-go", id="man1234", label="manual", auth_type="api_key", priority=0, source="manual", access_token=SYN_MANUAL) + entries = [manual] + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + _seed_from_env("opencode-go", entries) + populated = next((e for e in entries if e.source == "manual"), None) + assert populated is not None + assert populated.access_token == SYN_MANUAL + + def test_env_source_entry_with_no_env_value_unchanged(self, isolated_hermes_home): + """An env-source entry whose env var is unset stays empty and unavailable.""" + from agent.credential_pool import PooledCredential, _seed_from_env + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + secondary = PooledCredential(provider="opencode-go", id="sec1234", label="secondary", auth_type="api_key", priority=1, source="env:OPENCODE_GO_API_KEY2", access_token="") + entries = [secondary] + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed, _ = _seed_from_env("opencode-go", entries) + assert changed is False + populated = next((e for e in entries if e.source == "env:OPENCODE_GO_API_KEY2"), None) + assert populated is not None + assert populated.access_token == "" + # Also verify it is considered unavailable (filtered by _available_entries) + from agent.credential_pool import CredentialPool + pool = CredentialPool(provider="opencode-go", entries=entries) + avail, _ = pool._available_entries() + assert len(avail) == 0, "empty env entry should be filtered as unavailable" + + def test_raw_secrets_not_written_to_auth_json(self, tmp_path, monkeypatch): + """Raw synthetic secrets remain runtime-only; auth.json holds only fingerprint metadata.""" + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + from hermes_cli.config import invalidate_env_cache + invalidate_env_cache() + _write_env_file(home, OPENCODE_GO_API_KEY=SYN_PRIMARY, OPENCODE_GO_API_KEY2=SYN_SECONDARY, OPENCODE_GO_API_KEY3=SYN_TERTIARY) + _write_auth(home, { + "opencode-go": [ + {"id": "pri1", "label": "primary", "auth_type": "api_key", "priority": 0, "source": "env:OPENCODE_GO_API_KEY", "access_token": ""}, + {"id": "sec2", "label": "secondary", "auth_type": "api_key", "priority": 1, "source": "env:OPENCODE_GO_API_KEY2", "access_token": ""}, + {"id": "ter3", "label": "tertiary", "auth_type": "api_key", "priority": 2, "source": "env:OPENCODE_GO_API_KEY3", "access_token": ""}, + ] + }) + # Also test dedup + manual mix in same file — manual should keep its token on disk (it is not borrowed) + # Add a manual provider entry in same file to ensure isolation + from agent.credential_pool import load_pool + # Force strategy so load doesn't change selection expectations; not needed for persist test but harmless + monkeypatch.setattr("agent.credential_pool.get_pool_strategy", lambda p: "round_robin" if p == "opencode-go" else "fill_first") + pool = load_pool("opencode-go") + # In-memory must have hydrated secrets + for ent in pool.entries(): + if ent.source.startswith("env:"): + assert ent.runtime_api_key in (SYN_PRIMARY, SYN_SECONDARY, SYN_TERTIARY) + # On-disk must NOT contain raw synthetic secrets + raw = (home / "auth.json").read_text(encoding="utf-8") + assert SYN_PRIMARY not in raw, "raw primary secret leaked to auth.json" + assert SYN_SECONDARY not in raw, "raw secondary secret leaked to auth.json" + assert SYN_TERTIARY not in raw, "raw tertiary secret leaked to auth.json" + # Fingerprints must be present instead, and source refs preserved + data = _read_auth(home) + entries = data.get("credential_pool", {}).get("opencode-go", []) + assert len(entries) == 3 + for ent in entries: + assert ent.get("source", "").startswith("env:"), f"expected env source, got {ent!r}" + # Borrowed rows are sanitized: access_token removed, fingerprint kept + assert "access_token" not in ent or ent.get("access_token") == "", f"raw access_token persisted: {ent!r}" + assert ent.get("secret_fingerprint", "").startswith("sha256:"), f"missing fingerprint: {ent!r}" + assert ent.get("secret_source") is None or isinstance(ent.get("secret_source"), str) + + def test_no_pool_entries_unchanged_behavior(self, isolated_hermes_home): + """If auth.json has no env-source entries, behavior matches pre-fix (only registry tuple).""" + from agent.credential_pool import _seed_from_env + _write_env_file(isolated_hermes_home, OPENCODE_GO_API_KEY=SYN_PRIMARY) + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed, active_sources = _seed_from_env("opencode-go", []) + assert changed is True + assert "env:OPENCODE_GO_API_KEY" in active_sources + assert "env:OPENCODE_GO_API_KEY2" not in active_sources + + def test_env_var_name_extraction_handles_empty_suffix(self, isolated_hermes_home): + """A malformed source 'env:' with empty var name must not be treated as env var.""" + from agent.credential_pool import PooledCredential, _seed_from_env + _write_env_file(isolated_hermes_home, OPENCODE_GO_API_KEY=SYN_PRIMARY) + pconfig = _make_pconfig("opencode-go", ["OPENCODE_GO_API_KEY"]) + malformed = PooledCredential(provider="opencode-go", id="bad1", label="bad", auth_type="api_key", priority=0, source="env:", access_token="") + entries = [malformed] + with patch("agent.credential_pool.PROVIDER_REGISTRY", {"opencode-go": pconfig}): + changed, active_sources = _seed_from_env("opencode-go", entries) + # Should still seed the registry var, but not crash or add empty env var + assert "env:OPENCODE_GO_API_KEY" in active_sources + assert "" not in active_sources + assert "env:" not in active_sources + # Malformed entry stays empty + assert entries[0].access_token == ""