From 996f7bc5638c3e79d7d94d40c4f40054a01d0972 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:13:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(credential-pool):=20numbered=20env=20sibli?= =?UTF-8?q?ngs=20(KEY=5F2,=20KEY=5F3,=20=E2=80=A6)=20seed=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting NVIDIA_API_KEY_2 next to NVIDIA_API_KEY is now the whole opt-in for a second pooled key: _seed_from_env tries VAR_2, VAR_3, … for every declared var until the first gap, on the generic registry path and the openrouter branch alike. Secrets stay in the env / secret manager; only the reference row is persisted. Resolves #76593; supersedes the config-key approach of #87835. --- agent/credential_pool.py | 23 +++++++++++------ ...edential_pool_seed_existing_env_sources.py | 25 +++++++++++++++++++ .../user-guide/features/credential-pools.md | 12 ++++++++- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index fcea4c1510..fc678a4334 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -2566,16 +2566,25 @@ _ENV_BASE_URL_RESOLVERS = { } -def _with_on_disk_env_sources(env_vars: List[str], entries: List[PooledCredential]) -> List[str]: - """*env_vars* plus the ``env:VAR`` names already persisted in the pool. +def _env_key_var_candidates(env_vars: List[str], entries: List[PooledCredential]) -> List[str]: + """*env_vars*, their numbered siblings, and the ``env:VAR`` names already persisted. + + ``VAR_2``, ``VAR_3``, ... are tried for every declared VAR until the first + one that does not resolve, so a `.env` or secret-manager project can back a + whole rotation pool with no config: setting ``NVIDIA_API_KEY_2`` is the + whole opt-in (#76593). Env-backed rows are written to auth.json without their secret and re-hydrated on every load; a row whose VAR the registry does not - declare (a second key the user pointed at ``env:PROVIDER_API_KEY_2``) - would otherwise stay empty forever and be silently dropped from - rotation by ``_available_entries``. + declare would otherwise stay empty forever and be silently dropped + from rotation by ``_available_entries``. """ names = list(env_vars) + for base in env_vars: + n = 2 + while get_env_prefer_dotenv(f"{base}_{n}"): + names.append(f"{base}_{n}") + n += 1 for entry in entries: if entry.source.startswith("env:"): env_name = entry.source.split(":", 1)[1].strip() @@ -2594,7 +2603,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool return seed.result if provider == "openrouter": - for env_var in _with_on_disk_env_sources(["OPENROUTER_API_KEY"], entries): + for env_var in _env_key_var_candidates(["OPENROUTER_API_KEY"], entries): token = get_env_prefer_dotenv(env_var) if token and seed.upsert( f"env:{env_var}", @@ -2614,7 +2623,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool env_vars = list(pconfig.api_key_env_vars) if provider == "anthropic": env_vars = ["ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] - env_vars = _with_on_disk_env_sources(env_vars, entries) + env_vars = _env_key_var_candidates(env_vars, entries) resolve_base_url = _ENV_BASE_URL_RESOLVERS.get(provider) for env_var in env_vars: diff --git a/tests/agent/test_credential_pool_seed_existing_env_sources.py b/tests/agent/test_credential_pool_seed_existing_env_sources.py index f8fb0a3644..4631b778bf 100644 --- a/tests/agent/test_credential_pool_seed_existing_env_sources.py +++ b/tests/agent/test_credential_pool_seed_existing_env_sources.py @@ -67,3 +67,28 @@ def test_unset_env_row_stays_out_of_rotation_and_on_disk(home): # load_pool() is a non-destructive read for env rows (#9331): the # reference survives for the process that does have the var. assert "env:DEEPSEEK_API_KEY_2" in {e.source for e in pool._entries} + + +# Numbered siblings need no auth.json row at all: setting the variable is the +# whole opt-in (#76593). Discovery stops at the first gap so a leftover _5 does +# not silently enter rotation. +@pytest.mark.parametrize("provider,primary", [("deepseek", "DEEPSEEK_API_KEY"), ("openrouter", "OPENROUTER_API_KEY")]) +def test_numbered_env_siblings_seed_rotation_without_config(home, provider, primary, monkeypatch): + from agent.credential_pool import load_pool + + for n in (3, 5): + monkeypatch.delenv(f"{primary}_{n}", raising=False) + (home / ".env").write_text( + f"{primary}={SYN_PRIMARY}\n{primary}_2={SYN_SECONDARY}\n{primary}_3=syn-third-{'c' * 24}\n{primary}_5=syn-fifth-{'e' * 24}\n", + encoding="utf-8", + ) + (home / "config.yaml").write_text(f"credential_pool_strategies:\n {provider}: round_robin\n", encoding="utf-8") + from hermes_cli.config import invalidate_env_cache + invalidate_env_cache() + + pool = load_pool(provider) + available, _ = pool._available_entries() + assert {e.source for e in available} == {f"env:{primary}", f"env:{primary}_2", f"env:{primary}_3"} + assert len({pool.select().source for _ in range(6)}) == 3 + rows = json.loads((home / "auth.json").read_text(encoding="utf-8"))["credential_pool"][provider] + assert len(rows) == 3 and all("access_token" not in row for row in rows) diff --git a/website/docs/user-guide/features/credential-pools.md b/website/docs/user-guide/features/credential-pools.md index f8409a2cf3..f44a42572f 100644 --- a/website/docs/user-guide/features/credential-pools.md +++ b/website/docs/user-guide/features/credential-pools.md @@ -213,6 +213,7 @@ Hermes automatically discovers credentials from multiple sources and seeds the p | Source | Example | Auto-seeded? | |--------|---------|-------------| | Environment variables | `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY` | Yes | +| Numbered env siblings | `OPENROUTER_API_KEY_2`, `OPENROUTER_API_KEY_3`, … | Yes (see below) | | OAuth tokens (auth.json) | Codex device code, Nous device code | Yes | | Claude Code credentials | `~/.claude/.credentials.json` | Yes (Anthropic) | | Hermes PKCE OAuth | `~/.hermes/auth.json` | Yes (Anthropic) | @@ -221,6 +222,15 @@ Hermes automatically discovers credentials from multiple sources and seeds the p Auto-seeded entries are updated on each pool load — if you remove an env var, its pool entry is automatically pruned. Manual entries (added via `hermes auth add`) are never auto-pruned. +### Several keys from the environment + +Want more than one key for a provider without storing any of them in `auth.json`? Number them. Next to `NVIDIA_API_KEY` set `NVIDIA_API_KEY_2`, `NVIDIA_API_KEY_3`, … in your shell, `.env`, or secret manager (Bitwarden Secrets, Vault, …) and each becomes its own pool entry on the next load — no command, no config. Discovery stops at the first missing number, so a stray `_5` with no `_4` is ignored. Combine with `credential_pool_strategies` to rotate them: + +```yaml +credential_pool_strategies: + nvidia: round_robin +``` + Borrowed runtime secrets (for example env vars, Bitwarden/Vault/keyring/systemd references, and custom config values) are reference-only at the `auth.json` boundary. Hermes can use the resolved value in memory for the current run, but it persists only metadata such as the source ref, label, status, request counters, and a non-reversible fingerprint. Manual entries and Hermes-owned OAuth/device-code state keep the durable tokens they need to refresh. ## Delegation & Subagent Sharing @@ -287,7 +297,7 @@ Pool state is stored in `~/.hermes/auth.json` under the `credential_pool` key: The OpenRouter entry above was borrowed from an external source, so the raw key is not stored in `auth.json`. The manual Anthropic entry was intentionally added to Hermes' credential store, so its token remains persistable. -An `env:` row is re-hydrated from the environment on every load, and the variable name does not have to be one Hermes declares for the provider: a second row with `"source": "env:OPENROUTER_API_KEY_2"` is filled from `OPENROUTER_API_KEY_2` (shell, `.env`, or your secret manager) and rotates alongside the primary key without the secret ever being written to `auth.json`. +An `env:` row is re-hydrated from the environment on every load, and the variable name does not have to be one Hermes declares for the provider: numbered siblings (`OPENROUTER_API_KEY_2`, see [Auto-Discovery](#auto-discovery)) appear here automatically, and a hand-written row pointing at any other variable is filled the same way, without the secret ever being written to `auth.json`. Strategies are stored in `config.yaml` (not `auth.json`):