mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Restoring the primary after a fallback rebuilt credential ownership from live config.yaml, not from the config the session resolved on. Two edits made mid-session moved it onto another account: - alpha and beta share one URL and the session runs on custom:beta. Once beta's live endpoint moves, the named lookup misses, load_runtime_pool falls back to URL-only matching, and the session resumes on alpha's `hermes auth add` key. - the session runs on custom:deepseek with a declared key_env. Once the declaration is removed from live config, the entry looks credential-less, so the built-in's env row is no longer foreign and restore swaps to DEEPSEEK_API_KEY and api.deepseek.com. Both `_primary_runtime` builders (agent construction and a model switch) now record the owning entry's provenance (`custom_entry`), matched the way resolution matched it, in the frozen scope when one is active. The record holds the entry's name, provider_key, base_url, key_env names and whether it declares a credential; it never holds a secret. Restore takes the pool key and the ownership filter from that record, so live edits can't reassign the endpoint or drop the declaration. A named identity whose snapshot recorded no owner, including a snapshot from an older builder, keeps its snapshot credential instead of borrowing by URL. A genuinely bare `custom` snapshot without a recorded owner still matches by URL, as before. The two builders move unchanged into agent/primary_runtime_snapshot.py, so agent_init.py and agent_runtime_helpers.py, both over the file-size target, shrink instead of growing. test_primary_runtime_restore's named-pool test now builds its agent under the config it restores with, as a real session does; built without that config, its snapshot correctly records no owner. Refs #140297
136 lines
7.9 KiB
Python
136 lines
7.9 KiB
Python
"""Credential ownership for ``providers:`` entries whose pool slug is also a built-in's pool id.
|
|
|
|
``providers.deepseek`` pools under the slug ``deepseek``, which ``load_pool`` also seeds from the
|
|
built-in DeepSeek provider's env vars and auth files. Those rows are the built-in's accounts. Every
|
|
acquisition of a pool for the custom entry's runtime (main, frozen and auxiliary resolution, a
|
|
URL-matched bare ``custom`` route, restoring the primary after a fallback, a delegated child's pool)
|
|
takes the entry-scoped view from here, so the built-in's account never stands in for the entry's
|
|
declared credential. The built-in provider itself still loads, and persists, the whole pool.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable, Optional
|
|
|
|
_DECLARED_CREDENTIAL_KEYS = ("api_key", "key_env", "api_key_env", "key_cmd")
|
|
# The ``_primary_runtime`` field holding the owning entry's provenance (``runtime_entry_owner``).
|
|
SNAPSHOT_OWNER_KEY = "custom_entry"
|
|
|
|
|
|
def entry_declares_credential(entry: Any) -> bool:
|
|
"""Whether a custom entry names its own credential (``api_key`` / ``key_env`` / ``key_cmd``). A
|
|
snapshot's provenance carries the verdict itself, since it never copies an inline secret."""
|
|
if isinstance(entry, dict) and "declares_credential" in entry:
|
|
return bool(entry["declares_credential"])
|
|
return isinstance(entry, dict) and any(str(entry.get(k) or "").strip() for k in _DECLARED_CREDENTIAL_KEYS)
|
|
|
|
|
|
def row_owned_by_entry(row: Any, entry: Any) -> bool:
|
|
"""Ownership by provenance: ``hermes auth add`` (manual) rows, and rows seeded from the entry's
|
|
own ``key_env``. Rows the built-in seeded from its registry env vars or auth files are foreign."""
|
|
from agent.credential_pool import _is_manual_source
|
|
source = str(getattr(row, "source", "") or "").strip()
|
|
own_env = {f"env:{name}" for name in (str(entry.get(k) or "").strip() for k in ("key_env", "api_key_env")) if name}
|
|
return _is_manual_source(source) or source in own_env
|
|
|
|
|
|
def entry_scoped_pool(pool: Any, pool_key: str, entry: Optional[dict[str, Any]]) -> Any:
|
|
"""The rows of ``pool`` the custom ``entry`` owns: the pool itself when it owns them all, else a
|
|
view of them, or None when it owns none. ``custom:<name>`` pools are seeded from the entry's own
|
|
config and stay whole; so does any pool for an entry that declares no credential (as before).
|
|
|
|
The view shares the loaded rows and token baselines, so persisting it merges into the stored
|
|
pool under its lock and leaves the built-in's omitted rows on disk.
|
|
"""
|
|
from agent.credential_pool import CUSTOM_POOL_PREFIX, CredentialPool
|
|
if pool is None or str(pool_key or "").startswith(CUSTOM_POOL_PREFIX) or not entry_declares_credential(entry):
|
|
return pool
|
|
rows = pool.entries()
|
|
owned = [row for row in rows if row_owned_by_entry(row, entry)]
|
|
if len(owned) == len(rows):
|
|
return pool
|
|
if not owned:
|
|
return None
|
|
view = CredentialPool(pool.provider, owned)
|
|
view._persisted_token_pairs = pool._persisted_token_pairs
|
|
return view
|
|
|
|
|
|
def custom_entry_for_endpoint(
|
|
base_url: Optional[str], provider_name: Optional[str] = None, config: Optional[dict] = None,
|
|
) -> Optional[tuple[str, dict[str, Any]]]:
|
|
"""``(normalized_name, entry)`` of the configured custom entry that owns ``base_url``: by name
|
|
first (a named entry's pool never serves another URL), else by URL. ``config`` is a frozen
|
|
route's config, whose entry owns the endpoint even after a live edit."""
|
|
from agent import credential_pool as cp
|
|
if not base_url:
|
|
return None
|
|
url = cp._norm_url(base_url)
|
|
if (named := named_custom_entry(provider_name, config)) is not None:
|
|
entry_url = cp._norm_url(named[1].get("base_url"))
|
|
return None if entry_url and entry_url != url else named
|
|
return next(((n, e) for n, e in cp._iter_custom_providers(config) if url and cp._norm_url(e.get("base_url")) == url), None)
|
|
|
|
|
|
def named_custom_entry(provider_name: Optional[str], config: Optional[dict] = None) -> Optional[tuple[str, dict[str, Any]]]:
|
|
"""``(normalized_name, entry)`` of the configured custom entry ``provider_name`` names, else None."""
|
|
from agent import credential_pool as cp
|
|
aliases = cp._requested_custom_name_aliases(provider_name) if provider_name else set()
|
|
return next(((n, e) for n, e in cp._iter_custom_providers(config) if aliases & cp._custom_entry_name_aliases(n, e)), None) if aliases else None
|
|
|
|
|
|
def pool_for_aux_provider(provider: Optional[str], pool_key: str, load: Callable[[str], Any]) -> Any:
|
|
"""``load(pool_key)`` for an auxiliary pool read (the key peek, credential recovery). A pinned
|
|
``custom:<name>`` normalizes to its entry's slug, which may also be a same-named built-in's pool,
|
|
so it gets the entry-scoped view; a bare built-in name keeps its whole pool."""
|
|
pool = load(pool_key)
|
|
if pool is None or not str(provider or "").strip().lower().startswith("custom:"):
|
|
return pool
|
|
match = named_custom_entry(provider)
|
|
return entry_scoped_pool(pool, pool_key, match[1] if match else None)
|
|
|
|
|
|
def _custom_identity(provider: Optional[str], requested_provider: Optional[str]) -> tuple[bool, Optional[str]]:
|
|
"""``(is_custom, custom:<name> or None)`` for a runtime's provider / requested_provider pair."""
|
|
from agent.credential_pool import CUSTOM_POOL_PREFIX
|
|
identities = [str(v or "").strip().lower() for v in (provider, requested_provider)]
|
|
custom = any(v == "custom" or v.startswith(CUSTOM_POOL_PREFIX) for v in identities)
|
|
return custom, next((v for v in identities if v.startswith(CUSTOM_POOL_PREFIX)), None)
|
|
|
|
|
|
def runtime_entry_owner(provider: Optional[str], requested_provider: Optional[str], base_url: Optional[str]) -> Optional[dict[str, Any]]:
|
|
"""Credential provenance of the entry that owns a custom runtime, for its ``_primary_runtime``
|
|
snapshot: matched in the config the route runs on (the frozen scope, else the profile's), the way
|
|
resolution matched it. Names only, never a secret. None for a built-in or an unowned endpoint."""
|
|
custom, name = _custom_identity(provider, requested_provider)
|
|
if not custom or (match := custom_entry_for_endpoint(base_url, name)) is None:
|
|
return None
|
|
norm_name, entry = match
|
|
provenance = {k: entry.get(k) for k in ("provider_key", "base_url", "key_env", "api_key_env")}
|
|
return {"name": norm_name, **provenance, "declares_credential": entry_declares_credential(entry)}
|
|
|
|
|
|
def load_runtime_pool(
|
|
provider: Optional[str], base_url: Optional[str], requested_provider: Optional[str] = None,
|
|
snapshot: Optional[dict[str, Any]] = None,
|
|
) -> Any:
|
|
"""The pool for a runtime snapshot's identity. A custom runtime (``custom`` or ``custom:<name>``)
|
|
is the entry's, so it gets the entry-scoped view; a built-in identity loads its whole pool.
|
|
Ownership and the pool key come from the runtime identity, never from the pool slug, which both
|
|
share: a ``custom:<name>`` identity keys on its own entry, not the first entry on its URL.
|
|
|
|
``snapshot`` is the ``_primary_runtime`` being restored: the owner it recorded wins over live
|
|
config, so an edit can't reassign the endpoint or drop the entry's declared credential. A named
|
|
identity whose snapshot recorded no owner gets None and keeps the snapshot's own credential."""
|
|
from agent.credential_pool import _pool_keys_for_custom_entry, load_pool, resolve_runtime_pool_key
|
|
custom, name = _custom_identity(provider, requested_provider)
|
|
if not custom:
|
|
key = resolve_runtime_pool_key(provider, base_url)
|
|
return load_pool(key) if key else None
|
|
owner = (snapshot or {}).get(SNAPSHOT_OWNER_KEY)
|
|
if snapshot is not None and name and not owner:
|
|
return None
|
|
match = (owner["name"], owner) if owner else custom_entry_for_endpoint(base_url, name)
|
|
key = (_pool_keys_for_custom_entry(*match) or [""])[0] if match and (name or owner) else resolve_runtime_pool_key(provider, base_url)
|
|
return entry_scoped_pool(load_pool(key), key, match[1] if match else None) if key else None
|