feat: Pheby platform adapter/plugin for Hermes (protocol v1)

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.
This commit is contained in:
2026-09-02 19:31:17 +00:00
parent 31c16de665
commit 53ffdb8aa2
19 changed files with 4688 additions and 0 deletions
+562
View File
@@ -0,0 +1,562 @@
"""Pheby HTTP + WebSocket server (aiohttp).
Listens on localhost/plain HTTP behind Caddy. Routes:
* ``GET /health`` — unauthenticated liveness (minimal info).
* ``GET /ws`` — WebSocket; first frame must be ``hello``.
* ``GET /attachments/{id}`` — authenticated attachment download (streamed).
All message routing lives in :meth:`PhebyServer.handle_client_message`;
Hermes integration (runs, approvals, clarifications, models) lives in
:mod:`.hermes_bridge` to keep this module focused on protocol + transport.
"""
from __future__ import annotations
import asyncio
import logging
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from aiohttp import web
from . import protocol as proto
from .attachments import AttachmentStore, constant_time_equals
from .config import PhebyConfig
from .conversations import ConversationRouter
from . import hermes_bridge
from .ws_client import ClientConnection
logger = logging.getLogger(__name__)
class PhebyServer:
"""Owns the aiohttp app, connected clients, and shared subsystems."""
def __init__(self, config: PhebyConfig, adapter: Any = None):
self.config = config
self.adapter = adapter # PhebyAdapter (may be None in tests)
self.router = ConversationRouter()
from hermes_constants import get_hermes_home
root = config.attachments_root or str(
Path(get_hermes_home()) / "pheby-attachments")
self.store = AttachmentStore(
root=Path(root),
retention_days=config.retention_days,
index_path=Path(config.index_path) if config.index_path else None,
)
self.bridge = hermes_bridge # bridge function module
self._clients: Dict[str, ClientConnection] = {}
self._auth_failures: Dict[str, List[float]] = {}
self._cleanup_task: Optional[asyncio.Task] = None
self._app: Optional[web.Application] = None
self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None
self._conn_counter = 0
# ── lifecycle ────────────────────────────────────────────────────────
async def start(self) -> bool:
from aiohttp import web as _web # local import keeps import light
self.store.hydrate_legacy_meta()
app = _web.Application(client_max_size=proto.MAX_WS_MESSAGE_BYTES)
app.router.add_get("/health", self._handle_health)
app.router.add_get("/ws", self._handle_ws)
app.router.add_get("/attachments/{attachment_id}",
self._handle_attachment_download)
self._app = app
self._runner = web.AppRunner(app, access_log=None)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self.config.bind_host,
self.config.port)
try:
await self._site.start()
except OSError as exc:
logger.error("[pheby] failed to bind %s:%s — %s",
self.config.bind_host, self.config.port, exc)
await self.stop()
return False
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info("[pheby] serving on http://%s:%d (attachments: %s)",
self.config.bind_host, self.config.port,
self.store.root)
return True
async def stop(self) -> None:
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
for client in list(self._clients.values()):
client.closed = True
try:
await client.ws.close()
except Exception:
pass
self._clients.clear()
if self._runner:
await self._runner.cleanup()
self._runner = None
self._site = None
self._app = None
logger.info("[pheby] server stopped")
# ── background cleanup ───────────────────────────────────────────────
async def _cleanup_loop(self) -> None:
"""Hourly expired-attachment sweep; first sweep after 5 minutes."""
try:
await asyncio.sleep(300)
while True:
try:
await self.store.cleanup_expired()
except Exception:
logger.error("[pheby] attachment cleanup failed",
exc_info=True)
await asyncio.sleep(3600)
except asyncio.CancelledError:
pass
# ── HTTP handlers ────────────────────────────────────────────────────
async def _handle_health(self, request: web.Request) -> web.Response:
"""Minimal unauthenticated liveness probe."""
return web.json_response({"status": "ok"})
def _check_http_secret(self, request: web.Request) -> bool:
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
token = header[7:].strip()
elif header.startswith("ApiKey "):
token = header[7:].strip()
else:
token = request.headers.get("X-Pheby-Secret", "").strip()
if not token:
return False
return constant_time_equals(token, self.config.secret)
async def _handle_attachment_download(
self, request: web.Request) -> web.StreamResponse:
attachment_id = request.match_info.get("attachment_id", "")
if not self._check_http_secret(request):
return web.json_response(
{"error": {"code": proto.ERR_UNAUTHORIZED,
"message": "Authentication required"}},
status=401)
blob = self.store.resolve_blob(attachment_id)
if blob is None:
# Expired, unknown, or malformed — same minimal response so the
# endpoint leaks nothing about implementation details.
return web.json_response(
{"error": {"code": proto.ERR_NOT_FOUND,
"message": "Attachment unavailable"}},
status=404)
desc = self.store.describe(attachment_id) or {}
safe_name = desc.get("filename", "file.bin")
logger.info("[pheby] attachment download: id=%s bytes=%s",
attachment_id, desc.get("size"))
return web.FileResponse(
blob,
headers={
"Content-Disposition": f'attachment; filename="{safe_name}"',
"Content-Type": desc.get("mime_type",
"application/octet-stream"),
},
)
# ── WebSocket handler ────────────────────────────────────────────────
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(max_msg_size=proto.MAX_WS_MESSAGE_BYTES,
heartbeat=30.0, autoping=True)
await ws.prepare(request)
self._conn_counter += 1
conn_id = f"c{self._conn_counter}"
peer = request.remote or "unknown"
if self._is_locked_out(peer):
logger.warning("[pheby] auth lockout active for %s — refusing",
peer)
await ws.close(code=4401, message=b"locked out")
return ws
client = ClientConnection(ws, conn_id)
self._clients[conn_id] = client
logger.info("[pheby] client %s connected from %s", conn_id, peer)
try:
# Auth phase: hello must arrive within the window.
try:
authed = await asyncio.wait_for(
self._authenticate(client, peer),
timeout=proto.AUTH_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
await client.send_json(proto.error_event(
proto.ERR_AUTH_TIMEOUT, "hello not received in time"))
await ws.close()
return ws
if not authed:
await ws.close(code=4401, message=b"unauthorized")
return ws
await client.send_json({
"type": proto.S_READY,
"protocol_version": proto.PROTOCOL_VERSION,
"server": "pheby",
"ts": proto.now_iso(),
})
await client.read_loop(self)
finally:
self._clients.pop(conn_id, None)
logger.info("[pheby] client %s disconnected (authed=%s, %.0fs)",
conn_id, client.authenticated,
time.time() - client.connected_at)
return ws
def _is_locked_out(self, peer: str) -> bool:
fails = self._auth_failures.get(peer)
if not fails:
return False
cutoff = time.time() - proto.AUTH_FAILURE_LOCKOUT_SECONDS
recent = [t for t in fails if t > cutoff]
self._auth_failures[peer] = recent
return len(recent) >= proto.AUTH_FAILURE_THRESHOLD
def _record_auth_failure(self, peer: str) -> None:
self._auth_failures.setdefault(peer, []).append(time.time())
async def _authenticate(self, client: ClientConnection,
peer: str) -> bool:
"""Wait for the hello frame and validate the shared secret."""
msg = await client.ws.receive(timeout=proto.AUTH_TIMEOUT_SECONDS + 5)
if msg.type != "text" and not hasattr(msg, "data"):
return False
message, err = proto.decode_message(msg.data)
if err or message is None:
await client.send_json(
proto.error_event(err or proto.ERR_BAD_REQUEST,
"Expected hello message"))
return False
if message.get("type") != proto.C_HELLO:
await client.send_json(proto.error_event(
proto.ERR_UNAUTHORIZED, "First message must be hello"))
self._record_auth_failure(peer)
return False
supplied = str(message.get("secret", ""))
if not supplied or not constant_time_equals(supplied,
self.config.secret):
logger.warning("[pheby] auth failure from %s", peer)
self._record_auth_failure(peer)
# Small delay to slow brute force; constant-time compare already
# used for the secret itself.
await asyncio.sleep(0.5)
await client.send_json(proto.error_event(
proto.ERR_UNAUTHORIZED, "Invalid secret"))
return False
requested = message.get("protocol_version")
if requested is not None and int(requested) != proto.PROTOCOL_VERSION:
await client.send_json(proto.error_event(
proto.ERR_VERSION_MISMATCH,
f"Protocol version mismatch: server={proto.PROTOCOL_VERSION}, "
f"client={requested}"))
return False
client.authenticated = True
client.protocol_version = proto.PROTOCOL_VERSION
logger.info("[pheby] client %s authenticated", client.conn_id)
return True
# ── broadcast ────────────────────────────────────────────────────────
async def broadcast(self, payload: Dict[str, Any]) -> None:
"""Send an event to every authenticated client."""
for client in list(self._clients.values()):
if client.authenticated and not client.closed:
await client.send_json(payload)
def has_clients(self) -> bool:
return any(c.authenticated and not c.closed
for c in self._clients.values())
# ── inbound dispatch ─────────────────────────────────────────────────
async def handle_client_message(self, client: ClientConnection,
raw: str) -> None:
message, err = proto.decode_message(raw)
if err or message is None:
await client.send_json(proto.error_event(
err or proto.ERR_BAD_REQUEST, "Malformed message"))
return
mtype = message.get("type", "")
request_id = message.get("request_id")
if self.config.debug:
# Verbose protocol logging — never logs secrets; chat content
# only when explicitly configured (privacy default).
safe = {k: v for k, v in message.items()
if k not in ("secret",)}
if not self.config.log_chat_content and mtype == proto.C_CHAT_SEND:
safe = dict(safe)
safe["text"] = f"<{len(str(message.get('text', '')))} chars>"
logger.info("[pheby] << %s", proto.safe_str(safe, 400))
try:
handler = self._HANDLERS.get(mtype)
if handler is None:
await client.send_json(proto.error_event(
proto.ERR_UNKNOWN_TYPE, f"Unknown message type: {mtype}",
request_id))
return
await handler(self, client, message, request_id)
except Exception:
logger.error("[pheby] handler failed for %s", mtype, exc_info=True)
await client.send_json(proto.error_event(
proto.ERR_INTERNAL, "Internal server error", request_id))
# ── simple handlers ──────────────────────────────────────────────────
async def _handle_ping(self, client: ClientConnection, message: Dict,
request_id: Optional[str]) -> None:
await client.send_json({"type": proto.S_PONG,
"ts": proto.now_iso(),
**({"request_id": request_id}
if request_id else {})})
async def _handle_conversation_list(self, client, message, request_id):
conversations = await self.bridge.list_conversations()
await client.send_json({
"type": proto.S_CONVERSATION_SNAPSHOT,
"conversations": conversations,
**({"request_id": request_id} if request_id else {}),
})
async def _handle_conversation_open(self, client, message, request_id):
conversation_id = str(message.get("conversation_id", ""))
if not ConversationRouter.is_valid_conversation_id(conversation_id):
await client.send_json(proto.error_event(
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
return
limit = message.get("limit", proto.MAX_HISTORY_MESSAGES)
try:
limit = max(1, min(int(limit), proto.MAX_HISTORY_MESSAGES))
except (TypeError, ValueError):
limit = proto.MAX_HISTORY_MESSAGES
history, found = await self.bridge.conversation_history(
conversation_id, limit)
if not found:
await client.send_json(proto.error_event(
proto.ERR_CONVERSATION_NOT_FOUND,
"Conversation not found", request_id))
return
await client.send_json({
"type": proto.S_CONVERSATION_HISTORY,
"conversation_id": conversation_id,
"messages": history,
**({"request_id": request_id} if request_id else {}),
})
async def _handle_conversation_create(self, client, message, request_id):
name = message.get("name")
cid = await self.router.new_conversation(
str(name) if name else None)
await client.send_json({
"type": proto.S_CONVERSATION_CREATED,
"conversation_id": cid,
"name": await self.router.get_name(cid),
**({"request_id": request_id} if request_id else {}),
})
await self.broadcast({
"type": proto.S_CONVERSATION_UPDATED,
"conversation_id": cid,
"name": await self.router.get_name(cid),
})
async def _handle_conversation_rename(self, client, message, request_id):
conversation_id = str(message.get("conversation_id", ""))
name = str(message.get("name", "")).strip()
if not ConversationRouter.is_valid_conversation_id(conversation_id) \
or not name or len(name) > 200:
await client.send_json(proto.error_event(
proto.ERR_BAD_REQUEST,
"conversation_id and name (≤200 chars) required", request_id))
return
ok = await self.router.rename(conversation_id, name)
if not ok:
await client.send_json(proto.error_event(
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
request_id))
return
event = {
"type": proto.S_CONVERSATION_RENAMED,
"conversation_id": conversation_id,
"name": name,
**({"request_id": request_id} if request_id else {}),
}
await client.send_json(event)
await self.broadcast({k: v for k, v in event.items()
if k != "request_id"})
async def _handle_conversation_delete(self, client, message, request_id):
conversation_id = str(message.get("conversation_id", ""))
if not ConversationRouter.is_valid_conversation_id(conversation_id):
await client.send_json(proto.error_event(
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
return
ok = await self.bridge.delete_conversation(conversation_id)
if not ok:
await client.send_json(proto.error_event(
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
request_id))
return
event = {
"type": proto.S_CONVERSATION_DELETED,
"conversation_id": conversation_id,
**({"request_id": request_id} if request_id else {}),
}
await client.send_json(event)
await self.broadcast({k: v for k, v in event.items()
if k != "request_id"})
async def _handle_chat_send(self, client, message, request_id):
conversation_id = str(message.get("conversation_id", ""))
text = message.get("text")
if not ConversationRouter.is_valid_conversation_id(conversation_id):
await client.send_json(proto.error_event(
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
return
if not isinstance(text, str) or not text.strip():
await client.send_json(proto.error_event(
proto.ERR_BAD_REQUEST, "text is required", request_id))
return
if len(text) > proto.MAX_TEXT_CHARS:
await client.send_json(proto.error_event(
proto.ERR_TOO_LARGE,
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
return
await self.bridge.send_chat(conversation_id, text, client, request_id)
async def _handle_run_cancel(self, client, message, request_id):
conversation_id = str(message.get("conversation_id", ""))
run_id = message.get("run_id")
ok = await self.bridge.cancel_run(conversation_id, run_id)
await client.send_json({
"type": proto.S_RUN_FINISHED if ok else proto.S_ERROR,
**({"run_id": run_id, "status": "cancelled"}
if ok else {"error": {"code": proto.ERR_NOT_FOUND,
"message": "No active run"}}),
**({"request_id": request_id} if request_id else {}),
})
async def _handle_approval_respond(self, client, message, request_id):
approval_id = str(message.get("approval_id", ""))
choice = str(message.get("choice", ""))
reason = message.get("reason")
resolved = await self.bridge.resolve_approval(
approval_id, choice, str(reason) if reason else None)
if not resolved:
await client.send_json(proto.error_event(
proto.ERR_APPROVAL_NOT_FOUND,
"Unknown or already-resolved approval", request_id))
return
await client.send_json({
"type": proto.S_APPROVAL_RESOLVED,
"approval_id": approval_id,
"choice": choice,
**({"request_id": request_id} if request_id else {}),
})
async def _handle_clarify_respond(self, client, message, request_id):
clarify_id = str(message.get("clarify_id", ""))
response = message.get("response")
resolved = await self.bridge.resolve_clarify(
clarify_id, str(response) if response is not None else "")
if not resolved:
await client.send_json(proto.error_event(
proto.ERR_CLARIFY_NOT_FOUND,
"Unknown or already-resolved clarification", request_id))
return
await client.send_json({
"type": proto.S_CLARIFY_RESOLVED,
"clarify_id": clarify_id,
**({"request_id": request_id} if request_id else {}),
})
async def _handle_models_list(self, client, message, request_id):
snapshot = await self.bridge.models_snapshot()
snapshot["type"] = proto.S_MODELS_SNAPSHOT
if request_id:
snapshot["request_id"] = request_id
await client.send_json(snapshot)
async def _handle_model_current(self, client, message, request_id):
snapshot = await self.bridge.current_model_snapshot()
snapshot["type"] = proto.S_MODEL_CURRENT_SNAPSHOT
if request_id:
snapshot["request_id"] = request_id
await client.send_json(snapshot)
async def _handle_model_set(self, client, message, request_id):
model = str(message.get("model", "")).strip()
provider = message.get("provider")
conversation_id = message.get("conversation_id")
result = await self.bridge.set_model(
model, str(provider) if provider else None,
str(conversation_id) if conversation_id else None)
if not result.get("ok"):
await client.send_json(proto.error_event(
result.get("code", proto.ERR_BAD_REQUEST),
result.get("message", "Model change failed"), request_id))
return
event = {
"type": proto.S_MODEL_CHANGED,
"model": result.get("model"),
"provider": result.get("provider"),
"scope": result.get("scope", "global"),
**({"request_id": request_id} if request_id else {}),
}
await client.send_json(event)
await self.broadcast({k: v for k, v in event.items()
if k != "request_id"})
async def _handle_reasoning_current(self, client, message, request_id):
snapshot = await self.bridge.reasoning_snapshot()
snapshot["type"] = proto.S_REASONING_SNAPSHOT
if request_id:
snapshot["request_id"] = request_id
await client.send_json(snapshot)
async def _handle_reasoning_set(self, client, message, request_id):
effort = str(message.get("effort", "")).strip().lower()
conversation_id = message.get("conversation_id")
result = await self.bridge.set_reasoning(
effort, str(conversation_id) if conversation_id else None)
if not result.get("ok"):
await client.send_json(proto.error_event(
result.get("code", proto.ERR_BAD_REQUEST),
result.get("message", "Reasoning change failed"), request_id))
return
event = {
"type": proto.S_REASONING_CHANGED,
"effort": result.get("effort"),
"scope": result.get("scope", "global"),
**({"request_id": request_id} if request_id else {}),
}
await client.send_json(event)
await self.broadcast({k: v for k, v in event.items()
if k != "request_id"})
_HANDLERS = {
proto.C_PING: _handle_ping,
proto.C_CONVERSATION_LIST: _handle_conversation_list,
proto.C_CONVERSATION_OPEN: _handle_conversation_open,
proto.C_CONVERSATION_CREATE: _handle_conversation_create,
proto.C_CONVERSATION_RENAME: _handle_conversation_rename,
proto.C_CONVERSATION_DELETE: _handle_conversation_delete,
proto.C_CHAT_SEND: _handle_chat_send,
proto.C_RUN_CANCEL: _handle_run_cancel,
proto.C_APPROVAL_RESPOND: _handle_approval_respond,
proto.C_CLARIFY_RESPOND: _handle_clarify_respond,
proto.C_MODELS_LIST: _handle_models_list,
proto.C_MODEL_SET: _handle_model_set,
proto.C_MODEL_CURRENT: _handle_model_current,
proto.C_REASONING_SET: _handle_reasoning_set,
proto.C_REASONING_CURRENT: _handle_reasoning_current,
}
__all__ = ["PhebyServer"]