310 lines
12 KiB
Markdown
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.
|