Files
pheby-hermes-platform-adapter/docs/README.md
T
2026-09-02 13:59:00 -07:00

310 lines
12 KiB
Markdown

# 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, tool hooks → 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** — tool
approval, clarify, deliverables, streaming, cron delivery — because inbound
messages are ordinary `MessageEvent`s on a registered platform. The adapter
marks its authenticated WebSocket as the upstream authorization boundary,
so a valid Pheby secret is not rejected by Hermes's separate messaging-
platform allowlist layer.
- **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).
### 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.
- Authentication always depends on the shared secret. For lockout accounting,
`X-Forwarded-For` is accepted only when the direct peer is loopback (the
documented local-Caddy setup). A directly exposed non-loopback client cannot
spoof that header to evade rate limiting.
- 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 routing deletion uses a private Hermes detail.** Hermes's
SessionStore has no public per-routing-key delete. Pheby deletes the
authoritative transcript through `SessionDB.delete_session`, then removes
the exact routing entry under SessionStore's own lock/save discipline. It
does not call `reset_session` (which would recreate the deleted chat). This
is isolated in `hermes_bridge.py` but may need adjustment after a Hermes
SessionStore refactor.
4. **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).
5. **Reconnect recovery is state-based, not event-replay.** Re-opening a
conversation returns authoritative history plus unexpired attachments and
the current run, tool, approval, and clarification snapshot. The server
does not retain a replay log of every transient delta.
---
## 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
```
The 46 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.