Files
hermes-agent/gateway/runtime_bootstrap.py
Teknium dbde5a991a fix(kanban): the owner refuses a worker that dials into a restart wait opened after discovery
connect_gateway's restart-wait refusal read the state discovery returned. A restart that began
between discovery and the WebSocket dial still admitted the Kanban worker on its interactive
ticket into the after-turn wait, where each prompt.receipt answer refreshed its silence budget
and held `hermes update` until the forced-restart deadline.

The policy now rides the ticket: inside outside_restart_wait() the session-ticket request carries
`restart_wait: "refuse"`, the ticket store records it on the grant, and GatewayRuntimeAPI decides
admission from the grant at redeem time, so the owner closes the socket (1013) whenever its
restart wait is open by the time the worker connects. Interactive clients still attach (#139041).
An owner older than the client refuses the unknown parameter; the client re-mints without it and
keeps the pre-dial check rather than stranding the worker until that owner restarts.
2026-10-28 20:40:14 -07:00

87 lines
4.3 KiB
Python

"""Private local bootstrap grants; never put these credentials in discovery."""
from __future__ import annotations
import hashlib
import secrets
import threading
import time
_PURPOSE_CAPABILITIES = {
'interactive': frozenset({'session:create', 'session:read', 'session:submit',
'session:control', 'session:approve', 'session:respond'}),
'native-http': frozenset({'http:owner'}),
# Same-user host operation, not permission to read or mutate another profile.
'native-profile-create': frozenset({'profiles:create'}),
# Rename/delete of the ONE profile it is minted for (#140345): a registry write on that home,
# never a read of it or any other route.
'native-profile-registry': frozenset({'profiles:registry'}),
'exposure': frozenset({'transport:delegate'}),
'worker-adoption': frozenset({'worker:adopt'}),
}
# A grant's profile reach: its own home, or (interactive only) every served sibling.
_SCOPES = frozenset({'profile', 'host'})
class TicketStore:
TTL_SECONDS = 30
MAX_ENTRIES = 1024
def __init__(self, instance_id: str, profile_ids: frozenset[str]):
if not instance_id or not profile_ids:
raise ValueError('bootstrap requires instance and served profiles')
self.instance_id = instance_id
self.profile_ids = frozenset(profile_ids)
self._entries: dict[str, tuple[float, dict]] = {}
self._lock = threading.Lock()
def mint(self, *, profile_id, subject, purpose, scope='profile', refuse_restart_wait=False) -> str:
"""``scope='host'`` lets an interactive connection select every profile this process
serves (the Desktop's one shared socket); the default binds exactly ``profile_id``.
``refuse_restart_wait``: the socket redeeming it is refused while a restart's after-turn
wait is open, however long after the mint that wait began (a machine poller, never a human)."""
if (profile_id not in self.profile_ids or purpose not in _PURPOSE_CAPABILITIES or not subject
or scope not in _SCOPES or (scope == 'host' and purpose != 'interactive')):
raise PermissionError('bootstrap binding rejected')
with self._lock:
now = time.monotonic()
self._entries = {k: v for k, v in self._entries.items() if v[0] > now}
if len(self._entries) >= self.MAX_ENTRIES:
raise PermissionError('bootstrap capacity exhausted')
ticket = secrets.token_urlsafe(32)
self._entries[hashlib.sha256(ticket.encode()).hexdigest()] = (
now + self.TTL_SECONDS,
{'instance_id': self.instance_id, 'profile_id': profile_id, 'subject': subject,
'purpose': purpose, 'capabilities': _PURPOSE_CAPABILITIES[purpose], 'scope': scope,
'refuse_restart_wait': bool(refuse_restart_wait)})
return ticket
def redeem(self, ticket, *, profile_id, purpose) -> dict:
"""``profile_id=None`` accepts a ticket minted for ANY served profile; the returned grant's
``profile_id`` then selects the authority. A named profile must match exactly."""
if not isinstance(ticket, str) or len(ticket) > 256:
raise PermissionError('invalid bootstrap ticket')
key = hashlib.sha256(ticket.encode()).hexdigest()
with self._lock:
entry = self._entries.get(key)
if entry is None:
raise PermissionError('invalid bootstrap ticket')
expires, grant = entry
if expires <= time.monotonic():
del self._entries[key]
raise PermissionError('expired bootstrap ticket')
served = grant['profile_id'] in self.profile_ids if profile_id is None else grant['profile_id'] == profile_id
if not served or grant['purpose'] != purpose:
raise PermissionError('bootstrap binding rejected')
del self._entries[key]
return dict(grant)
def outstanding(self) -> bool:
"""An unexpired ticket was minted and not yet redeemed: a client is attaching right now."""
with self._lock:
now = time.monotonic()
return any(expires > now for expires, _grant in self._entries.values())
def revoke(self) -> None:
with self._lock:
self._entries.clear()