Files
hermes-agent/gateway/session_local_mcp.py
Teknium 8d389383f6 fix(gateway): a prepared branch hashes the child's own edit policy
On the prepared fork path _child_secret_ref stamped the durable config-secret
reference's legacy policy hash from the parent's policy (Don't Ask), while the
child is stored at Default. Hash the policy the child is actually stored with.
2026-10-18 22:58:30 -07:00

513 lines
25 KiB
Python

"""Editor MCP servers are session state the gateway owns; an ACP connection only requests them.
Only a fingerprint and the selected schema manifest are durable. Editors must
resupply the exact transport specification after owner restart, including argv:
credentials are not reliably distinguishable from ordinary command arguments.
A different specification is a ``session.mutate editor_mcp`` (control capability,
revision/generation fenced, idle only), never an implicit swap on attach.
The frozen manifest is part of the session's prompt-cache prefix, so it only changes
at a turn boundary: through that mutation while the session is idle, or through the
re-freeze the drain runs at the start of an admission (``refreeze_editor_mcp``).
Inside an execution it never changes.
"""
import asyncio
from contextlib import contextmanager
from dataclasses import replace
import hashlib
import hmac
import json
import logging
import threading
import uuid
from hermes_state_runtime import RuntimeStoreError
logger = logging.getLogger(__name__)
PRIVATE_KEY = ('editor_mcp',)
_HELD_LOCK = threading.Lock()
_RELIST_TIMEOUT = 5 # seconds an admission start waits on a held server's tools/list
def _json(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'))
def fingerprint(servers):
return hashlib.sha256(_json(servers).encode()).hexdigest()
def validate_servers(servers):
from acp.schema import McpServerStdio, McpServerHttp, McpServerSse
from pydantic import ValidationError
kinds = {'stdio': McpServerStdio, 'http': McpServerHttp, 'sse': McpServerSse}
if not isinstance(servers, list):
raise RuntimeStoreError('invalid_params', field='editor.mcp_servers',
detail=f'must be a list, got {type(servers).__name__}')
if len(servers) > 16:
raise RuntimeStoreError('invalid_params', field='editor.mcp_servers',
detail=f'at most 16 servers, got {len(servers)}')
if len(_json(servers)) > 65536:
raise RuntimeStoreError('invalid_params', field='editor.mcp_servers',
detail='the serialized server list exceeds 65536 bytes')
names = set()
try:
for spec in servers:
kind = spec.get('type', 'stdio')
model = kinds[kind].model_validate(spec)
if not model.name or model.name in names:
raise ValueError('duplicate server')
names.add(model.name)
except (ValidationError, KeyError, AttributeError, TypeError, ValueError) as exc:
# Never the message: pydantic's carries ``input_value=`` and a server's argv may hold a credential.
if isinstance(exc, ValidationError):
why = '; '.join(f"{'.'.join(map(str, err['loc']))}: {err['type']}"
for err in exc.errors(include_input=False))
else:
why = 'duplicate server' if str(exc) == 'duplicate server' else type(exc).__name__
raise RuntimeStoreError('invalid_params', field='editor.mcp_servers',
detail=f'unusable server spec: {why}') from exc
def private_editor_request(request, private):
editor = request.get('editor') or {}
servers = editor.get('mcp_servers') or []
if not servers:
return
if private is None:
raise RuntimeStoreError('launch_credentials_unavailable')
private[PRIVATE_KEY] = json.loads(_json(servers))
request['editor'] = {**editor, 'mcp_servers': {'fingerprint': fingerprint(servers)}}
def private_mutation_payload(operation, payload):
"""``(store payload, servers)`` of an ``editor_mcp`` or a ``branch`` naming servers: the list
stays in owner memory; the receipt digest and every store write see only its fingerprint."""
if operation == 'editor_mcp':
if not isinstance(payload, dict) or set(payload) != {'mcp_servers'}:
raise RuntimeStoreError('invalid_params')
elif operation != 'branch' or not isinstance(payload, dict) or 'mcp_servers' not in payload:
return payload, None
servers = payload['mcp_servers']
validate_servers(servers)
servers = json.loads(_json(servers))
return {**payload, 'mcp_servers': {'fingerprint': fingerprint(servers)}}, servers
def session_scope(authority, sid):
"""A session's own ledger scope: never shared with another session (a fork included) or profile."""
return 'editor-session:' + hashlib.sha256(f'{authority.profile_id}:{sid}'.encode()).hexdigest()
def _server_names(scope, servers):
"""Index -> ledger name: the editor's own server name (the model sees ``mcp__editor_github_<tag>__x``)
plus a per-session tag, so two sessions borrowing same-named servers never adopt one connection."""
from tools.mcp_tool_schema import sanitize_mcp_name_component
return {i: f"editor_{sanitize_mcp_name_component(spec['name'])}_"
f"{hashlib.sha256(f'{scope}:{i}'.encode()).hexdigest()[:8]}" for i, spec in enumerate(servers)}
def _frozen_names(data, servers):
"""The names a session's manifest was frozen under; manifests without ``names`` predate them."""
if 'names' in data:
return {int(i): name for i, name in data['names'].items()}
prefix = hashlib.sha256(data['scope'].encode()).hexdigest()[:20]
return {i: f'editor_{prefix}_{i}' for i in range(len(servers))}
def _frozen_toolset(data, toolset):
"""Whether *toolset* is one of the editor toolsets frozen in *data* (a manifest or None)."""
if not data:
return False
if 'names' in data:
return toolset in {f'mcp-{name}' for name in data['names'].values()}
return toolset.startswith(f"mcp-editor_{hashlib.sha256(data['scope'].encode()).hexdigest()[:20]}_")
def _configs(servers, names, cwd):
configs = {}
for i, name in names.items():
spec = servers[i]
if spec.get('type', 'stdio') == 'stdio':
config = {'command': spec['command'], 'args': spec['args'],
'env': {e['name']: e['value'] for e in spec.get('env', [])}, 'cwd': cwd}
else:
config = {'url': spec['url'], 'headers': {e['name']: e['value'] for e in spec.get('headers', [])}}
if spec['type'] == 'sse':
config['transport'] = 'sse'
# One bounded eager discovery; no lazy or late schema refresh.
config.update(connect_timeout=5, tool_timeout=30, lazy=False)
configs[name] = config
return configs
class _Held:
"""One session scope's editor connections. ``users`` executions borrow them right now; between
executions they stay connected only while the session has a viewer (``_viewed``)."""
def __init__(self):
self.lock = threading.Lock()
self.users = 0
self.fingerprint = None
def _held(authority, scope):
with _HELD_LOCK:
table = getattr(authority, '_editor_mcp_held', None)
if table is None:
table = authority._editor_mcp_held = {}
return table.setdefault(scope, _Held())
def _viewed(authority, scope):
return any(live.subscribers and session_scope(authority, sid) == scope
for sid, live in list(getattr(authority, 'sessions', {}).items()))
def _connected(scope, names):
from tools.mcp_tool import _servers
from tools.mcp_tool_scope import _server_key
# Inside the scope the ledger keys are ``(scope, name)``, never bare names.
return [i for i, name in names.items()
if _server_key(name, scope) in _servers and _servers[_server_key(name, scope)].session]
def _disconnect(held, scope):
from tools.mcp_tool_lifecycle import shutdown_mcp_servers
shutdown_mcp_servers(scope=scope)
held.fingerprint = None
@contextmanager
def _borrowed(authority, scope, servers, names, cwd, *, partial=False):
"""Yield ``(schemas, connected indexes)`` inside the session's tool scope. Only ``partial`` (a
selection being frozen) may leave a server out; an execution needs every server its manifest
was frozen with. A held connection is reused; the last borrower closes it unless a viewer remains."""
from tools.registry import registry, session_tool_scope
from tools.mcp_tool_discovery import register_mcp_servers
held = _held(authority, scope)
# The cwd too: stdio servers run in it, so a committed workspace move respawns them there.
wanted = fingerprint([servers, cwd])
with session_tool_scope(scope):
with held.lock:
if held.fingerprint not in (None, wanted):
# A committed editor_mcp swapped the credentials (or a workspace move the cwd): no
# execution may still run on the old ones.
if held.users:
raise RuntimeStoreError('session_busy')
_disconnect(held, scope)
connected = _connected(scope, names)
if len(connected) != len(names):
register_mcp_servers(_configs(servers, names, cwd))
connected = _connected(scope, names)
held.fingerprint = wanted
held.users += 1
try:
if len(connected) != len(names) and not partial:
raise RuntimeStoreError('acp_mcp_discovery_failed')
toolsets = {f'mcp-{names[i]}' for i in connected}
tools = [n for n in registry.get_all_tool_names() if registry.get_toolset_for_tool(n) in toolsets]
yield {n: registry.get_schema(n) for n in sorted(tools)}, connected
finally:
with held.lock:
held.users -= 1
if not held.users and not _viewed(authority, scope):
_disconnect(held, scope)
def release_editor_mcp(authority, session_ids):
"""The last viewer left (or the sessions retired): close their held editor connections. One an
execution still borrows is closed by that execution when it ends."""
table = getattr(authority, '_editor_mcp_held', None) or {}
for sid in session_ids:
scope = session_scope(authority, sid)
held = table.get(scope)
if held is None:
continue
with held.lock:
if not held.users and held.fingerprint is not None and not _viewed(authority, scope):
_disconnect(held, scope)
def rebind_editor_mcp(authority, sid, policy, servers):
"""``(policy, manifest changed)``: *policy* with its editor selection freshly discovered from
*servers* under *sid*'s own scope (blocking). A broken server is left out of the frozen selection
(main skipped it too), so no later execution respawns it."""
old = json.loads(policy.editor_mcp_json or 'null')
toolsets = {name for name in policy.toolsets if not _frozen_toolset(old, name)}
data = None
if servers:
scope = session_scope(authority, sid)
held = _held(authority, scope)
with held.lock:
if held.users:
raise RuntimeStoreError('session_busy')
# Fresh discovery: a held connection keeps the schemas it was first frozen with.
_disconnect(held, scope)
names = _server_names(scope, servers)
with _borrowed(authority, scope, servers, names, policy.cwd, partial=True) as (schemas, connected):
for i in sorted(set(names) - set(connected)):
logger.warning("Editor MCP server %r failed discovery; session %s continues without it",
servers[i].get('name'), sid)
names = {i: names[i] for i in connected}
data = {'scope': scope, 'fingerprint': fingerprint(servers), 'schemas': schemas,
'names': {str(i): name for i, name in names.items()}}
toolsets |= {f'mcp-{name}' for name in names.values()}
rebound = replace(policy, editor_mcp_json=_json(data) if data else None, toolsets=tuple(sorted(toolsets)))
changed = ((old or {}).get('schemas', {}) != (data or {}).get('schemas', {})
or rebound.toolsets != policy.toolsets)
return rebound, changed
def bind_editor_mcp(authority, sid, policy, servers):
if not servers:
return policy
policy, _ = rebind_editor_mcp(authority, sid, policy, servers)
attach_editor_mcp(authority, policy, servers)
return policy
def attach_editor_mcp(authority, policy, servers):
validate_servers(servers)
data = json.loads(policy.editor_mcp_json or '{}')
if not data or not hmac.compare_digest(data['fingerprint'], fingerprint(servers)):
raise RuntimeStoreError('acp_mcp_policy_conflict')
borrowed = getattr(authority, '_local_editor_mcp', None)
if borrowed is None:
borrowed = authority._local_editor_mcp = {}
borrowed[data['scope']] = json.loads(_json(servers))
def resume_editor_mcp(authority, ref, editor):
"""Call after ordinary resume authorization, before attaching the viewer. An empty or omitted
list keeps the session's servers: ACP load always carries one, so a second editor without MCP
config must not strip the first one's tools for every viewer. Removal is only the fenced
``editor_mcp`` change with ``[]``."""
if editor is None:
return
servers = editor.get('mcp_servers') if isinstance(editor, dict) else None
if servers == []:
return
from gateway.session_local_editor import validate_editor
from gateway.session_policy import policy_for_source
policy = policy_for_source(authority.runner, authority.sessions[ref.session_id].source)
if policy is None:
raise RuntimeStoreError('acp_mcp_policy_conflict')
validate_editor(policy.source, editor)
if servers:
attach_editor_mcp(authority, policy, servers)
def retire_editor_mcp(authority, sid):
"""An empty selection committed: *sid*'s held editor connections close now (blocking), viewer or not."""
scope = session_scope(authority, sid)
held = _held(authority, scope)
with held.lock:
_disconnect(held, scope)
def install_editor_mcp(authority, sid, servers):
"""After a committed change: *sid*'s borrowed credentials follow its committed policy (memory only)."""
from gateway.session_policy import restore_policy
from hermes_state_local import local_receipt
policy = restore_policy(local_receipt(authority.db, sid)['policy'])
data = json.loads(policy.editor_mcp_json or '{}')
borrowed = getattr(authority, '_local_editor_mcp', None)
if borrowed is None:
borrowed = authority._local_editor_mcp = {}
if not data:
borrowed.pop(session_scope(authority, sid), None)
elif hmac.compare_digest(data['fingerprint'], fingerprint(servers)):
borrowed[data['scope']] = json.loads(_json(servers))
def announce_tools_changed(live, session_id, result):
"""One ``session.tools_changed`` on the session's stream when a change altered the manifest:
server names and the generation only, never a transport specification."""
if live is not None and result.get('tools_changed'):
live.event_stream.publish(session_id, {'mcp_servers': result['mcp_servers'],
'execution_generation': result['execution_generation']}, event_type='session.tools_changed')
async def prepare_editor_mcp(authority, live, payload, prepared, *, servers):
"""``session.mutate editor_mcp``: discover the new list and freeze it into the policy the next
turn runs under. Equal schemas leave the manifest, and so the prompt-cache prefix, as it was;
only the fingerprint and the borrowed credentials swap."""
from gateway.session_mutation_policy import _rebind
from gateway.session_policy import restore_policy
receipt = prepared['snapshot']['receipt']
old = restore_policy(receipt['policy'])
if old.source != 'acp':
raise RuntimeStoreError('invalid_params')
policy, changed = await asyncio.to_thread(rebind_editor_mcp, authority, receipt['session_id'], old, servers)
names = json.loads(policy.editor_mcp_json or '{}').get('names', {})
return _rebind(authority, prepared, old, policy, tools_changed=changed,
mcp_servers=[servers[int(i)]['name'] for i in sorted(names, key=int)])
def _live_tools_changed(scope, names):
"""Whether a held server's live ``tools/list`` differs from the tools its connection was
discovered with. Editor servers ignore ``tools/list_changed`` (the manifest is frozen), so
the admission start asks; an unanswered list (bounded) counts as unchanged."""
from tools import mcp_tool_loop
from tools.mcp_tool import _paginate_full_list, _servers
from tools.mcp_tool_scope import _server_key
def dump(tools):
return _json([tool.model_dump(mode='json') for tool in tools])
held = [server for server in (_servers.get(_server_key(name, scope)) for name in names.values())
if server is not None]
async def relist(server):
async with server._rpc_lock:
if server.session is None:
return None
return await _paginate_full_list(server.session.list_tools, 'tools', server.name)
async def relist_all():
# Every held server at once: one bound for the whole check, not one per server.
tasks = [asyncio.ensure_future(relist(server)) for server in held]
try:
if tasks:
await asyncio.wait(tasks, timeout=_RELIST_TIMEOUT)
finally:
for task in tasks:
task.cancel() # an unanswered list (bounded) counts as unchanged
answered = []
for server, task in zip(held, tasks):
settled = task.done() and not task.cancelled()
error = task.exception() if settled else None
if settled and error is None:
answered.append((server, task.result()))
else:
logger.warning('Editor MCP server %r: tools/list for the drift check failed', server.name,
exc_info=error)
return answered
try:
# The loop's own bound is a backstop; relist_all settles within _RELIST_TIMEOUT.
answered = mcp_tool_loop._run_on_mcp_loop(relist_all, timeout=_RELIST_TIMEOUT + 1)
except (RuntimeError, TimeoutError, InterruptedError):
logger.warning('Editor MCP drift check of %s failed', scope, exc_info=True)
return False
return any(tools is not None and dump(tools) != dump(server._tools) for server, tools in answered)
async def refreeze_editor_mcp(authority, ref, live):
"""The drain, once the FIFO head is authorized and before it is claimed (claim gate and mutation
lock held, nothing executing), re-checks the editor schemas; a drifted manifest is re-frozen through the
``editor_mcp`` writer path, so the turn runs on it instead of failing ``acp_mcp_schema_changed``.
Best-effort: the FIFO must not pause on it, so a failed re-check only logs and the turn then
reports real drift as before."""
if not getattr(authority, '_local_editor_mcp', None):
return # no session of this owner borrows editor servers: nothing to re-check
try:
await _refreeze(authority, ref)
except Exception: # health: allow BLE001 -- the probe spawns arbitrary editor servers; nothing it raises may kill the drain
logger.warning('Editor MCP re-check of session %s failed; the turn runs on its frozen manifest',
ref.session_id, exc_info=True)
async def _refreeze(authority, ref):
from gateway.session_policy import restore_policy
from hermes_state_local import local_receipt
# The committed policy the claim just restored (``restore_local_session``).
receipt = await asyncio.to_thread(local_receipt, authority.db, ref.session_id)
policy = restore_policy(receipt['policy'])
if not policy.editor_mcp_json:
return
data = json.loads(policy.editor_mcp_json)
servers = authority._local_editor_mcp.get(data['scope'])
if servers is None:
return # the execution reports launch_credentials_unavailable
def probe():
names = _frozen_names(data, servers)
with _borrowed(authority, data['scope'], servers, names, policy.cwd, partial=True) as (schemas, connected):
complete = len(connected) == len(names)
# A held connection keeps the registry schemas it was discovered with: ask the server.
return schemas, complete, complete and _live_tools_changed(data['scope'], names)
schemas, complete, drifted = await asyncio.to_thread(probe)
# A server that is down right now is the execution's discovery failure, never a narrowed manifest.
if not complete or (schemas == data['schemas'] and not drifted):
return
await _commit_refreeze(authority, ref, servers)
async def _commit_refreeze(authority, ref, servers):
"""The ``editor_mcp`` change on the owning principal's behalf, through ``session.mutate``'s own
writer (the drain holds the session's locks, and a schema re-check needs no capability)."""
from gateway.session_mutations import write_mutation
from hermes_state_local import local_receipt
owner = await asyncio.to_thread(local_receipt, authority.db, ref.session_id)
row = await asyncio.to_thread(authority.db.get_session, ref.session_id)
payload, servers = private_mutation_payload('editor_mcp', {'mcp_servers': servers})
params = {'session_id': ref.session_id, 'request_id': 'editor-mcp-refreeze:' + uuid.uuid4().hex,
'expected_revision': row['runtime_revision'], 'expected_generation': row['runtime_generation'],
'operation': 'editor_mcp', 'payload': payload}
await write_mutation(authority, ref, params, servers, principal_id=owner['principal_id'])
def _child_secret_ref(authority, child, parent, policy):
"""A durable config-secret reference names the policy identity, which the child's own editor
selection changed; the frozen secrets themselves are the parent's."""
from gateway.session_policy_credentials import PREFIX, config_reference, recover_config_secrets
if parent.config_secret_ref is None or not parent.config_secret_ref.startswith(PREFIX):
return policy
secrets = recover_config_secrets(authority, parent, skip=())
return replace(policy, config_secret_ref=config_reference(authority, child, policy, secrets))
async def prepare_branch_editor(authority, ref, servers, gate):
"""``(prepared, servers)`` for a fork of an editor session: the child's selection under its OWN
scope, from the fork's list or else a copy of the parent's borrowed servers. The child never
shares the parent's scope or connections. ``(None, None)`` when no editor servers are involved.
Discovery starts every server, so *gate* (the branch's prepare-only fences: receipt, revision,
generation, idle) runs first; an exact retry's durable receipt comes back as ``prepared``."""
from gateway.session_policy import restore_policy
from hermes_state_keys import local_identity
from hermes_state_local import local_receipt
try:
saved = await asyncio.to_thread(local_receipt, authority.db, ref.session_id)
except RuntimeStoreError:
return None, None # not a local session: the branch transaction refuses it
parent = restore_policy(saved['policy'])
if servers is None:
if not parent.editor_mcp_json:
return None, None
servers = getattr(authority, '_local_editor_mcp', {}).get(json.loads(parent.editor_mcp_json)['scope'])
if servers is None:
raise RuntimeStoreError('launch_credentials_unavailable')
servers = json.loads(_json(servers))
elif parent.source != 'acp':
raise RuntimeStoreError('invalid_params')
gated = await gate()
if 'snapshot' not in gated:
return gated, servers
request_id = 'branch:' + uuid.uuid4().hex
child = local_identity(saved['profile_id'], saved['principal_id'], request_id)
policy, _ = await asyncio.to_thread(rebind_editor_mcp, authority, child, parent, servers)
# Hashed as the child is stored: a branch starts at Default (hermes_state_mutation_branch._child_policy).
policy = _child_secret_ref(authority, child, parent, replace(policy, edit_approval_policy='ask'))
return {**gated, 'request_id': request_id, 'child': child, 'parent_editor_mcp_json': parent.editor_mcp_json,
'editor_mcp_json': policy.editor_mcp_json, 'toolsets': list(policy.toolsets),
'config_secret_ref': policy.config_secret_ref}, servers
@contextmanager
def editor_mcp_scope(authority, policy):
if not policy.editor_mcp_json:
yield
return
data = json.loads(policy.editor_mcp_json)
servers = getattr(authority, '_local_editor_mcp', {}).get(data['scope'])
if servers is None:
raise RuntimeStoreError('launch_credentials_unavailable')
with _borrowed(authority, data['scope'], servers, _frozen_names(data, servers), policy.cwd) as (schemas, _):
if schemas != data['schemas']:
# Drift is re-frozen by the drain before a claim; inside an execution the manifest never changes.
raise RuntimeStoreError('acp_mcp_schema_changed')
yield