mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Review on #141484: when no current custom_providers entry carries the frozen entry's identity, but the entry at the frozen index still points at the same endpoint URL, recovery reads that index again, as main did, and the key fingerprint decides. A rename in place (same index, URL and key) runs again; deletion, a renamed entry moved to another index, and a duplicate-name swap behave as before. Refs #134889 (MP-17)
390 lines
21 KiB
Python
390 lines
21 KiB
Python
"""Borrow frozen config credentials from the existing profile source, not a vault."""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
|
|
from agent.credential_persistence import fingerprint_secret_value
|
|
from hermes_state_runtime import RuntimeStoreError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
PREFIX = 'profile-config-v1:'
|
|
_ABSENT = object()
|
|
|
|
|
|
def _defined(root, path):
|
|
"""The value *root* defines at *path*, else ``_ABSENT`` when the key is simply not there."""
|
|
value = root
|
|
for key in path:
|
|
if isinstance(value, list) and type(key) is int:
|
|
if not 0 <= key < len(value):
|
|
return _ABSENT
|
|
elif not isinstance(value, dict) or key not in value:
|
|
return _ABSENT
|
|
value = value[key]
|
|
return value
|
|
|
|
|
|
def _route_paths(policy):
|
|
"""Secret paths the session's own route authenticates with: its ``model:`` block, a direct alias's
|
|
key (``api_key_from``) and the one ``providers:`` / ``custom_providers:`` entry the runtime's own
|
|
named-provider selection routes its provider through."""
|
|
from hermes_cli.runtime_provider_custom import (custom_provider_config_path_for_url,
|
|
named_custom_provider_config_path)
|
|
config = json.loads(policy.config_json)
|
|
model = config.get('model') if isinstance(config.get('model'), dict) else {}
|
|
paths = {('model', key) for key in model}
|
|
if isinstance(model.get('api_key_from'), list):
|
|
paths.add(tuple(model['api_key_from']))
|
|
provider = str(model.get('provider') or '')
|
|
if provider.strip().lower() == 'custom':
|
|
# A bare custom route authenticates with the entry owning its base_url (the pool's owner).
|
|
selected = custom_provider_config_path_for_url(model.get('base_url'), config)
|
|
else:
|
|
selected = named_custom_provider_config_path(provider, config)
|
|
entry = _defined(config, selected) if selected else _ABSENT
|
|
if isinstance(entry, dict):
|
|
paths |= {selected + (field,) for field in entry}
|
|
return paths
|
|
|
|
|
|
class RecoveredSecrets(dict):
|
|
"""The frozen secrets still valid, plus ``dropped``: the paths whose value changed or was removed
|
|
in config.yaml since the session started (never supplied; the policy disables what used them)."""
|
|
dropped = ()
|
|
session = None
|
|
|
|
|
|
def policy_identity(policy, unhashed=()):
|
|
data = asdict(policy)
|
|
data.pop('credential_ref')
|
|
data.pop('config_secret_ref')
|
|
# Fields added after release are hashed only when set, so an older session's reference still matches.
|
|
if data.get('editor_mcp_json') is None:
|
|
data.pop('editor_mcp_json', None)
|
|
if not data.get('coding_posture'):
|
|
data.pop('coding_posture', None)
|
|
for name in ('client_env_json', 'client_env_rules_json'):
|
|
if data.get(name) is None:
|
|
data.pop(name, None)
|
|
if not data.get('client_env_remote'):
|
|
data.pop('client_env_remote', None)
|
|
if data.get('edit_approval_policy', 'ask') == 'ask':
|
|
data.pop('edit_approval_policy', None)
|
|
for name in unhashed: # _legacy_identities: a field the stamping gateway did not have yet
|
|
data.pop(name, None)
|
|
return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
# Policy fields a release added after frozen config secrets shipped, hashed unconditionally by the
|
|
# gateway that added them. A reference an older gateway stamped never hashed them; at their defaults
|
|
# they carry nothing that gateway could have seen, so the same policy without them is its policy.
|
|
# The commit that added each, to audit this list against the stamping releases:
|
|
_LATER_POLICY_FIELDS = (('skills_prompt', None), # 2ee1cd1ebb02
|
|
('kanban_json', None), # c8f8d9ae6384
|
|
('safe_mode', False), # 53d362e18741
|
|
('ignore_user_config', False)) # 53d362e18741
|
|
|
|
|
|
def _legacy_identities(policy):
|
|
"""Every whole-policy hash a gateway older than ``bound`` could have stamped for *policy*: the
|
|
current hash, plus each subset of the later fields dropped while they are at their defaults."""
|
|
from itertools import combinations
|
|
unset = [name for name, default in _LATER_POLICY_FIELDS if getattr(policy, name) == default]
|
|
return {policy_identity(policy, dropped)
|
|
for size in range(len(unset) + 1) for dropped in combinations(unset, size)}
|
|
|
|
|
|
def _fingerprint(value):
|
|
return fingerprint_secret_value(value if isinstance(value, str) else json.dumps(value, sort_keys=True))
|
|
|
|
|
|
# What a frozen secret authenticates: the endpoint fields of the block that holds it (``model:``, a
|
|
# ``providers:`` entry, an MCP server), plus the endpoint its route RESOLVES to when that lives in another
|
|
# block. A secret is bound to these, never to the rest of the policy. ``api`` is an entry's URL field
|
|
# (runtime_provider_custom._entry_url reads it before ``url``/``base_url``).
|
|
_ENDPOINT_KEYS = ('provider', 'base_url', 'api', 'url', 'command', 'args', 'api_mode')
|
|
_ENTRY_SECTIONS = ('providers', 'custom_providers')
|
|
# An auxiliary route naming no provider of its own (or ``main``/``auto``) goes where the session's
|
|
# ``model:`` route goes, so its key authenticates that endpoint too.
|
|
_AUX_FOLLOWS_MAIN = ('', 'auto', 'main')
|
|
|
|
|
|
def _route_owner(config, path):
|
|
"""The config path of the ROUTE block a secret at *path* rides with, else None. Every route shape a
|
|
consumer reads: ``model:`` and its aliases (``model.aliases.<n>``, ``model_aliases.<n>``), both
|
|
fallback sections in dictionary (one route) and list form, and each ``auxiliary.<task>`` route with
|
|
its ``fallback_chain`` entries. A secret nested deeper in a route (headers, extra_body) rides with it."""
|
|
head, rest = path[0], tuple(path[1:])
|
|
model = config.get('model') if isinstance(config.get('model'), dict) else {}
|
|
if isinstance(model.get('api_key_from'), list) and tuple(model['api_key_from']) == tuple(path):
|
|
# ``/model <alias>`` with a literal key: config() projects the alias entry's key into ``model.api_key``,
|
|
# so it authenticates the session's ``model:`` route, not the alias entry it is stored under.
|
|
return ('model',)
|
|
if head == 'model':
|
|
if rest[:1] == ('aliases',) and len(rest) > 2:
|
|
return ('model', 'aliases', rest[1])
|
|
return ('model',)
|
|
if head == 'model_aliases' and len(rest) > 1:
|
|
return ('model_aliases', rest[0])
|
|
if head in ('fallback_model', 'fallback_providers'):
|
|
if isinstance(config.get(head), list) and rest and type(rest[0]) is int:
|
|
return (head, rest[0])
|
|
return (head,)
|
|
if head == 'auxiliary' and len(rest) > 1:
|
|
task = rest[0]
|
|
if rest[1] == 'fallback_chain' and len(rest) > 3 and type(rest[2]) is int:
|
|
return ('auxiliary', task, 'fallback_chain', rest[2])
|
|
return ('auxiliary', task)
|
|
return None
|
|
|
|
|
|
def _owner_block(config, path):
|
|
"""The config path of the block whose endpoint the secret at *path* authenticates: a route block
|
|
(_route_owner) or a provider entry as a whole, else the secret's own parent block."""
|
|
route = _route_owner(config, path)
|
|
if route is not None:
|
|
return route
|
|
if path[0] in _ENTRY_SECTIONS:
|
|
return tuple(path[:2])
|
|
return tuple(path[:-1])
|
|
|
|
|
|
def _resolved(owner, block, config):
|
|
"""``(provider_identity, url)`` the route *block* at *owner* is actually sent to."""
|
|
from gateway.session_route_identity import resolved_route
|
|
if owner[0] == 'auxiliary':
|
|
provider = str(block.get('provider') or '').strip().lower()
|
|
if provider in _AUX_FOLLOWS_MAIN and not str(block.get('base_url') or '').strip():
|
|
main = config.get('model') if isinstance(config.get('model'), dict) else {}
|
|
return ['main', *resolved_route(main, config)]
|
|
return list(resolved_route(block, config))
|
|
|
|
|
|
# Endpoint fields holding a URL: digested in normalize_route_base_url's form, so a re-spelling of the same
|
|
# endpoint (host case, a trailing slash, the scheme's default port) is not a moved endpoint.
|
|
_URL_KEYS = ('base_url', 'api', 'url')
|
|
|
|
|
|
def _url(value):
|
|
from hermes_cli.route_identity import normalize_route_base_url
|
|
return normalize_route_base_url(str(value or '').strip())
|
|
|
|
|
|
def _binding(config, path):
|
|
"""Digest of the endpoint the secret at *path* authenticates in the frozen *config* (a terminal-scope
|
|
secret, ``path[0] is None``, authenticates no endpoint)."""
|
|
owner = _owner_block(config, path) if path and path[0] is not None else None
|
|
block = _defined(config, owner) if owner else _ABSENT
|
|
endpoint = {k: block[k] for k in _ENDPOINT_KEYS if k in block} if isinstance(block, dict) else {}
|
|
if isinstance(block, dict) and _route_owner(config, path) is not None:
|
|
# A route's key authenticates what it resolves to: a named ``providers:`` entry's URL included.
|
|
endpoint['resolved'] = _resolved(owner, block, config)
|
|
endpoint['resolved'][-1] = _url(endpoint['resolved'][-1])
|
|
elif isinstance(block, dict) and path[0] in _ENTRY_SECTIONS:
|
|
from hermes_cli.runtime_provider_custom import _entry_url
|
|
endpoint['resolved'] = _url(_entry_url(block))
|
|
for key in _URL_KEYS:
|
|
if isinstance(endpoint.get(key), str):
|
|
endpoint[key] = _url(endpoint[key])
|
|
return hashlib.sha256(json.dumps([list(path), endpoint], sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
def config_reference(authority, session_id, policy, secrets):
|
|
home = Path(authority.db.db_path).resolve().parent
|
|
config = json.loads(policy.config_json)
|
|
return PREFIX + json.dumps({'home': str(home), 'profile': authority.profile_id,
|
|
# ``policy`` (the whole-policy hash) is still written for an older gateway reading this reference;
|
|
# this gateway checks ``bound``: each secret against the endpoint it authenticates (CFG-10).
|
|
'session': session_id, 'policy': policy_identity(policy),
|
|
'bound': [[list(path), _binding(config, path)] for path in secrets],
|
|
'entries': [[list(path), _fingerprint(value)] for path, value in secrets.items()]},
|
|
sort_keys=True)
|
|
|
|
|
|
def _field(path):
|
|
return '.'.join(str(part) for part in path if part is not None)
|
|
|
|
|
|
def _check_scope(authority, source, policy, home, skip):
|
|
"""Refuse, by name, a reference from another home or profile, or a secret whose endpoint the policy
|
|
moved without re-binding it. A change to anything else in the policy (model name, reasoning, toolsets,
|
|
an unrelated provider or MCP server, a policy field a later release added) is not a credential change."""
|
|
for name, mine in (('home', str(home)), ('profile', authority.profile_id)):
|
|
if source[name] != mine:
|
|
raise RuntimeStoreError('launch_credentials_unavailable', field=name,
|
|
detail=f'the frozen config credentials belong to another {name}')
|
|
if 'bound' not in source:
|
|
# Stamped before ``bound``: its whole-policy hash is its only endpoint guard, so it must still
|
|
# match -- allowing for the policy fields added since, at their defaults (_legacy_identities).
|
|
# The next re-bind writes ``bound``.
|
|
if source['policy'] not in _legacy_identities(policy):
|
|
raise RuntimeStoreError('launch_credentials_unavailable', field='config_secret_ref',
|
|
detail='stamped by an older gateway for another session policy')
|
|
return
|
|
config = json.loads(policy.config_json)
|
|
for path, digest in source['bound']:
|
|
if path[0] not in skip and not hmac.compare_digest(_binding(config, path), digest):
|
|
raise RuntimeStoreError('launch_credentials_unavailable', field=_field(path),
|
|
detail='its endpoint moved in the session policy without a re-bind')
|
|
|
|
|
|
def secrets_moved(ref, secrets, sections):
|
|
"""Whether *secrets* (the private values of *sections*, as the profile has them now) differ from the ones
|
|
the durable reference *ref* froze there, by fingerprint. Unknown for an in-memory reference: False."""
|
|
if ref is not None and not ref.startswith(PREFIX):
|
|
return False
|
|
entries = json.loads(ref[len(PREFIX):])['entries'] if ref else []
|
|
frozen = {tuple(path): fingerprint for path, fingerprint in entries if path[0] in sections}
|
|
return frozen != {path: _fingerprint(value) for path, value in secrets.items()}
|
|
|
|
|
|
def recover_config_secrets(authority, policy, skip=None):
|
|
"""The frozen config secrets; entries under a *skip* section are neither re-read nor checked. Every
|
|
caller of a following session skips its followed sections: the live profile supplies them, so a key
|
|
rotation there is not a revoked launch credential."""
|
|
if skip is None:
|
|
from gateway.session_policy import FOLLOW_SECTIONS
|
|
skip = FOLLOW_SECTIONS if policy.follows_profile else ()
|
|
ref = policy.config_secret_ref
|
|
if not ref.startswith(PREFIX):
|
|
values = getattr(authority, '_local_config_secrets', {}).get(ref)
|
|
if values is None:
|
|
raise RuntimeStoreError('launch_credentials_unavailable')
|
|
return {path: value for path, value in values.items() if path[0] not in skip}
|
|
try:
|
|
source = json.loads(ref[len(PREFIX):])
|
|
home = Path(authority.db.db_path).resolve().parent
|
|
_check_scope(authority, source, policy, home, skip)
|
|
cached = getattr(authority, '_local_config_secrets', {}).get(ref)
|
|
if cached is not None:
|
|
return {path: value for path, value in cached.items() if path[0] not in skip}
|
|
from gateway.run import _load_gateway_config
|
|
entries = [entry for entry in source['entries'] if entry[0][0] not in skip]
|
|
config = _load_gateway_config(home / 'config.yaml') if any(path[0] is not None for path, _ in entries) else {}
|
|
route = None # computed on the first mismatch only: an unchanged source never needs it
|
|
values, dropped = RecoveredSecrets(), []
|
|
frozen = None # parsed on the first legacy entry only
|
|
for path, fingerprint in entries:
|
|
value, lookup = config, path
|
|
if path[0] == 'custom_providers' and len(path) > 2 and type(path[1]) is int:
|
|
frozen = json.loads(policy.config_json) if frozen is None else frozen
|
|
lookup = _current_custom_path(frozen, config, path)
|
|
if path[0] is None:
|
|
from tools.terminal_scope import build_profile_terminal_scope
|
|
value, lookup = build_profile_terminal_scope(home), path[1:]
|
|
value = _defined(value, lookup)
|
|
actual = None if value is _ABSENT else _fingerprint(value)
|
|
if actual is None or not hmac.compare_digest(actual, fingerprint):
|
|
if route is None:
|
|
route = _route_paths(policy)
|
|
if tuple(path) in route:
|
|
raise RuntimeStoreError('launch_credentials_unavailable', field=_field(path),
|
|
detail=('removed from' if value is _ABSENT else 'changed in')
|
|
+ ' config.yaml since this session started')
|
|
# Any other secret (an MCP env token, an unused provider's key) is simply no longer
|
|
# supplied: the frozen tree holds None there, never the profile's new value.
|
|
logger.warning('Session %s: frozen config secret %s no longer matches the profile '
|
|
'config; skipping it', source.get('session'), path)
|
|
dropped.append(tuple(path))
|
|
continue
|
|
values[tuple(path)] = value
|
|
values.dropped, values.session = tuple(dropped), source.get('session')
|
|
return values
|
|
except RuntimeStoreError:
|
|
raise
|
|
except (KeyError, TypeError, ValueError, AttributeError, OSError) as exc:
|
|
logger.warning('Frozen config reference unreadable: %s', type(exc).__name__)
|
|
raise RuntimeStoreError('launch_credentials_unavailable', field='config_secret_ref',
|
|
detail=f'unreadable ({type(exc).__name__})') from exc
|
|
|
|
|
|
def _current_custom_path(frozen, config, path):
|
|
"""*path* (``custom_providers[i]...``, an index into the frozen list) re-pointed at the profile's
|
|
entry of the SAME canonical identity (``custom_provider_slug``), wherever a reorder moved it.
|
|
An entry the profile no longer declares maps to no index (absent), never to whatever entry now
|
|
sits at ``i``, unless that entry is the same endpoint renamed in place (the fingerprint then decides,
|
|
as on main). A frozen entry with no name has no identity: its index is all there is."""
|
|
from hermes_cli.providers import custom_provider_slug
|
|
|
|
def identity(entry):
|
|
if not isinstance(entry, dict) or not (entry.get('name') or entry.get('provider_key')):
|
|
return None
|
|
return custom_provider_slug(str(entry.get('name') or ''), str(entry.get('provider_key') or ''))
|
|
|
|
held = frozen.get('custom_providers')
|
|
mine = identity(held[path[1]]) if isinstance(held, list) and 0 <= path[1] < len(held) else None
|
|
current = config.get('custom_providers')
|
|
if mine is None or not isinstance(current, list):
|
|
return path
|
|
if 0 <= path[1] < len(current) and identity(current[path[1]]) == mine:
|
|
return path # unmoved (also keeps a duplicate-named later entry on its own index)
|
|
for index, entry in enumerate(current):
|
|
if identity(entry) == mine: # the first match, as the runtime's own name lookup takes
|
|
return (path[0], index, *path[2:])
|
|
from hermes_cli.runtime_provider_custom import _entry_url, _normalize_base_url_for_match as url
|
|
if 0 <= path[1] < len(current) and isinstance(current[path[1]], dict) \
|
|
and url(_entry_url(current[path[1]])) == url(_entry_url(held[path[1]])):
|
|
return path # renamed in place: same endpoint at the same index, as main read it (the fingerprint decides)
|
|
return (path[0], len(current), *path[2:]) # gone: past the end reads as absent
|
|
|
|
|
|
def _entry_owner_path(entry, config):
|
|
"""The ``providers:`` / ``custom_providers:`` entry a fallback entry authenticates through."""
|
|
from hermes_cli.runtime_provider_custom import (custom_provider_config_path_for_url,
|
|
named_custom_provider_config_path)
|
|
provider = str(entry.get('provider') or '').strip()
|
|
if provider.lower() == 'custom':
|
|
return custom_provider_config_path_for_url(entry.get('base_url'), config)
|
|
return named_custom_provider_config_path(provider, config) if provider else None
|
|
|
|
|
|
def disable_dropped_secrets(config, dropped, session=None):
|
|
"""Disable, in the session's effective *config*, whatever authenticated with a *dropped* frozen
|
|
secret, instead of leaving it with a null key (null means 'resolve another credential' and would
|
|
send another account's key). A fallback using it is removed from the chain; an ``auxiliary.<task>``
|
|
override using it is replaced by the session's primary route (``provider: main``). Never substitutes."""
|
|
if not dropped:
|
|
return config
|
|
from agent.auxiliary_task_config import _AUX_ROUTE_KEYS
|
|
auxiliary = config.get('auxiliary') if isinstance(config.get('auxiliary'), dict) else {}
|
|
for task in sorted({path[1] for path in dropped if path[0] == 'auxiliary' and len(path) > 2}, key=str):
|
|
block = auxiliary.get(task)
|
|
if isinstance(block, dict):
|
|
# The task's own route is gone with its key: the session's primary route, its own credential.
|
|
auxiliary[task] = {**{k: v for k, v in block.items() if k not in _AUX_ROUTE_KEYS
|
|
and k != 'fallback_chain'}, 'provider': 'main'}
|
|
logger.warning('Session %s: auxiliary task %s returned to the primary route: its credential '
|
|
'changed in config.yaml since this session started', session, task)
|
|
owners = {path[:2] for path in dropped if path[0] in ('providers', 'custom_providers') and len(path) > 2}
|
|
for section in ('fallback_providers', 'fallback_model'):
|
|
raw = config.get(section)
|
|
if isinstance(raw, dict):
|
|
entries, single = [raw], True
|
|
elif isinstance(raw, list):
|
|
entries, single = list(raw), False
|
|
else:
|
|
continue
|
|
kept = []
|
|
for index, entry in enumerate(entries):
|
|
prefix = (section,) if single else (section, index)
|
|
own = any(path[:len(prefix)] == prefix for path in dropped)
|
|
owner = _entry_owner_path(entry, config) if isinstance(entry, dict) and owners else None
|
|
if own or (owner is not None and tuple(owner) in owners):
|
|
logger.warning('Session %s: fallback %s/%s removed: its credential changed in config.yaml '
|
|
'since this session started', session,
|
|
(entry or {}).get('provider') if isinstance(entry, dict) else '?',
|
|
(entry or {}).get('model') if isinstance(entry, dict) else '?')
|
|
continue
|
|
kept.append(entry)
|
|
if single:
|
|
if kept:
|
|
config[section] = kept[0]
|
|
else:
|
|
config.pop(section, None)
|
|
else:
|
|
config[section] = kept
|
|
return config
|