53ffdb8aa2
Third-party Hermes platform plugin serving the Pheby WebSocket+HTTPS protocol for the native Android client, behind Caddy: - Platform adapter (BasePlatformAdapter subclass) registered via the documented plugin system (plugins.platforms pattern, kind: platform) - Multiple conversations mapped to Hermes sessions (authoritative state) - Streaming chat via GatewayStreamConsumer edit path (message.delta) - Structured tool events (never fake tool text in chat), incl. post_tool_call hook relay with Hermes tool_call_ids - Native approval + clarification round-trips via tools.approval / tools.clarify_gateway primitives - Attachment delivery: adapter-owned copies, opaque IDs, persistent metadata, 7-day retention + safe cleanup, authenticated HTTPS download - Model + reasoning-effort query/change via Hermes picker data and session/global overrides - Shared-secret auth (constant-time, lockout), size limits, path- traversal-proof attachment resolution, minimal health endpoint - Protocol v1 spec with JSON examples (docs/PROTOCOL.md) - Install/config/Caddy/security docs + discovered Hermes limitations - 37 passing tests (auth, conversations, protocol, attachments incl. expiry/traversal, tool events, approvals, clarifications, cancel, reconnect re-sync, live WS smoke tests) — gateway-free fakes No Hermes core modifications required.
73 lines
2.7 KiB
Python
73 lines
2.7 KiB
Python
"""Per-client WebSocket connection state and inbound dispatch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
|
|
from aiohttp import web, WSMsgType
|
|
|
|
from . import protocol as proto
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ClientConnection:
|
|
"""One authenticated WebSocket client (single-user app: at most a few)."""
|
|
|
|
def __init__(self, ws: "web.WebSocketResponse", conn_id: str):
|
|
self.ws = ws
|
|
self.conn_id = conn_id
|
|
self.authenticated = False
|
|
self.protocol_version: Optional[int] = None
|
|
self.connected_at = time.time()
|
|
# Serialized outbound writes — aiohttp WS frames are not safe to
|
|
# compose concurrently from multiple tasks.
|
|
self._send_lock = asyncio.Lock()
|
|
self.closed = False
|
|
|
|
async def send_json(self, payload: Dict[str, Any]) -> bool:
|
|
"""Thread-safe send; returns False when the socket is going away."""
|
|
if self.closed:
|
|
return False
|
|
try:
|
|
async with self._send_lock:
|
|
await self.ws.send_str(proto.encode_message(payload))
|
|
return True
|
|
except (ConnectionError, RuntimeError, asyncio.CancelledError):
|
|
self.closed = True
|
|
return False
|
|
|
|
# ------------------------------------------------------------------
|
|
async def read_loop(self, server: Any) -> None:
|
|
"""Receive/dispatch loop; exits on close, error, or auth timeout."""
|
|
try:
|
|
async for msg in self.ws:
|
|
if msg.type == WSMsgType.TEXT:
|
|
if len(msg.data) > proto.MAX_WS_MESSAGE_BYTES:
|
|
await self.send_json(proto.error_event(
|
|
proto.ERR_TOO_LARGE,
|
|
"WebSocket message exceeds server limit"))
|
|
continue
|
|
await server.handle_client_message(self, msg.data)
|
|
elif msg.type == WSMsgType.BINARY:
|
|
await self.send_json(proto.error_event(
|
|
proto.ERR_BAD_REQUEST,
|
|
"Binary frames are not part of the Pheby protocol"))
|
|
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING,
|
|
WSMsgType.CLOSED):
|
|
break
|
|
elif msg.type == WSMsgType.ERROR:
|
|
logger.warning("[pheby] ws error on %s: %s",
|
|
self.conn_id, msg.data)
|
|
break
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.warning("[pheby] client %s read loop crashed",
|
|
self.conn_id, exc_info=True)
|
|
finally:
|
|
self.closed = True
|