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:
+309
@@ -0,0 +1,309 @@
|
||||
# Pheby — Hermes Platform Adapter/Plugin
|
||||
|
||||
A private, third-party **Hermes Agent platform plugin** that serves the
|
||||
**Pheby protocol** — a WebSocket + HTTPS API designed for a native Android
|
||||
client (Kotlin/Compose). It exposes multiple Hermes conversations, streamed
|
||||
chat, structured tool events, native approval/clarification round-trips,
|
||||
model + reasoning-effort selection, and agent-generated attachments with
|
||||
7-day retention.
|
||||
|
||||
No Hermes core modifications. Installs into Hermes's supported user plugin
|
||||
location and uses the documented platform-adapter extension points
|
||||
(same mechanism as the bundled Telegram/Discord/ntfy adapters).
|
||||
|
||||
- **Protocol spec (for the Kotlin client):** [`docs/PROTOCOL.md`](PROTOCOL.md)
|
||||
- **Original product spec:** [`docs/spec-source/`](spec-source/)
|
||||
|
||||
---
|
||||
|
||||
## Architecture (concise)
|
||||
|
||||
```
|
||||
Android (Kotlin/Compose)
|
||||
│ HTTPS + WSS (shared secret)
|
||||
▼
|
||||
Caddy ── TLS termination, reverse proxy
|
||||
│ plain HTTP/WS
|
||||
▼
|
||||
Pheby plugin (in Hermes gateway process)
|
||||
├─ aiohttp server: GET /ws, GET /attachments/{id}, GET /health
|
||||
├─ protocol layer: JSON message types, auth, limits, errors
|
||||
├─ PhebyAdapter (BasePlatformAdapter subclass)
|
||||
│ outbound: send/edit → chat events, format_tool_event → tool.event,
|
||||
│ send_clarify → clarify.request, send_document/… → attachments
|
||||
│ inbound: WS messages → MessageEvent → gateway pipeline
|
||||
└─ hermes_bridge: sessions, approvals (tools.approval),
|
||||
clarifications (tools.clarify_gateway), models, reasoning
|
||||
▼
|
||||
Hermes Gateway (authoritative): agent loop, sessions/state.db,
|
||||
tools, skills, cron, deliverables
|
||||
```
|
||||
|
||||
Key properties:
|
||||
|
||||
- **Hermes is authoritative.** Conversations are Hermes sessions keyed
|
||||
`agent:main:pheby:dm:<conversation_id>`; history/titles live in Hermes
|
||||
`state.db`. Pheby keeps only a thin, rebuildable name index.
|
||||
- **The full gateway pipeline works unchanged** — auth/pairing, tool
|
||||
approval, clarify, deliverables, streaming, cron delivery — because inbound
|
||||
messages are ordinary `MessageEvent`s on a registered platform.
|
||||
- **Single-user, shared-secret.** One `PHEBY_SECRET` gates every WS
|
||||
connection and attachment download (constant-time compare). No accounts,
|
||||
no registration — but IDs and message shapes are multi-client friendly.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Requirements: Hermes Agent with its bundled venv (aiohttp is already a Hermes
|
||||
dependency — nothing extra to install).
|
||||
|
||||
### 1. Copy the plugin into HERMES_HOME
|
||||
|
||||
```bash
|
||||
# HERMES_HOME is ~/.hermes by default (or your profile dir)
|
||||
mkdir -p ~/.hermes/plugins
|
||||
cp -r plugin/pheby ~/.hermes/plugins/pheby
|
||||
```
|
||||
|
||||
The plugin directory must contain `plugin.yaml`, `__init__.py`, and the
|
||||
`.py` modules.
|
||||
|
||||
### 2. Set the secret
|
||||
|
||||
Generate a strong secret and put it in Hermes's env file:
|
||||
|
||||
```bash
|
||||
echo "PHEBY_SECRET=$(openssl rand -hex 32)" >> ~/.hermes/.env
|
||||
chmod 600 ~/.hermes/.env
|
||||
```
|
||||
|
||||
### 3. Enable it in config.yaml
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
enabled:
|
||||
- pheby # user plugins are opt-in (untrusted code gate)
|
||||
|
||||
platforms:
|
||||
pheby:
|
||||
enabled: true
|
||||
extra:
|
||||
bind_host: "127.0.0.1" # default; safe behind local Caddy
|
||||
port: 8620
|
||||
attachment_storage_dir: "" # default: <HERMES_HOME>/pheby-attachments
|
||||
attachment_retention_days: 7
|
||||
debug: false # verbose protocol logging
|
||||
log_chat_content: false # debug logs include chat text (privacy)
|
||||
```
|
||||
|
||||
Env vars override YAML: `PHEBY_SECRET`, `PHEBY_BIND_HOST`, `PHEBY_PORT`,
|
||||
`PHEBY_DEBUG`, `PHEBY_LOG_CHAT_CONTENT`. Optional: `PHEBY_HOME_CHANNEL`
|
||||
(conversation ID receiving cron deliveries), `PHEBY_ALLOWED_USERS`,
|
||||
`PHEBY_ALLOW_ALL_USERS`.
|
||||
|
||||
### 4. Start Hermes with the gateway
|
||||
|
||||
```bash
|
||||
hermes gateway restart # or: hermes gateway run (foreground)
|
||||
hermes gateway status # should list pheby as connected
|
||||
```
|
||||
|
||||
Logs: `~/.hermes/logs/gateway.log` (look for `[pheby]` lines: startup,
|
||||
connections, auth failures, attachment registration, cleanup).
|
||||
|
||||
### Smoke check
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8620/health # → {"status":"ok"}
|
||||
# WS: connect to /ws, send hello with your secret → "ready"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caddy reverse proxy
|
||||
|
||||
Terminate TLS at Caddy; Pheby stays plain HTTP on localhost. WebSockets and
|
||||
attachment downloads need no special config beyond the reverse proxy itself
|
||||
(Caddy handles WebSocket upgrades transparently):
|
||||
|
||||
```caddy
|
||||
# Caddyfile
|
||||
pheby.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
# WebSocket + API
|
||||
reverse_proxy /ws 127.0.0.1:8620
|
||||
reverse_proxy /attachments 127.0.0.1:8620
|
||||
reverse_proxy /health 127.0.0.1:8620
|
||||
|
||||
# Optionally restrict by source when on a public VPS:
|
||||
# @notlan not remote_ip 10.0.0.0/8 192.168.0.0/16
|
||||
# respond @notlan 403
|
||||
}
|
||||
```
|
||||
|
||||
A single catch-all also works:
|
||||
|
||||
```caddy
|
||||
pheby.example.com {
|
||||
reverse_proxy 127.0.0.1:8620
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Caddy provides HTTPS + automatic certificates; the plugin never sees TLS.
|
||||
- `X-Forwarded-For` is not used for auth decisions (the lockout key is the
|
||||
direct peer address — behind Caddy that is Caddy itself, so lockout is
|
||||
effectively global; that is acceptable for a single-user deployment and
|
||||
still stops brute force).
|
||||
- The Android client connects to `wss://pheby.example.com/ws` and downloads
|
||||
attachments from `https://pheby.example.com/attachments/{id}` with
|
||||
`Authorization: Bearer <PHEBY_SECRET>`.
|
||||
|
||||
---
|
||||
|
||||
## How authentication works
|
||||
|
||||
- The client presents `PHEBY_SECRET` **once** in the `hello` WS frame, and on
|
||||
**every** attachment HTTP request (`Authorization: Bearer …`,
|
||||
`ApiKey …`, or `X-Pheby-Secret: …`).
|
||||
- Comparison is constant-time (`hmac.compare_digest`); failed hellos rate
|
||||
limit the source (5 failures / 60 s → lockout).
|
||||
- `/health` is the only unauthenticated route and leaks nothing.
|
||||
- The secret is **never** Hermes's API-server key — it is an independent
|
||||
credential stored in `~/.hermes/.env` (never in config.yaml history, never
|
||||
logged; debug logs redact it and it is excluded from protocol echo).
|
||||
- It is **not** a per-user identity: anyone with the secret *is* the user.
|
||||
Keep it secret; rotate by changing `.env` + restarting the gateway.
|
||||
|
||||
---
|
||||
|
||||
## Attachment delivery & expiration
|
||||
|
||||
1. The agent produces a file through Hermes's normal deliverable pipeline
|
||||
(`MEDIA:` tags → `validate_media_delivery_path` → adapter send hooks).
|
||||
2. The adapter **copies** the file into adapter-owned storage
|
||||
(`<HERMES_HOME>/pheby-attachments/`), assigns an opaque 32-hex ID, and
|
||||
records metadata (filename, MIME, size, kind, conversation, timestamps)
|
||||
in a persistent JSON index.
|
||||
3. The client gets `attachment.added` with a `download_path`; downloads are
|
||||
streamed over authenticated HTTPS. `inline_image` marks previewable
|
||||
images.
|
||||
4. An hourly sweep deletes expired blobs (default 7 days) — **only** files
|
||||
the store registered, inside its own root. Hermes-owned originals
|
||||
elsewhere on disk are never touched.
|
||||
5. Expired/unknown/malformed IDs all return the same minimal 404.
|
||||
Metadata persists across restarts, so valid attachments survive gateway
|
||||
restarts. Path traversal is impossible: IDs are validated, blobs are
|
||||
resolved from stored metadata inside the storage root, and every resolve
|
||||
re-checks containment.
|
||||
|
||||
---
|
||||
|
||||
## Debugging & logging
|
||||
|
||||
Set `PHEBY_DEBUG=true` (env) or `extra.debug: true` (YAML) for verbose
|
||||
protocol logs (inbound/outbound types, connection lifecycle, cleanup counts).
|
||||
Debug logging **never** prints secrets, provider keys, or Authorization
|
||||
headers, and by default masks chat text (`PHEBY_LOG_CHAT_CONTENT=true` to
|
||||
include text during client development — privacy tradeoff, off by default).
|
||||
|
||||
Tool **results** are deliberately not relayed (they can embed host paths);
|
||||
clients get structured status/duration. Full transcripts always remain
|
||||
available via `conversation.open` (Hermes's authoritative store).
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- Treat the endpoint as access to a powerful agent with host tool access:
|
||||
bind to `127.0.0.1`, front with TLS, use a 256-bit secret.
|
||||
- No client-controllable filesystem paths exist anywhere in the protocol.
|
||||
- Inbound WS frames are size-capped (2 MiB) and strictly JSON-validated;
|
||||
chat text is length-capped (64k chars).
|
||||
- Lockout + constant-time comparison + auth timeout blunt brute force.
|
||||
- Errors are machine-readable codes; stack traces and internal paths never
|
||||
reach the client.
|
||||
- CORS is irrelevant (native client); `/health` exposes only
|
||||
`{"status":"ok"}`.
|
||||
|
||||
---
|
||||
|
||||
## Hermes-version limitations (discovered, not assumed)
|
||||
|
||||
These are current Hermes behaviors the adapter documents rather than hacks
|
||||
around. None require core modifications; all are handled cleanly.
|
||||
|
||||
1. **Session model change is not instant mid-run.** `model.set` /
|
||||
`reasoning.set` write the session override (or global config); the next
|
||||
turn picks it up. An in-flight run finishes on its current model.
|
||||
2. **Reasoning-effort capability metadata is partial.** Hermes knows *that*
|
||||
a model supports reasoning (models.dev) but has no per-provider enum of
|
||||
valid effort levels. Pheby exposes Hermes's canonical level set
|
||||
(`none, minimal, low, medium, high, xhigh, max, ultra`) and documents that
|
||||
an unsupported level surfaces as a provider error on the next turn.
|
||||
3. **Conversation delete is a documented approximation.** Hermes's
|
||||
SessionStore has no public per-routing-key delete; Pheby deletes the
|
||||
authoritative transcript row (`SessionDB.delete_session`) and resets the
|
||||
routing entry, which yields the same user-visible behavior.
|
||||
4. **Tool `running` → `completed` ID correlation.** Start events get
|
||||
adapter-generated IDs; completion events carry Hermes's authoritative
|
||||
`tool_call_id` from the `post_tool_call` hook. The protocol documents
|
||||
correlating by `(tool_name, conversation)` when IDs differ. (Gateway
|
||||
tool-start events don't carry Hermes's call ID yet.)
|
||||
5. **Standalone cron delivery.** Cron jobs targeting `pheby` are delivered
|
||||
in-process with the gateway. A `standalone_sender_fn` hook exists but
|
||||
cannot push to a WS server it isn't hosting; out-of-process cron delivery
|
||||
to Pheby is not supported (documented, fail-loud).
|
||||
6. **Reconnect recovery is state-based, not event-replay.** Missed events are
|
||||
recovered by re-opening conversations (authoritative history), not by
|
||||
replaying a server-side event log. This is the spec's preferred approach
|
||||
and keeps the protocol simple.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && .venv/bin/pip install pytest pytest-asyncio aiohttp pyyaml requests
|
||||
.venv/bin/python -m pytest tests/ -o addopts= -q --asyncio-mode=auto
|
||||
```
|
||||
|
||||
Tests are gateway-free (fakes; real Hermes primitives exercised in-process
|
||||
where safe — no LLM calls). Two live smoke tests bind an ephemeral localhost
|
||||
port.
|
||||
|
||||
```
|
||||
repo layout
|
||||
├── plugin/pheby/ # the plugin (install this dir)
|
||||
│ ├── __init__.py # register(ctx): platform + hook registration
|
||||
│ ├── plugin.yaml # manifest (kind: platform)
|
||||
│ ├── adapter.py # BasePlatformAdapter subclass
|
||||
│ ├── server.py # aiohttp app + WS dispatch
|
||||
│ ├── ws_client.py # per-connection state
|
||||
│ ├── hermes_bridge.py # ALL Hermes-internal integration (defensive)
|
||||
│ ├── conversations.py # conversation ID ↔ session routing
|
||||
│ ├── attachments.py # attachment store + cleanup + auth helpers
|
||||
│ ├── protocol.py # message types, errors, limits
|
||||
│ └── config.py # env/YAML config resolution
|
||||
├── tests/
|
||||
└── docs/ # PROTOCOL.md, spec-source/
|
||||
```
|
||||
|
||||
## Client implementation checklist (Kotlin/Compose)
|
||||
|
||||
Minimum client surface:
|
||||
|
||||
- WS connect + `hello` (handle `ready` / error / close 4401) + ping/pong.
|
||||
- `conversation.list/open/create/rename/delete` + `chat.send`.
|
||||
- Render state machine: `run.accepted`, `message.start`,
|
||||
`message.delta` (replace bubble text), `message.complete`,
|
||||
`run.finished`.
|
||||
- `tool.event` components keyed by `tool_call_id`.
|
||||
- `approval.request` → Approve/Deny buttons → `approval.respond`.
|
||||
- `clarify.request` → choice buttons + free text → `clarify.respond`.
|
||||
- `attachment.added` → inline preview when `inline_image`, else download
|
||||
(authenticated GET) → `run.cancel` for the stop button.
|
||||
- `models.list/current/set`, `reasoning.current/set`.
|
||||
- Reconnect: backoff, re-hello, re-open visible conversations.
|
||||
Reference in New Issue
Block a user