mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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.
87 lines
4.3 KiB
Python
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()
|