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
+12
View File
@@ -0,0 +1,12 @@
# Pheby plugin environment template — copy real values into ~/.hermes/.env
# NEVER commit the real .env.
# Required: shared credential for every WS connection / attachment download
PHEBY_SECRET=change-me-openssl-rand-hex-32
# Optional
#PHEBY_BIND_HOST=127.0.0.1
#PHEBY_PORT=8620
#PHEBY_DEBUG=false
#PHEBY_LOG_CHAT_CONTENT=false
#PHEBY_HOME_CHANNEL=<conversation-id-for-cron-delivery>
+24
View File
@@ -0,0 +1,24 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Environment / secrets — NEVER commit
.env
*.env
!.env.example
# Attachment storage / runtime state (never lives in the repo)
pheby-attachments/
pheby_conversations.json
# Editors / OS
.idea/
.vscode/
.DS_Store
*.swp
+17
View File
@@ -0,0 +1,17 @@
# Root README redirects to the docs; kept short so the repo landing page
# links straight to the important files.
# Pheby — Hermes Platform Adapter/Plugin
Private Hermes Agent platform plugin serving the Pheby WebSocket/HTTPS
protocol for a native Android client.
- **Start here:** [docs/README.md](docs/README.md) — architecture, install, Caddy, security, limitations
- **Protocol spec:** [docs/PROTOCOL.md](docs/PROTOCOL.md) — every message type with JSON examples (Kotlin client reference)
- **Original product spec:** [docs/spec-source/](docs/spec-source/)
Install: copy `plugin/pheby/` into `~/.hermes/plugins/`, set `PHEBY_SECRET`
in `~/.hermes/.env`, enable in config.yaml, restart the gateway. See
[docs/README.md](docs/README.md).
Tests: `.venv/bin/python -m pytest tests/ -o addopts= -q --asyncio-mode=auto`
+408
View File
@@ -0,0 +1,408 @@
# Pheby Protocol v1 — Specification
JSON messages over WebSocket, plus one authenticated HTTPS endpoint for
attachment downloads. Every message (both directions) carries a `"type"`.
Client→server requests MAY carry a `"request_id"` (any client-chosen string);
the direct reply echoes it. Server→client events are broadcast to all
authenticated connections (single-user app — typically one client).
Timestamps (`"ts"`) are ISO-8601 UTC. All IDs are opaque strings — the client
never constructs meaning from them and never sees server filesystem paths.
## Handshake
Connect to `wss://<host>/ws` (behind Caddy; the plugin itself is plain
`ws://127.0.0.1:8620/ws`). The **first** client frame must be `hello` within
10 seconds, or the server closes the socket (`auth_timeout`).
### Client → Server `hello`
```json
{
"type": "hello",
"secret": "<PHEBY_SECRET>",
"protocol_version": 1,
"request_id": "optional"
}
```
### Server → Client `ready` (success)
```json
{ "type": "ready", "protocol_version": 1, "server": "pheby",
"ts": "2026-09-02T18:00:00+00:00" }
```
Failure: the server replies with an `error` event (`unauthorized`,
`auth_timeout`, or `version_mismatch`) and closes. After 5 failed hellos from
one source address within 60s, further connections are refused (lockout).
### Heartbeat
```json
→ { "type": "ping" }
← { "type": "pong", "ts": "..." }
```
The server also sends WebSocket protocol-level pings (aiohttp `heartbeat=30`).
## Errors
Every error uses one shape with a machine-readable code:
```json
{ "type": "error", "request_id": "r1",
"error": { "code": "conversation_not_found", "message": "Conversation not found" },
"ts": "..." }
```
Codes: `unauthorized`, `auth_timeout`, `version_mismatch`, `bad_request`,
`invalid_json`, `unknown_type`, `not_found`, `conversation_not_found`,
`approval_not_found`, `clarify_not_found`, `too_large`, `rate_limited`,
`internal_error`, `not_implemented`.
Limits: chat text ≤ 64,000 chars; inbound WS frame ≤ 2 MiB (violations get
`too_large`); conversation history fetch ≤ 500 messages.
---
## Conversations
### `conversation.list`
```json
→ { "type": "conversation.list", "request_id": "r1" }
← { "type": "conversation.snapshot", "request_id": "r1",
"conversations": [
{ "conversation_id": "9f1c…", "name": "Project X",
"session_id": "20260902_101112_ab12cd34", // Hermes session (may be null)
"last_active": "2026-09-02T17:44:01+00:00", // may be null
"source": "hermes" } ] }
```
### `conversation.create`
```json
→ { "type": "conversation.create", "name": "New chat", "request_id": "r2" }
← { "type": "conversation.created", "conversation_id": "a1b2…", "name": "New chat", "request_id": "r2" }
// plus, broadcast to all clients:
← { "type": "conversation.updated", "conversation_id": "a1b2…", "name": "New chat" }
```
`name` optional. Conversation IDs are server-generated 32-hex opaque strings.
### `conversation.open` — load history (reconnect recovery)
```json
→ { "type": "conversation.open", "conversation_id": "a1b2…", "limit": 200, "request_id": "r3" }
← { "type": "conversation.history", "conversation_id": "a1b2…", "request_id": "r3",
"messages": [
{ "message_id": "m12", "role": "user", "text": "hey", "ts": "…|null" },
{ "message_id": "m13", "role": "assistant", "text": "hi!", "ts": "…|null" } ] }
```
History is the authoritative Hermes transcript (`role` is always `user` or
`assistant`). On reconnect, re-open the last-open conversations and resume —
no client-side message cache is needed for correctness. Unknown conversation
→ `conversation_not_found` error.
### `conversation.rename`
```json
→ { "type": "conversation.rename", "conversation_id": "a1b2…", "name": "Renamed", "request_id": "r4" }
← { "type": "conversation.renamed", "conversation_id": "a1b2…", "name": "Renamed", "request_id": "r4" }
← { "type": "conversation.renamed", "conversation_id": "a1b2…", "name": "Renamed" } // broadcast
```
### `conversation.delete`
```json
→ { "type": "conversation.delete", "conversation_id": "a1b2…", "request_id": "r5" }
← { "type": "conversation.deleted", "conversation_id": "a1b2…", "request_id": "r5" }
```
Deletes the Hermes session transcript and the routing entry. Attachments
belonging to the conversation age out on their own 7-day schedule.
**Not supported by design:** message editing, per-message deletion,
regeneration, edit-and-resend. If you need to "undo", send a correction
message (the agent sees the whole transcript).
---
## Chat & streaming
### `chat.send`
```json
→ { "type": "chat.send", "conversation_id": "a1b2…", "text": "What's the weather?", "request_id": "r6" }
```
### Server → Client run lifecycle
```json
← { "type": "run.accepted", "conversation_id": "a1b2…", "run_id": "8c1f…", "request_id": "r6" }
← { "type": "message.start", "conversation_id": "a1b2…", "run_id": "8c1f…", "message_id": "draft-8c1f…" }
```
While the agent streams, the server pushes **cumulative** draft text (the
client can simply replace the bubble's text each time — no delta stitching):
```json
← { "type": "message.delta", "conversation_id": "a1b2…", "message_id": "draft-8c1f…",
"text": "It's currently 27°C…", "ts": "..." }
```
Completion (final text supersedes the draft — render the final, drop the
draft):
```json
← { "type": "message.complete", "conversation_id": "a1b2…",
"message_id": "draft-8c1f…", "text": "…full final answer…", "ts": "..." }
```
`message.complete` with `"kind": "notice"` is a gateway lifecycle/status
notice rather than conversation content — render or ignore.
Run end:
```json
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…",
"status": "completed" | "cancelled" | "failed" | "idle",
"error": "only on failure" }
```
State machine per assistant turn:
`run.accepted → message.start → (message.delta)* → message.complete → run.finished`.
A turn with no streaming skips `message.start`/`message.delta`. Never infer
state from text — use these events.
### `run.cancel` — stop an active run
```json
→ { "type": "run.cancel", "conversation_id": "a1b2…", "run_id": "8c1f…", "request_id": "r7" }
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…", "status": "cancelled" }
```
Cancellation uses Hermes's supported interrupt mechanism (agent interrupt +
run-generation invalidation) — the conversation stays consistent and
resumable. Cancelling with no active run returns `run.finished`
`status:"idle"`.
---
## Tool events (structured — never chat text)
Tool activity arrives as `tool.event` messages, completely separate from
`message.*` chat content. The client renders them as compact tool components
(ChatGPT-style) attached to the assistant turn.
```json
{ "type": "tool.event",
"conversation_id": "a1b2…",
"tool_call_id": "t-1a2b3c4d5e6f",
"tool_name": "web_search",
"status": "running" | "completed" | "failed",
"description": "cats — short preview from the agent (may be null)",
"args_redacted": { "query": "cats" }, // only on "running"; secret-looking keys redacted
"duration_ms": 1234, // only on completion/failure (may be null)
"error": "only on failed, truncated", // only on "failed"
"ts": "..." }
```
Correlate `running` → `completed`/`failed` by `tool_call_id`. Note: the
running event's ID comes from the adapter and the completion event from
Hermes's `post_tool_call` hook; when they differ, correlate by
`(tool_name, conversation)` as a fallback and prefer the completion event's
ID going forward.
No fake "Searching the web…" text is ever injected into `message.*` events.
---
## Approvals
When Hermes pauses for a human decision on a dangerous action:
```json
{ "type": "approval.request",
"approval_id": "3d4e5f6070a1",
"session_key": "agent:main:pheby:dm:a1b2…",
"command": "rm -rf /tmp/build-output",
"description": "Destructive shell command (rm -rf)",
"choices": ["once", "session", "always", "deny"],
"ts": "..." }
```
Respond:
```json
→ { "type": "approval.respond", "approval_id": "3d4e5f6070a1",
"choice": "once" | "session" | "always" | "deny",
"reason": "optional free text with deny", "request_id": "r8" }
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "request_id": "r8" }
// broadcast confirmation (also informs other tabs):
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "accepted": true }
```
Choices map to Hermes semantics: `once` (approve this action), `session`
(approve pattern for this conversation), `always` (also persist), `deny`
(decline; the agent is told NOT to retry). Unknown/stale ID →
`approval_not_found` error. Hermes itself fails the approval closed after its
own timeout, so a silently-closed socket can't leave a zombie gate.
---
## Clarifications / choices
```json
{ "type": "clarify.request",
"clarify_id": "c1a2b3d4e5",
"session_key": "agent:main:pheby:dm:a1b2…",
"question": "Deploy to staging or production?",
"choices": ["staging", "production"], // null ⇒ free text only
"allow_free_text": true,
"ts": "..." }
```
Respond (either a choice value or free text):
```json
→ { "type": "clarify.respond", "clarify_id": "c1a2b3d4e5", "response": "production", "request_id": "r9" }
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "request_id": "r9" }
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "accepted": true } // broadcast
```
`accepted:false` on the broadcast means Hermes had already resolved/timed out
the prompt. Always render an "Other" affordance — Hermes clarifications
accept free text.
---
## Attachments (agent → client deliverables)
When the agent produces a file (image, document, audio, video… via Hermes's
normal `MEDIA:` deliverable pipeline), the server copies it into
adapter-managed storage and broadcasts:
```json
{ "type": "attachment.added",
"conversation_id": "a1b2…",
"attachment": {
"attachment_id": "e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 48213,
"kind": "image" | "voice" | "video" | "audio" | "document",
"inline_image": false,
"conversation_id": "a1b2…",
"message_id": null,
"created_at": "2026-09-02T18:30:00+00:00",
"expires_at": "2026-09-09T18:30:00+00:00", // null when retention=0
"download_path": "/attachments/e5f6…" },
"ts": "..." }
```
Download over **HTTPS** (authenticated — same `PHEBY_SECRET`):
```
GET {download_path}
Authorization: Bearer <PHEBY_SECRET> (or ApiKey <secret>, or X-Pheby-Secret: <secret>)
```
* `inline_image: true` → `kind == "image"`, safe for an inline preview
(`BitmapFactory` / `AsyncImage` with the same authenticated GET).
* Everything else: download and open as an Android document.
* Unknown / expired / malformed ID → `404` with
`{"error":{"code":"not_found","message":"Attachment unavailable"}}` —
no implementation details.
* **The client can never request arbitrary files** — only registered
attachment IDs resolve.
* **Retention:** adapter copies expire after 7 days (configurable) and are
deleted by an hourly cleanup. Original files the agent produced elsewhere
on the host are never touched. `expires_at` tells the client when to stop
offering the download.
---
## Models
### List providers + models
```json
→ { "type": "models.list", "request_id": "r10" }
← { "type": "models.snapshot", "request_id": "r10",
"providers": [
{ "slug": "openrouter", "name": "OpenRouter", "is_current": true,
"models": ["z-ai/glm-5.3-flash", "anthropic/claude-sonnet-4", "…"],
"total_models": 42 } ],
"current_model": "z-ai/glm-5.3-flash",
"current_provider": "openrouter",
"supported_reasoning_efforts": ["minimal","low","medium","high","xhigh","max","ultra"],
"ts": "..." }
```
Lists come from Hermes's own credential-aware picker data — nothing is
hardcoded. Models are exactly what the configured providers expose.
### Read / change current model
```json
→ { "type": "models.current", "request_id": "r11" }
← { "type": "model.current", "request_id": "r11", "model": "z-ai/glm-5.3-flash", "provider": "openrouter", "ts": "..." }
→ { "type": "model.set", "model": "anthropic/claude-sonnet-4",
"provider": "anthropic", // optional
"conversation_id": "a1b2…", // present ⇒ session-scoped override
"request_id": "r12" }
← { "type": "model.changed", "model": "anthropic/claude-sonnet-4",
"provider": "anthropic", "scope": "conversation" | "global", "request_id": "r12" }
// plus broadcast of model.changed (without request_id) to all clients
```
Omit `conversation_id` ⇒ the change is persisted globally (Hermes
`model.default`). A `model.changed` with `scope:"global"` tells every open
conversation the default moved.
---
## Reasoning effort
```json
→ { "type": "reasoning.current", "request_id": "r13" }
← { "type": "reasoning.snapshot", "request_id": "r13",
"effort": "medium", // current effective effort (may be null = provider default)
"enabled": true, // false ⇒ thinking disabled
"supported_efforts": ["none","minimal","low","medium","high","xhigh","max","ultra"],
"ts": "..." }
→ { "type": "reasoning.set", "effort": "high", "conversation_id": "a1b2…", "request_id": "r14" }
← { "type": "reasoning.changed", "effort": "high", "scope": "conversation", "request_id": "r14" }
```
`effort: "none"` disables thinking. Invalid values → `bad_request`. Scope
rules mirror `model.set` (with `conversation_id` ⇒ session override; without ⇒
global `agent.reasoning_effort`). **Capability note:** Hermes knows *whether*
a model supports reasoning (models.dev metadata) but does not expose a
per-provider enum of valid effort values; the listed levels are Hermes's
canonical set — unsupported levels on a given provider surface as a provider
error on the next turn, not at set time. This is a documented Hermes
limitation, not a Pheby guess.
---
## Unsolicited messages & reconnect behavior
The WebSocket stays connected; any Hermes-originated output destined for the
Pheby platform (scheduled/cron deliveries, background completions,
notifications) is pushed as normal `message.*` / `run.*` events even when it
is not a reply to your last request. Reconnection procedure for clients:
1. Reconnect WS, redo `hello`.
2. Re-`conversation.open` the conversations you show; replace local state
with `conversation.history` (authoritative).
3. Re-`models.current` / `reasoning.current` if those views are visible.
4. Live events continue from there.
No external push service exists (no FCM); Android notification behavior is
the client's responsibility while the socket is down.
+309
View File
@@ -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.
@@ -0,0 +1,274 @@
I want you to build a private custom Hermes Agent platform adapter/plugin called Pheby.
Before implementing anything, inspect the current Hermes Agent documentation and current Hermes source code, especially its platform adapter/plugin APIs, Gateway architecture, Telegram/Discord adapters, conversation/session handling, tool-call events, clarification/approval mechanisms, model selection, reasoning effort, generated-file/deliverable handling, and plugin loading system. Do not rely on assumptions from older Hermes versions. The implementation must target the currently installed/current upstream Hermes architecture.
The Pheby adapter will serve a native Android application written in Kotlin with Jetpack Compose. For this task, build only the Hermes-side plugin and its protocol/API. Do not build the Android application.
The plugin must be a clean third-party Hermes plugin/platform adapter. Do not modify Hermes core files, monkey-patch Hermes, fork Hermes, or rely on hacks that will make Hermes upgrades difficult. It should install into Hermes's supported user plugin location and use documented extension points.
Overall architecture
Pheby is a single-user private platform.
The adapter should expose a network service intended to sit behind Caddy and be reachable over HTTPS/WSS from the public internet.
Use:
• WebSocket for realtime bidirectional chat/events.
• Normal HTTPS endpoints for downloading generated attachments.
• A shared secret/API-key-style credential for authentication.
• Hermes/Gateway functionality underneath rather than reimplementing the agent loop.
Assume Caddy handles TLS. The Pheby service itself can listen on localhost/plain HTTP and WebSocket.
Do not expose or reuse Hermes's master API-server key as the Pheby client credential.
The Pheby credential should be configurable through an environment variable and every WebSocket connection and HTTP attachment request must require authentication.
This is a single-user application. Do not build account registration, users, roles, password recovery, OAuth, etc. However, avoid unnecessarily designing the protocol in a way that makes future multi-client support impossible.
Conversations
Pheby must expose multiple Hermes conversations.
The Android client needs to be able to:
• List conversations.
• Open/load a conversation and its message history.
• Create a new conversation.
• Rename a conversation.
• Delete a conversation.
Hermes should remain the authoritative source of conversation/session state.
The protocol should provide stable conversation IDs.
When Pheby reconnects, it must be able to retrieve existing conversations and their messages rather than treating the connection as a new chat.
Do not implement message editing, individual message deletion, regeneration, or edit-and-resend.
Sending messages and streaming responses
The client must be able to send a text message into a selected conversation.
Use whichever response-streaming mechanism integrates most cleanly with Hermes. Token-level streaming is not a requirement. Chunk-level streaming is perfectly acceptable if it substantially simplifies the adapter.
The protocol should distinguish between:
• A new assistant message.
• Incremental content being appended to an assistant message.
• A completed assistant message.
• A failed/cancelled assistant run.
The UI should never have to infer these states by parsing arbitrary text.
The client must be able to stop/cancel an active Hermes task/run.
Stopping should use Hermes's supported cancellation/interruption mechanism rather than killing threads/processes or corrupting the conversation.
Tool calls
Tool execution must be represented as structured events, separate from the normal textual chat content.
This is important.
Pheby will display tool activity similarly to ChatGPT: tool calls can appear as compact UI components associated with an assistant turn without cluttering the actual chat transcript.
Do not inject fake text such as:
"Searching the web..." "Running command..." "Tool completed..."
into the assistant's message merely to represent activity.
Expose structured information for tool activity where Hermes makes it available, including at minimum:
• Unique tool-call/event ID.
• Conversation/run association.
• Tool name/type.
• Human-readable description if available.
• Start/running state.
• Completion state.
• Failure state.
• Result/summary information that is safe and appropriate to expose.
Preserve enough raw structured information that the Android client can create richer custom UI later, but do not expose secrets or internal credentials.
If Hermes provides nested/sub-agent/tool execution events, preserve their relationship where practical.
Approvals
Pheby must support Hermes's human approval system.
When Hermes pauses because an action requires approval, send a structured WebSocket event to Pheby containing enough information to render native:
Approve Deny
controls.
The event should include:
• Approval/request ID.
• Conversation/run association.
• Human-readable description.
• Relevant action/tool information.
• Any choices/actions Hermes permits.
Pheby must be able to submit an approval or denial and have the existing Hermes run continue appropriately.
Do not simulate approval through ordinary user chat messages if Hermes exposes a proper approval mechanism.
Clarifications / choices
Pheby must also support Hermes's interactive clarification/choice functionality.
If Hermes asks the user to choose between several structured options, expose the request as structured data over WebSocket so the Android app can render native buttons/options.
Provide:
• Clarification ID.
• Prompt/question.
• Available choices.
• Choice IDs/values.
• Whether free-text input is permitted, if Hermes supports that distinction.
• Conversation/run association.
The client must be able to submit the selected choice or clarification response so Hermes can continue the same pending operation.
Use Hermes's existing clarification/elicitation mechanism rather than inventing a parallel agent workflow.
Generated attachments / Deliverable Mode
This is a critical requirement.
Hermes must be able to generate files and send them to Pheby in the same general spirit as Telegram/Discord deliverables.
Pheby does not need to upload arbitrary files to Hermes.
Agent → Pheby attachments are required.
Reuse Hermes's existing Deliverable Mode/media detection and gateway abstractions wherever possible rather than recreating filename parsing from scratch.
Support arbitrary document/file types that Hermes's deliverable system supports.
At minimum represent:
• Attachment ID.
• Filename.
• MIME type.
• File size.
• Download endpoint/identifier.
• Associated conversation/message.
• Whether it is suitable for inline image preview.
Images should be identifiable as images so Pheby can display an inline preview.
Other files should be downloadable/openable as normal Android documents.
Do NOT expose arbitrary server filesystem paths to the client.
Do NOT provide an API that lets the client request arbitrary filesystem files.
Map generated files to opaque attachment IDs and serve only explicitly registered Hermes deliverables.
Attachment downloads must require the Pheby authentication credential.
Use normal HTTP streaming/downloads rather than transferring large files through the WebSocket.
Attachment cleanup
Generated Pheby attachment files should expire after 7 days.
Implement safe cleanup of expired adapter-managed attachments.
Cleanup must never delete unrelated Hermes/user files.
If Hermes's deliverable mechanism points to files Hermes owns elsewhere, do not blindly delete their originals. Prefer an adapter-managed attachment storage/copy/link strategy where the ownership and lifecycle are unambiguous.
Persist enough attachment metadata that restarting Hermes/Pheby does not immediately make all valid attachments inaccessible.
Expired attachment IDs should cleanly return an appropriate HTTP error rather than exposing implementation details.
Models
The Android client needs to query and change the active Hermes model.
Expose the models/providers Hermes currently makes available through its supported configuration/API.
Do not hardcode model names.
The protocol should expose useful model metadata when Hermes provides it.
The selected model must be changeable through Pheby using Hermes's supported mechanisms.
Reasoning effort
Pheby must expose and allow changing the model's reasoning effort where supported by the active provider/model.
Do not assume every model supports the same reasoning settings.
Expose supported values/capabilities dynamically where Hermes makes that information available.
If Hermes does not currently provide perfect capability metadata, implement the cleanest documented approach and clearly document the limitation instead of hardcoding fragile assumptions.
Skills and toolsets
Pheby does not need to enable, disable, configure, or manage Hermes skills/toolsets.
Normal Hermes tools and configured skills must continue functioning through the agent, but no management UI API is required.
Voice and user file uploads
Do not implement:
• Voice messages.
• Speech-to-text.
• Text-to-speech.
• Arbitrary user file uploads.
Design the protocol cleanly enough that additional event/content types could be added later without breaking existing clients, but don't spend significant implementation effort on features outside this scope.
Realtime / unsolicited messages
The WebSocket is intended to remain connected while Pheby is running.
The adapter must be able to push messages/events originating from Hermes even when they were not an immediate response to the most recent Pheby request. For example, if Hermes Gateway produces a scheduled/automated message destined for the Pheby platform, the connected client should receive it.
Do not implement Firebase Cloud Messaging or another external push-notification service.
Android notification behavior is the client's responsibility.
Make reconnection resilient. The protocol should make it possible for the client to recover messages/events it missed during a temporary WebSocket disconnect, preferably by re-syncing authoritative conversation state rather than requiring a perfectly uninterrupted socket.
Protocol
Design a small, explicit and versioned Pheby protocol.
Prefer JSON messages over WebSocket.
Every event should contain a clear type and whatever IDs are needed to associate it with:
• Conversation.
• Message.
• Run.
• Tool call.
• Approval.
• Clarification.
• Attachment.
Avoid making the Android client parse prose to determine application state.
Use stable opaque IDs.
Include an initial handshake/protocol-version exchange if useful.
Provide clean error responses/events with machine-readable error codes and human-readable messages.
Consider heartbeat/ping handling and clean reconnect behavior.
Do not build unnecessary complexity such as a custom binary protocol.
Document every client → server and server → client message type with JSON examples.
The protocol specification is part of the deliverable.
Configuration
Use Hermes-compatible configuration conventions.
Secrets should use environment variables.
Non-secret behavioral configuration may use Hermes/plugin YAML configuration where appropriate.
At minimum configuration should cover:
• Enabled/disabled.
• Bind host.
• Port.
• Authentication secret.
• Attachment storage location if configurable.
• Attachment retention period, defaulting to 7 days.
• Verbose/debug protocol logging.
The default bind host should be safe for use behind a local Caddy reverse proxy rather than listening publicly on every interface without explicit configuration.
Logging
Provide normal useful logging for:
• Startup/shutdown.
• Connections/disconnections.
• Authentication failures.
• Conversation routing errors.
• Hermes agent errors.
• Attachment handling.
• Cleanup.
• Approval/clarification lifecycle errors.
Include a configurable verbose/debug logging mode useful while developing the Android client.
Debug logging must still avoid printing:
• Authentication secrets.
• Provider API keys.
• Sensitive authorization headers.
Be thoughtful about whether raw chat content or tool results should appear in verbose logs; default to protecting user content unless explicitly configured otherwise.
Security requirements
Treat this adapter as access to a powerful personal Hermes agent capable of using tools on the host.
Do not trust client-provided filesystem paths.
Do not expose arbitrary filesystem downloads.
Prevent path traversal.
Authenticate all non-health-check functionality.
Use constant-time secret comparison where appropriate.
Validate message sizes and input structure.
Apply reasonable limits to avoid trivially exhausting server memory with malformed WebSocket messages.
Do not leak stack traces/internal paths to remote clients.
Design it to be safely reverse-proxied through Caddy over HTTPS/WSS.
CORS is not important because the intended client is native Android.
If HTTP health-check endpoints are included, they should expose minimal information.
Plugin quality
Keep the code maintainable and idiomatic for the existing Hermes codebase.
Reuse Hermes abstractions instead of copying large portions of gateway logic.
Use async code consistently with Hermes where appropriate.
Keep networking, Hermes adapter integration, attachment management, authentication, and protocol serialization separated enough to be understandable and testable.
Add type hints.
Add useful docstrings/comments where behavior is non-obvious.
Avoid unnecessary dependencies. Prefer libraries Hermes already depends on when practical.
Tests
Include tests for important behavior, particularly:
• Authentication success/failure.
• Conversation operations.
• Protocol serialization/parsing.
• Attachment registration and secure download.
• Rejection of arbitrary/path-traversal file access.
• Seven-day attachment expiration/cleanup.
• Tool-call event translation.
• Approval round trip.
• Clarification/choice round trip.
• Cancellation of active runs.
• WebSocket reconnect/recovery behavior where practical.
Mock the Hermes agent/gateway where appropriate rather than making tests consume paid LLM API calls.
Documentation
Create documentation explaining:
• Installation into Hermes.
• Required environment variables.
• Optional configuration.
• Starting Hermes with Pheby enabled.
• Example Caddy reverse-proxy configuration supporting WebSockets and attachment downloads.
• How authentication works.
• Pheby protocol specification.
• Conversation operations.
• Chat/message flow.
• Tool events.
• Approvals.
• Clarifications.
• Cancellation.
• Model selection.
• Reasoning effort.
• Attachment delivery.
• Attachment expiration.
• Reconnection behavior.
• Debugging/logging.
• Security considerations.
Include enough examples that I can implement the Kotlin/Compose client from the protocol documentation later.
Implementation process
Before writing the implementation, inspect the actual current Hermes abstractions and identify how the existing first-party platform adapters accomplish:
• registration,
• inbound messages,
• outbound messages,
• conversations/sessions,
• tool progress,
• deliverables,
• approvals,
• clarification prompts,
• cancellation,
• unsolicited Gateway messages.
Prefer adapting those existing mechanisms over designing parallel ones.
If one of my requirements cannot be implemented cleanly through Hermes's public plugin/adapter APIs, do not modify Hermes core to force it.
Instead:
1. Explain exactly what Hermes currently exposes.
2. Explain what limitation prevents the requested behavior.
3. Implement the cleanest supported approximation if one exists.
4. Keep the protocol extensible so the proper feature can be added later if Hermes exposes it.
Do not silently omit required functionality.
At the end, give me:
• The complete plugin source.
• Installation/configuration files.
• Tests.
• Protocol documentation.
• Caddy example.
• A concise explanation of the architecture.
• Any Hermes-version-specific limitations you discovered.
• A list of the key protocol events/endpoints that the eventual Kotlin client needs to implement.
The resulting Pheby adapter should require no Hermes core modifications and should survive normal Hermes upgrades as well as can reasonably be expected from its documented plugin API.
+81
View File
@@ -0,0 +1,81 @@
"""Pheby — Hermes Agent platform adapter/plugin for the Pheby Android client.
A third-party Hermes platform plugin. Install this package directory as
``~/.hermes/plugins/pheby/`` (HERMES_HOME/plugins/pheby), enable it in
config.yaml (``plugins.enabled: [pheby]``, ``platforms.pheby.enabled: true``),
set ``PHEBY_SECRET`` in ``~/.hermes/.env``, and restart the gateway.
"""
from __future__ import annotations
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
__version__ = "1.0.0"
def register(ctx: Any) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup."""
from .adapter import PhebyAdapter
from .config import (
check_requirements,
env_enablement,
is_connected,
validate_config,
)
adapter_holder: dict = {"adapter": None}
def _factory(cfg: Any) -> PhebyAdapter:
adapter = PhebyAdapter(cfg)
adapter_holder["adapter"] = adapter
return adapter
ctx.register_platform(
name="pheby",
label="Pheby",
adapter_factory=_factory,
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["PHEBY_SECRET"],
install_hint="pip install aiohttp # already a Hermes dependency; "
"set PHEBY_SECRET in ~/.hermes/.env",
env_enablement_fn=env_enablement,
# Home channel for cron / notification delivery when configured.
cron_deliver_env_var="PHEBY_HOME_CHANNEL",
allowed_users_env="PHEBY_ALLOWED_USERS",
allow_all_env="PHEBY_ALLOW_ALL_USERS",
emoji="🐱",
pii_safe=True, # single-user private platform; no PII in routing IDs
allow_update_command=True,
platform_hint=(
"You are communicating with the user via Pheby, a private "
"native Android client over WebSocket. Respond in normal "
"markdown; the client renders it natively. Attachments you "
"produce via MEDIA: tags are delivered as downloadable files "
"and inline image previews."
),
)
# post_tool_call observer → structured tool-result events. Registered
# against the plugin context so it loads with the plugin, before any
# adapter is constructed (the hook is a no-op until the adapter serves).
def _post_tool_call(**kwargs: Any) -> None:
adapter = adapter_holder.get("adapter")
if adapter is not None:
adapter.on_post_tool_call(**kwargs)
try:
ctx.register_hook("post_tool_call", _post_tool_call)
except Exception:
logger.debug("[pheby] post_tool_call hook registration failed",
exc_info=True)
logger.info("[pheby] plugin registered (platform 'pheby')")
__all__ = ["register", "__version__"]
+485
View File
@@ -0,0 +1,485 @@
"""Pheby platform adapter — the Hermes gateway ↔ Pheby protocol bridge.
Extends ``BasePlatformAdapter`` like every other platform (Telegram,
Discord, ntfy, …) so the full gateway pipeline — sessions, tool approval,
clarify, deliverables, streaming — works unchanged on the Pheby platform.
Outbound mapping:
* ``send`` / ``edit_message`` → chat draft events (S_MESSAGE_DELTA etc.)
* ``format_tool_event`` → structured S_TOOL_EVENT JSON (never fake text)
* ``send_clarify`` → structured S_CLARIFY_REQUEST
* ``send_document`` etc. → attachment registration + S_ATTACHMENT_ADDED
Inbound mapping (reversed): Pheby WS messages are turned into MessageEvents
delivered through ``handle_message`` so the gateway treats them identically
to any other platform's messages.
"""
from __future__ import annotations
import asyncio
import logging
import mimetypes
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
try:
import aiohttp
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError: # pragma: no cover
AIOHTTP_AVAILABLE = False
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
from . import protocol as proto
from . import hermes_bridge
from .config import PhebyConfig, load_config
logger = logging.getLogger(__name__)
_MEDIA_TAG_RE = None # populated lazily from base module helpers
class PhebyAdapter(BasePlatformAdapter):
"""Serve the Pheby WebSocket/HTTP protocol and map it onto the gateway."""
# Async tools (background terminal tasks, delegate_task) may wake a later
# turn on this platform — the WS is a persistent push channel.
supports_async_delivery: bool = True
# Pheby clients re-render every delta; we accumulate full text and the
# client truncates nothing — no platform length limit.
MAX_MESSAGE_LENGTH = 0
def __init__(self, config: PlatformConfig):
# Platform("pheby") resolves via the enum's _missing_() hook once the
# plugin registry knows the name; in bare unit tests (registry not
# populated) fall back to a synthetic enum member so the adapter can
# still be constructed and tested.
try:
platform = Platform("pheby")
except ValueError:
platform = object.__new__(Platform)
platform._value_ = "pheby"
platform._name_ = "PHEBY"
super().__init__(config=config, platform=platform)
self._pcfg: PhebyConfig = load_config(config.extra or {})
self._server: Any = None
self._drafts: Dict[str, Dict[str, Any]] = {} # conv → draft state
self._typing: Dict[str, float] = {}
# ── connection lifecycle ─────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool:
if not AIOHTTP_AVAILABLE:
logger.warning("[pheby] aiohttp not installed — cannot serve")
return False
if not self._pcfg.enabled:
self._set_fatal_error(
"pheby_no_secret",
"PHEBY_SECRET is not set — refusing to start the Pheby "
"server without a credential. Set it in ~/.hermes/.env.",
retryable=False)
return False
from .server import PhebyServer
hermes_bridge.set_adapter(self)
self._server = PhebyServer(self._pcfg, adapter=self)
hermes_bridge.set_server(self._server)
ok = await self._server.start()
if not ok:
return False
self._mark_connected()
logger.info("[pheby] adapter connected (protocol v%d)",
proto.PROTOCOL_VERSION)
return True
async def disconnect(self) -> None:
self._running = False
if self._server is not None:
await self._server.stop()
self._server = None
self._mark_disconnected()
logger.info("[pheby] adapter disconnected")
# ── outbound: chat text ──────────────────────────────────────────────
def _conv_from_chat_id(self, chat_id: str) -> str:
return str(chat_id)
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Deliver assistant text (final response, commentary, or notices).
The stream consumer calls ``send`` for the first streamed chunk and
the gateway calls it for the final response; both land as
``S_MESSAGE_COMPLETE``. Streamed deltas ride ``edit_message``.
"""
if self._server is None:
return SendResult(success=False, error="server not running")
conversation_id = self._conv_from_chat_id(chat_id)
message_id = f"m-{proto.new_id()[:12]}"
# A draft exists while the turn streams; the final text supersedes
# the draft and closes it out.
draft = self._drafts.pop(conversation_id, None)
event = {
"type": proto.S_MESSAGE_COMPLETE,
"conversation_id": conversation_id,
"message_id": (draft or {}).get("message_id", message_id),
"text": content,
"ts": proto.now_iso(),
}
if metadata and metadata.get("non_conversational"):
# Gateway lifecycle/status notices — deliver as a system note so
# the client can render them differently (or ignore).
event["kind"] = "notice"
await self._server.broadcast(event)
hermes_bridge.note_run_finished(conversation_id, "completed")
return SendResult(success=True, message_id=message_id)
async def edit_message(
self,
chat_id: str,
message_id: str,
content: str,
finalize: bool = False,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Streaming path: GatewayStreamConsumer edits the in-place draft.
The stream-consumer contract requires concrete adapters to accept
``finalize=`` even when ignored (it's False during progressive
edits; the final content always arrives via ``send()``).
"""
if self._server is None:
return SendResult(success=False, error="server not running")
conversation_id = self._conv_from_chat_id(chat_id)
draft = self._drafts.setdefault(conversation_id, {
"message_id": message_id or f"draft-{proto.new_id()[:12]}",
"text": "",
})
draft["text"] = content # consumer sends cumulative text
await self._server.broadcast({
"type": proto.S_MESSAGE_DELTA,
"conversation_id": conversation_id,
"message_id": draft["message_id"],
"text": content,
"ts": proto.now_iso(),
})
return SendResult(success=True, message_id=draft["message_id"])
# ── structured stream events ─────────────────────────────────────────
def format_tool_event(self, event: Any, *, mode: str = "all",
preview_max_len: int = 40) -> Optional[str]:
"""Emit tool activity as structured JSON — never as fake chat text.
Returning a truthy marker would put prose in chat; instead we push an
S_TOOL_EVENT broadcast and return None so the gateway's text queue
stays clean. (The dispatcher treats None as "adapter ate the event".)
"""
try:
conversation_id = self._active_conversation_id()
if not conversation_id or self._server is None:
return None
if isinstance(event, ToolCallShim):
return None # never used at runtime; type-safety shim only
from gateway.stream_events import ToolCallChunk, ToolCallFinished
tool_event: Dict[str, Any]
if isinstance(event, ToolCallChunk):
tool_id = f"t-{proto.new_id()[:12]}"
args = event.args if isinstance(event.args, dict) else None
self._remember_tool(tool_id, conversation_id, event.tool_name)
tool_event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"tool_call_id": tool_id,
"tool_name": event.tool_name,
"status": "running",
"description": proto.safe_str(event.preview, 300)
if event.preview else None,
"args_redacted": _redact_args(args),
"ts": proto.now_iso(),
}
elif isinstance(event, ToolCallFinished):
tool_id = self._lookup_tool(event.tool_name, conversation_id)
tool_event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"tool_call_id": tool_id,
"tool_name": event.tool_name,
"status": "completed" if event.ok else "failed",
"duration_ms": int(event.duration * 1000)
if event.duration else None,
"ts": proto.now_iso(),
}
else:
return None
asyncio.ensure_future(self._server.broadcast(tool_event))
except Exception:
logger.debug("[pheby] tool event translation failed",
exc_info=True)
return None # never render tool chrome as chat text
def _tool_state(self) -> Dict[str, Any]:
if not hasattr(self, "_tool_calls"):
self._tool_calls: Dict[Tuple[str, str], str] = {}
self._tool_order: List[Tuple[str, str]] = []
return {"calls": self._tool_calls, "order": self._tool_order}
def _remember_tool(self, tool_id: str, conversation_id: str,
tool_name: str) -> None:
state = self._tool_state()
state["calls"][(tool_name, conversation_id)] = tool_id
state["order"].append((tool_name, conversation_id))
if len(state["order"]) > 200:
old = state["order"].pop(0)
state["calls"].pop(old, None)
def _lookup_tool(self, tool_name: str, conversation_id: str) -> str:
state = self._tool_state()
return state["calls"].get((tool_name, conversation_id),
f"t-{proto.new_id()[:12]}")
# -- Hermes plugin hooks (registered in __init__.py register()) --------
def on_post_tool_call(self, **kwargs: Any) -> None:
"""Observer for the ``post_tool_call`` plugin hook.
Hermes fires this after every tool execution with the authoritative
tool_call_id, status, duration, and result. We relay it as a
structured ``S_TOOL_EVENT`` so the client can settle the matching
"running" event emitted by ``format_tool_event``.
"""
try:
conversation_id = self._active_conversation_id()
if not conversation_id or self._server is None:
return
tool_name = str(kwargs.get("tool_name") or "tool")
status = str(kwargs.get("status") or "")
duration_ms = kwargs.get("duration_ms") or 0
event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"tool_call_id": str(kwargs.get("tool_call_id")
or self._lookup_tool(tool_name,
conversation_id)),
"tool_name": tool_name,
"status": "completed" if status in ("ok", "success", "")
else "failed" if status == "error" else status or "completed",
"duration_ms": int(duration_ms) if duration_ms else None,
# Result summaries are intentionally NOT included by default:
# tool results can embed file paths/host details. The client
# gets outcome status; verbose content stays in Hermes.
"ts": proto.now_iso(),
}
error_message = kwargs.get("error_message")
if error_message and event["status"] == "failed":
event["error"] = proto.safe_str(error_message, 200)
asyncio.ensure_future(self._server.broadcast(event))
except Exception:
logger.debug("[pheby] post_tool_call relay failed", exc_info=True)
def _active_conversation_id(self) -> Optional[str]:
"""Best-effort current conversation for adapter-level callbacks."""
if not self._active_sessions:
return None
# Most recent active session wins (single-user platform).
key = sorted(self._active_sessions.keys())[-1]
# Session keys end with :dm:<conversation_id>
return key.rsplit(":", 1)[-1] if ":" in key else None
# ── typing indicator → run activity ──────────────────────────────────
async def send_typing(self, chat_id: str, metadata=None) -> None:
# Pheby clients show their own activity UI from run/tool events.
return
# ── approvals ────────────────────────────────────────────────────────
async def send_approval_prompt(self, session_key: str,
approval_data: Dict[str, Any]) -> None:
"""Called from the approval notify callback (agent thread → here)."""
try:
await hermes_bridge.push_approval(approval_data, session_key)
except Exception:
logger.error("[pheby] approval push failed", exc_info=True)
def register_approval_notify(self, session_key: str) -> None:
"""Wire tools.approval's per-session notify callback to Pheby."""
from tools.approval import register_gateway_notify, \
unregister_gateway_notify
loop = asyncio.get_event_loop()
# The callback runs on the agent's worker thread; bridge to the loop.
def _notify(approval_data: Dict[str, Any]) -> None:
asyncio.run_coroutine_threadsafe(
self.send_approval_prompt(session_key, approval_data), loop)
register_gateway_notify(session_key, _notify)
self._approval_notify_sessions = getattr(
self, "_approval_notify_sessions", set())
self._approval_notify_sessions.add(session_key)
# ── clarification ────────────────────────────────────────────────────
async def send_clarify(
self,
chat_id: str,
question: str,
choices: Optional[list],
clarify_id: str,
session_key: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Native structured clarify prompt (buttons on the client)."""
if self._server is None:
return SendResult(success=False, error="server not running")
await hermes_bridge.push_clarify(clarify_id, session_key, question,
choices)
# Text capture is unnecessary: the client responds through
# clarify.respond, which resolves the entry directly.
return SendResult(success=True, message_id=clarify_id)
# ── deliverables (attachments) ───────────────────────────────────────
async def _register_and_broadcast(
self,
file_path: str,
conversation_id: str,
*,
kind_hint: Optional[str] = None,
filename: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if self._server is None:
return None
desc = await self._server.store.register_file(
file_path,
conversation_id=conversation_id,
message_id=None,
filename=filename,
kind_hint=kind_hint,
)
if desc is None:
return None
await self._server.broadcast({
"type": proto.S_ATTACHMENT_ADDED,
"conversation_id": conversation_id,
"attachment": desc,
"ts": proto.now_iso(),
})
return desc
async def send_document(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
file_path, conversation_id, filename=file_name)
if desc is None:
return SendResult(success=False, error="attachment failed")
if caption:
await self.send(chat_id, caption)
return SendResult(success=True, message_id=desc["attachment_id"])
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
image_path, conversation_id, kind_hint="image")
if desc is None:
return SendResult(success=False, error="attachment failed")
if caption:
await self.send(chat_id, caption)
return SendResult(success=True, message_id=desc["attachment_id"])
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
audio_path, conversation_id, kind_hint="voice")
if desc is None:
return SendResult(success=False, error="attachment failed")
return SendResult(success=True, message_id=desc["attachment_id"])
async def send_video(
self,
chat_id: str,
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
video_path, conversation_id, kind_hint="video")
if desc is None:
return SendResult(success=False, error="attachment failed")
return SendResult(success=True, message_id=desc["attachment_id"])
# ── misc contract ────────────────────────────────────────────────────
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": str(chat_id), "type": "dm"}
# Standalone cron/send_message delivery (out-of-process).
def standalone_send(self):
async def _send(pconfig, chat_id: str, message: str, **kwargs):
# Out-of-process there is no WS server; deliver via a transient
# connection to our own HTTP/WS endpoint is overkill — instead
# cron jobs targeting Pheby run in-process with the gateway.
return {"error": "pheby standalone delivery requires the gateway "
"(deliver within gateway-managed processes)"}
return _send
class ToolCallShim:
"""Marker type for internal typing only — never instantiated."""
def _redact_args(args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Strip likely-secret values from tool args before sending to client."""
if not isinstance(args, dict):
return None
sensitive = ("key", "token", "secret", "password", "credential", "auth")
out: Dict[str, Any] = {}
for k, v in args.items():
k_l = str(k).lower()
if any(s in k_l for s in sensitive):
out[str(k)] = "[redacted]"
elif isinstance(v, str) and len(v) > 500:
out[str(k)] = v[:500] + "…"
else:
out[str(k)] = v
return out
__all__ = ["PhebyAdapter", "AIOHTTP_AVAILABLE"]
+357
View File
@@ -0,0 +1,357 @@
"""Pheby attachment store — adapter-owned copies of Hermes deliverables.
Design (spec: "Generated attachments / Deliverable Mode"):
* The agent produces files via Hermes's normal deliverable pipeline. The
adapter intercepts ``send_document`` / ``send_image_file`` / ``send_voice``
/ ``send_video`` and *copies* the source file into adapter-owned storage,
registering an opaque 32-hex attachment ID.
* The client only ever sees attachment IDs — never server paths. Downloads
resolve ID → registered file inside the storage root; path traversal and
arbitrary filesystem reads are impossible by construction.
* Metadata (JSON, one file per attachment) persists across restarts so valid
attachments survive a Hermes/Pheby restart.
* Cleanup deletes only files this store owns (inside its own storage root,
matched by registered IDs) after the retention window (default 7 days).
Hermes-owned originals elsewhere on disk are never touched.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import logging
import mimetypes
import os
import shutil
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from . import protocol
logger = logging.getLogger(__name__)
IMAGE_MIME_PREFIXES = ("image/",)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic"}
# Extensions Hermes's deliverable system treats as audio/video (used to pick
# a sensible kind for inline preview decisions).
AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
def guess_mime(filename: str, fallback: str = "application/octet-stream") -> str:
"""Best-effort MIME type for a filename (stdlib mimetypes + extras)."""
ext = Path(filename).suffix.lower()
explicit = {
".md": "text/markdown", ".yml": "application/yaml",
".yaml": "application/yaml", ".toml": "application/toml",
".log": "text/plain", ".apk": "application/vnd.android.package-archive",
".ogg": "audio/ogg", ".opus": "audio/opus",
}
if ext in explicit:
return explicit[ext]
guessed, _ = mimetypes.guess_type(filename)
return guessed or fallback
class AttachmentStore:
"""Owns adapter-managed attachment copies and their metadata."""
def __init__(self, root: Path, retention_days: int = 7,
index_path: Optional[Path] = None):
self._root = Path(root).resolve()
self._retention_days = max(0, int(retention_days))
self._index_path = (
Path(index_path) if index_path else self._root / "attachments.json"
)
self._lock = asyncio.Lock()
self._meta: Dict[str, Dict[str, Any]] = {}
self._loaded = False
# ── paths ────────────────────────────────────────────────────────────
@property
def root(self) -> Path:
return self._root
def _blob_path(self, attachment_id: str, filename: str) -> Path:
"""Blob location: ``blobs/<aa>/<id>__<sanitized-filename>``."""
safe_name = self._sanitize_filename(filename)
return self._root / "blobs" / attachment_id[:2] / f"{attachment_id}__{safe_name}"
def _meta_path(self, attachment_id: str) -> Path:
return self._root / "meta" / f"{attachment_id}.json"
@staticmethod
def _sanitize_filename(filename: str) -> str:
"""Strip path separators/control chars from a stored filename."""
name = os.path.basename(str(filename or "").replace("\\", "/")).strip()
name = "".join(c for c in name if c.isprintable() and c not in '/\\')
return name[:120] or "file.bin"
# ── persistence ──────────────────────────────────────────────────────
def _load_index(self) -> None:
if self._loaded:
return
self._loaded = True
try:
if self._index_path.exists():
data = json.loads(self._index_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
self._meta = {
k: v for k, v in data.items()
if isinstance(k, str) and isinstance(v, dict)
and protocol.is_valid_attachment_id(k)
}
except Exception:
logger.warning("[pheby] attachment index unreadable; starting empty",
exc_info=True)
def _save_index(self) -> None:
try:
self._index_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._index_path.with_suffix(".tmp")
tmp.write_text(
json.dumps(self._meta, ensure_ascii=False, indent=1),
encoding="utf-8")
os.replace(tmp, self._index_path)
except Exception:
logger.error("[pheby] failed to persist attachment index",
exc_info=True)
# ── registration ─────────────────────────────────────────────────────
async def register_file(
self,
source_path: str,
*,
conversation_id: str,
message_id: Optional[str] = None,
filename: Optional[str] = None,
kind_hint: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Copy *source_path* into adapter storage and register metadata.
Returns the attachment descriptor dict, or ``None`` when the source
is missing/unsafe. The original file is never modified or deleted.
"""
try:
src = Path(source_path).expanduser().resolve(strict=True)
except (OSError, RuntimeError, ValueError):
logger.warning("[pheby] deliverable not found: %s",
protocol.safe_str(source_path, 120))
return None
if not src.is_file():
return None
fname = self._sanitize_filename(filename or src.name)
attachment_id = protocol.new_id()
mime = guess_mime(fname)
is_image = mime.startswith(IMAGE_MIME_PREFIXES) or (
Path(fname).suffix.lower() in IMAGE_EXTS)
if kind_hint == "voice":
kind = "voice"
elif kind_hint == "video" or Path(fname).suffix.lower() in VIDEO_EXTS:
kind = "video"
elif kind_hint == "audio" or Path(fname).suffix.lower() in AUDIO_EXTS:
kind = "audio"
elif is_image:
kind = "image"
else:
kind = "document"
try:
size = src.stat().st_size
async with self._lock:
self._load_index()
blob = self._blob_path(attachment_id, fname)
blob.parent.mkdir(parents=True, exist_ok=True)
# Copy under the lock so cleanup can never race a half-written
# blob (cleanup only deletes registered+expired entries).
await asyncio.to_thread(shutil.copy2, str(src), str(blob))
meta: Dict[str, Any] = {
"attachment_id": attachment_id,
"filename": fname,
"mime_type": mime,
"size": size,
"kind": kind,
"conversation_id": conversation_id,
"message_id": message_id,
"created_at": protocol.now_iso(),
"created_epoch": time.time(),
"retention_days": self._retention_days,
"blob": blob.name,
"blob_subdir": blob.parent.name,
}
self._meta[attachment_id] = meta
self._save_index()
except Exception:
logger.error("[pheby] attachment registration failed for %s",
protocol.safe_str(source_path, 120), exc_info=True)
return None
logger.info(
"[pheby] attachment registered: id=%s kind=%s size=%d conv=%s",
attachment_id, kind, size, conversation_id)
return self.describe(attachment_id)
# ── lookup / download ────────────────────────────────────────────────
def describe(self, attachment_id: str) -> Optional[Dict[str, Any]]:
"""Public descriptor for an attachment (no server paths)."""
meta = self._meta.get(attachment_id)
if not meta:
return None
return {
"attachment_id": attachment_id,
"filename": meta.get("filename", "file.bin"),
"mime_type": meta.get("mime_type", "application/octet-stream"),
"size": int(meta.get("size", 0)),
"kind": meta.get("kind", "document"),
"inline_image": meta.get("kind") == "image",
"conversation_id": meta.get("conversation_id"),
"message_id": meta.get("message_id"),
"created_at": meta.get("created_at"),
"expires_at": self._expires_at_iso(meta),
"download_path": f"/attachments/{attachment_id}",
}
def _expires_at_iso(self, meta: Dict[str, Any]) -> Optional[str]:
retention = int(meta.get("retention_days", self._retention_days))
if retention <= 0:
return None
created = float(meta.get("created_epoch", 0) or 0)
if not created:
return None
import datetime as _dt
return _dt.datetime.fromtimestamp(
created + retention * 86400, tz=_dt.timezone.utc).isoformat()
def resolve_blob(self, attachment_id: str) -> Optional[Path]:
"""Resolve an ID to its blob path — only for registered IDs.
Returns ``None`` for unknown, expired, or malformed IDs. The
returned path is always inside the storage root (the blob filename
comes from sanitized metadata, never client input).
"""
if not protocol.is_valid_attachment_id(attachment_id):
return None
meta = self._meta.get(attachment_id)
if not meta:
return None
if self._is_expired(meta):
return None
blob = (self._root / "blobs" / str(meta.get("blob_subdir", "")) /
str(meta.get("blob", "")))
try:
resolved = blob.resolve(strict=True)
except (OSError, RuntimeError, ValueError):
return None
# Defense in depth: blob must live inside our storage root.
try:
resolved.relative_to(self._root)
except ValueError:
return None
if not resolved.is_file():
return None
return resolved
def list_for_conversation(self, conversation_id: str) -> List[Dict[str, Any]]:
out = []
for aid in list(self._meta):
desc = self.describe(aid)
if desc and desc.get("conversation_id") == conversation_id:
out.append(desc)
return out
# ── cleanup ──────────────────────────────────────────────────────────
def _is_expired(self, meta: Dict[str, Any]) -> bool:
retention = int(meta.get("retention_days", self._retention_days))
if retention <= 0:
return False
created = float(meta.get("created_epoch", 0) or 0)
return created > 0 and (time.time() - created) > retention * 86400
async def cleanup_expired(self) -> int:
"""Delete expired adapter-owned blobs + metadata. Returns count.
Only deletes blobs this store registered (inside its own root, keyed
by ID). Never touches anything outside the storage root.
"""
async with self._lock:
self._load_index()
expired = [aid for aid, m in self._meta.items() if self._is_expired(m)]
removed = 0
for aid in expired:
meta = self._meta.pop(aid, None)
if not meta:
continue
blob = (self._root / "blobs" / str(meta.get("blob_subdir", "")) /
str(meta.get("blob", "")))
try:
resolved = blob.resolve(strict=False)
resolved.relative_to(self._root) # containment check
if resolved.is_file():
await asyncio.to_thread(resolved.unlink)
removed += 1
except (OSError, RuntimeError, ValueError):
logger.warning("[pheby] cleanup skipped blob for %s", aid)
try:
mp = self._meta_path(aid)
if mp.exists():
await asyncio.to_thread(mp.unlink)
except OSError:
pass
if expired:
self._save_index()
logger.info("[pheby] cleaned %d expired attachment(s)", removed)
return removed
# ── legacy per-id meta files (restart durability helper) ─────────────
def hydrate_legacy_meta(self) -> None:
"""Read any per-ID ``meta/*.json`` files from older versions."""
self._load_index()
try:
meta_dir = self._root / "meta"
if not meta_dir.is_dir():
return
for mp in meta_dir.glob("*.json"):
aid = mp.stem
if aid in self._meta or not protocol.is_valid_attachment_id(aid):
continue
try:
data = json.loads(mp.read_text(encoding="utf-8"))
if isinstance(data, dict):
self._meta[aid] = data
except Exception:
continue
except Exception:
logger.debug("[pheby] legacy meta hydration skipped", exc_info=True)
def wipe_all(self) -> None:
"""Test helper: remove everything this store owns."""
self._meta = {}
self._loaded = True
if self._root.exists():
shutil.rmtree(self._root, ignore_errors=True)
def constant_time_equals(a: str, b: str) -> bool:
"""Length-safe constant-time string comparison for secrets."""
a_b = a.encode("utf-8")
b_b = b.encode("utf-8")
return len(a_b) == len(b_b) and hmac.compare_digest(a_b, b_b)
def hash_secret_for_log(secret: str) -> str:
"""Short non-reversible fingerprint for log lines (never the secret)."""
if not secret:
return "unset"
return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:8]
__all__ = [
"AttachmentStore", "constant_time_equals", "hash_secret_for_log",
"guess_mime", "IMAGE_EXTS", "AUDIO_EXTS", "VIDEO_EXTS",
]
+179
View File
@@ -0,0 +1,179 @@
"""Pheby plugin configuration.
Secrets come from environment variables (Hermes convention: ``~/.hermes/.env``
is loaded by Hermes itself before plugins load). Non-secret behavior lives in
the platform's ``extra`` block in ``config.yaml`` under ``platforms.pheby``.
Resolution precedence for every key (highest wins):
1. environment variable (secrets *must* come from env)
2. ``platforms.pheby.extra.<key>`` in config.yaml
3. built-in default
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
# Environment variable names
ENV_SECRET = "PHEBY_SECRET" # shared credential (required to serve)
ENV_BIND_HOST = "PHEBY_BIND_HOST"
ENV_PORT = "PHEBY_PORT"
ENV_DEBUG = "PHEBY_DEBUG"
ENV_LOG_CHAT_CONTENT = "PHEBY_LOG_CHAT_CONTENT"
# Defaults
DEFAULT_BIND_HOST = "127.0.0.1" # safe behind a local Caddy reverse proxy
DEFAULT_PORT = 8620
DEFAULT_RETENTION_DAYS = 7
DEFAULT_MAX_SECRET_LEN = 1024
@dataclass
class PhebyConfig:
"""Resolved runtime configuration for the Pheby server + adapter."""
# Shared secret for WS/HTTP auth. Empty disables the adapter entirely.
secret: str = ""
bind_host: str = DEFAULT_BIND_HOST
port: int = DEFAULT_PORT
# Attachment storage directory; a per-instance subdir is created inside.
storage_dir: str = ""
# Attachment retention in days (0 = keep forever — not recommended).
retention_days: int = DEFAULT_RETENTION_DAYS
# Verbose protocol logging (still never logs secrets).
debug: bool = False
# When True, debug logs MAY include chat text and tool previews.
log_chat_content: bool = False
# Path to a persistent JSON index of registered attachments. Empty =
# derive from storage_dir.
index_path: str = ""
# Extra config passthrough (whole ``extra`` dict) for future keys.
extra: Dict[str, Any] = field(default_factory=dict)
# ------------------------------------------------------------------
@property
def enabled(self) -> bool:
"""The adapter only serves when a secret is configured."""
return bool(self.secret)
@property
def attachments_root(self) -> str:
if self.storage_dir:
return self.storage_dir
# Resolved lazily by the attachment store (needs get_hermes_home).
return ""
def _env_bool(name: str) -> bool:
return os.getenv(name, "").strip().lower() in ("1", "true", "yes", "on")
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def load_config(extra: Optional[Dict[str, Any]] = None) -> PhebyConfig:
"""Build a :class:`PhebyConfig` from env + ``platforms.pheby.extra``.
Environment variables win over YAML ``extra`` keys. Never raises.
"""
extra = dict(extra or {})
def _pick(env_name: str, key: str, default: Any = "") -> Any:
env = os.getenv(env_name, "")
if env.strip():
return env.strip()
val = extra.get(key)
if val is None or (isinstance(val, str) and not val.strip()):
return default
return val
secret = os.getenv(ENV_SECRET, "").strip()
if not secret:
# A YAML secret is allowed for local testing but strongly discouraged;
# env always wins and docs recommend env-only.
secret = str(extra.get("secret", "") or "").strip()
if len(secret) > DEFAULT_MAX_SECRET_LEN:
secret = secret[:DEFAULT_MAX_SECRET_LEN]
port = _coerce_int(
_pick(ENV_PORT, "port", DEFAULT_PORT), DEFAULT_PORT)
if not (0 < port < 65536):
port = DEFAULT_PORT
retention = _coerce_int(
_pick("", "attachment_retention_days", DEFAULT_RETENTION_DAYS),
DEFAULT_RETENTION_DAYS)
if retention < 0:
retention = DEFAULT_RETENTION_DAYS
debug = _env_bool(ENV_DEBUG) or bool(extra.get("debug", False))
log_chat = _env_bool(ENV_LOG_CHAT_CONTENT) or bool(
extra.get("log_chat_content", False))
cfg = PhebyConfig(
secret=secret,
bind_host=str(_pick(ENV_BIND_HOST, "bind_host", DEFAULT_BIND_HOST)),
port=port,
storage_dir=str(_pick("", "attachment_storage_dir", "") or ""),
retention_days=retention,
debug=bool(debug),
log_chat_content=bool(log_chat),
index_path=str(_pick("", "attachment_index_path", "") or ""),
extra=extra,
)
return cfg
def check_requirements() -> bool:
"""Platform-entry dependency check: aiohttp available + secret set."""
try:
import aiohttp # noqa: F401
except ImportError:
return False
return bool(os.getenv(ENV_SECRET, "").strip())
def validate_config(config: Any) -> bool:
"""Gateway config validation: at minimum a secret must be resolvable."""
extra = getattr(config, "extra", {}) or {}
if os.getenv(ENV_SECRET, "").strip():
return True
return bool(str(extra.get("secret", "") or "").strip())
def is_connected(config: Any) -> bool:
"""True when Pheby is configured (env or config.yaml)."""
return validate_config(config)
def env_enablement() -> Optional[Dict[str, Any]]:
"""Seed ``PlatformConfig.extra`` from env for env-only setups.
Mirrors the ntfy adapter pattern so ``hermes gateway status`` reflects a
PHEBY_SECRET-only deployment without instantiating the server.
"""
secret = os.getenv(ENV_SECRET, "").strip()
if not secret:
return None
seed: Dict[str, Any] = {}
host = os.getenv(ENV_BIND_HOST, "").strip()
if host:
seed["bind_host"] = host
port = os.getenv(ENV_PORT, "").strip()
if port:
seed["port"] = port
return seed
__all__ = [
"ENV_SECRET", "ENV_BIND_HOST", "ENV_PORT", "ENV_DEBUG",
"ENV_LOG_CHAT_CONTENT", "DEFAULT_BIND_HOST", "DEFAULT_PORT",
"DEFAULT_RETENTION_DAYS", "PhebyConfig", "load_config",
"check_requirements", "validate_config", "is_connected", "env_enablement",
]
+148
View File
@@ -0,0 +1,148 @@
"""Pheby conversation/session routing — maps Pheby conversations to Hermes sessions.
Hermes remains the authoritative source of conversation state. Each Pheby
conversation is one Hermes session reached through a stable ``SessionSource``
keyed ``pheby:dm:<conversation_id>``. Pheby keeps only a thin, rebuildable
mapping (conversation ID → display name) in ``pheby_conversations.json`` under
HERMES_HOME; everything else (history, titles, tokens) is read from the
SessionStore / SessionDB on demand.
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
from . import protocol
logger = logging.getLogger(__name__)
PLATFORM_NAME = "pheby"
_INDEX_FILE = "pheby_conversations.json"
_INDEX_MAX_ENTRIES = 500
class ConversationRouter:
"""Maps stable Pheby conversation IDs to Hermes session sources."""
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._names: Dict[str, str] = {}
self._loaded = False
# ── persistence ──────────────────────────────────────────────────────
@property
def _index_path(self) -> Path:
return Path(get_hermes_home()) / _INDEX_FILE
def _load(self) -> None:
if self._loaded:
return
self._loaded = True
try:
if self._index_path.exists():
data = json.loads(self._index_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
self._names = {
str(k): str(v)
for k, v in data.items()
if isinstance(k, str) and isinstance(v, (str, int))
}
except Exception:
logger.warning("[pheby] conversation index unreadable", exc_info=True)
def _save(self) -> None:
try:
# Bound the index; oldest-written entries lose (dict order).
if len(self._names) > _INDEX_MAX_ENTRIES:
keep = list(self._names.items())[-_INDEX_MAX_ENTRIES:]
self._names = dict(keep)
tmp = self._index_path.with_suffix(".tmp")
tmp.write_text(json.dumps(self._names, ensure_ascii=False, indent=1),
encoding="utf-8")
import os
os.replace(tmp, self._index_path)
except Exception:
logger.error("[pheby] failed to persist conversation index",
exc_info=True)
# ── ID management ────────────────────────────────────────────────────
async def ensure_conversation(self, conversation_id: str,
name: Optional[str] = None) -> str:
"""Register a conversation ID (client-generated or server-new)."""
async with self._lock:
self._load()
cid = str(conversation_id or protocol.new_id())
if not cid or len(cid) > 128:
cid = protocol.new_id()
if cid not in self._names:
self._names[cid] = (name or "").strip() or "New chat"
self._save()
elif name:
self._names[cid] = name.strip()
self._save()
return cid
async def new_conversation(self, name: Optional[str] = None) -> str:
return await self.ensure_conversation(protocol.new_id(), name)
async def rename(self, conversation_id: str, name: str) -> bool:
async with self._lock:
self._load()
cid = str(conversation_id)
if cid not in self._names:
return False
self._names[cid] = (name or "").strip() or self._names[cid]
self._save()
return True
async def forget(self, conversation_id: str) -> bool:
"""Remove the local index entry (session deletion is handled via DB)."""
async with self._lock:
self._load()
existed = str(conversation_id) in self._names
self._names.pop(str(conversation_id), None)
if existed:
self._save()
return existed
async def get_name(self, conversation_id: str) -> Optional[str]:
async with self._lock:
self._load()
return self._names.get(str(conversation_id))
async def known_ids(self) -> List[str]:
async with self._lock:
self._load()
return list(self._names.keys())
# ── session key ──────────────────────────────────────────────────────
@staticmethod
def session_key_for(conversation_id: str) -> str:
"""The Hermes gateway session key for a Pheby conversation.
Session keys are built by ``gateway.session.build_session_key`` for
DM sources as ``agent:main:pheby:dm:<chat_id>``. Conversation IDs are
server/opaque-controlled (32-hex or client GUIDs validated below), so
the chat_id component is safe to embed in the key.
"""
return f"agent:main:{PLATFORM_NAME}:dm:{conversation_id}"
@staticmethod
def is_valid_conversation_id(value: Any) -> bool:
"""Accept opaque IDs up to 128 chars from a safe alphabet."""
if not isinstance(value, str) or not value or len(value) > 128:
return False
allowed = set(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
)
return all(c in allowed for c in value)
__all__ = ["ConversationRouter", "PLATFORM_NAME"]
+715
View File
@@ -0,0 +1,715 @@
"""Hermes gateway integration — runs, approvals, clarifications, models.
This module is the ONLY place that touches Hermes internals, so every Hermes
API dependency is documented and defensive (getattr + try/except) to survive
normal Hermes upgrades. All Hermes imports are deferred (inside functions)
so the module can be imported by unit tests without a Hermes install.
"""
from __future__ import annotations
import asyncio
import contextvars
import logging
import threading
import uuid
from typing import Any, Dict, List, Optional, Tuple
from . import protocol as proto
from .conversations import ConversationRouter
logger = logging.getLogger(__name__)
# Pending interactive requests (approval/clarify) keyed by opaque ID → context.
# Single-user app, but a dict keeps the protocol multi-client friendly.
_PENDING_APPROVALS: Dict[str, Dict[str, Any]] = {}
_PENDING_CLARIFIES: Dict[str, Dict[str, Any]] = {}
_RUN_LOCK = threading.Lock()
_ACTIVE_RUNS: Dict[str, Dict[str, Any]] = {} # conversation_id → run info
def _runner() -> Any:
"""The GatewayRunner back-reference injected into the adapter."""
adapter = _current_adapter()
return getattr(adapter, "gateway_runner", None) if adapter else None
_ADAPTER_CTX: contextvars.ContextVar = contextvars.ContextVar(
"pheby_adapter", default=None)
def _current_adapter() -> Any:
return _ADAPTER_CTX.get()
def set_adapter(adapter: Any) -> None:
_ADAPTER_CTX.set(adapter)
def _session_store() -> Any:
runner = _runner()
return getattr(runner, "session_store", None) if runner else None
def _session_db() -> Any:
runner = _runner()
db = getattr(runner, "_session_db", None) if runner else None
return getattr(db, "_db", db) if db else None
def _source_for(conversation_id: str, user_name: str = "Chris"):
"""Build the SessionSource for a Pheby conversation (deferred import)."""
adapter = _current_adapter()
if adapter is not None:
return adapter.build_source(
chat_id=conversation_id,
chat_name=conversation_id,
chat_type="dm",
user_id="pheby-client",
user_name=user_name,
)
# Fallback (tests / standalone): construct directly.
from gateway.config import Platform
from gateway.session import SessionSource
return SessionSource(
platform=Platform("pheby"),
chat_id=str(conversation_id),
chat_name=str(conversation_id),
chat_type="dm",
user_id="pheby-client",
user_name=user_name,
)
def _session_key_for(conversation_id: str) -> str:
"""Compute the gateway session key for a conversation.
Prefers the SessionStore's own key builder (authoritative); falls back to
the documented deterministic shape used by ``build_session_key`` for DM
sources (``agent:main:<platform>:dm:<chat_id>``).
"""
store = _session_store()
if store is not None:
try:
source = _source_for(conversation_id)
return store._generate_session_key(source)
except Exception:
logger.debug("[pheby] session key via store failed", exc_info=True)
return ConversationRouter.session_key_for(conversation_id)
# ═══════════════════════════════════════════════════════════════════════════
# Conversations
# ═══════════════════════════════════════════════════════════════════════════
async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
"""Enumerate conversations known to the router + Hermes session store."""
server = server or _current_server()
router = server.router if server else None
out: List[Dict[str, Any]] = []
seen: set = set()
# 1. Sessions Hermes already tracks for the pheby platform.
store = _session_store()
if store is not None:
try:
entries = await asyncio.to_thread(store.list_sessions)
for entry in entries:
origin = getattr(entry, "origin", None)
platform = getattr(getattr(origin, "platform", None),
"value", "")
if platform != "pheby":
continue
cid = str(getattr(origin, "chat_id", "") or "")
if not cid or cid in seen:
continue
seen.add(cid)
out.append({
"conversation_id": cid,
"name": (getattr(entry, "display_name", None)
or _router_name(router, cid) or cid),
"session_id": getattr(entry, "session_id", None),
"last_active": _iso(getattr(entry, "updated_at", None)),
"source": "hermes",
})
except Exception:
logger.debug("[pheby] session store listing failed", exc_info=True)
# 2. Router-known conversations (incl. freshly created, no messages yet).
if router is not None:
for cid in await router.known_ids():
if cid in seen:
continue
seen.add(cid)
out.append({
"conversation_id": cid,
"name": await router.get_name(cid) or cid,
"session_id": None,
"last_active": None,
"source": "pheby",
})
out.sort(key=lambda c: (c.get("last_active") is None,
c.get("last_active") or ""), reverse=False)
out.sort(key=lambda c: c.get("last_active") or "", reverse=True)
return out
def _router_name(router: Any, cid: str) -> Optional[str]:
if router is None:
return None
return router._names.get(cid)
def _iso(value: Any) -> Optional[str]:
try:
return value.isoformat() if value else None
except AttributeError:
return None
async def conversation_history(conversation_id: str, limit: int
) -> Tuple[List[Dict[str, Any]], bool]:
"""Load transcript rows for a conversation from Hermes state.db.
Returns ``(messages, found)``. ``found`` is False when neither the
session store nor the session DB knows the conversation.
"""
messages: List[Dict[str, Any]] = []
found = False
store = _session_store()
session_id: Optional[str] = None
if store is not None:
try:
source = _source_for(conversation_id)
entry = await asyncio.to_thread(store.peek_session_id,
_session_key_for(conversation_id))
if entry:
session_id = str(entry)
found = True
except Exception:
logger.debug("[pheby] peek_session_id failed", exc_info=True)
db = _session_db()
if db is not None and session_id:
try:
rows = await asyncio.to_thread(
db.get_messages_as_conversation, session_id)
for row in rows[-limit:]:
role = row.get("role")
if role not in ("user", "assistant"):
continue
content = row.get("content")
text = content if isinstance(content, str) else str(content or "")
# Tool-call rows can surface as assistant rows with empty
# content; skip empties so the client transcript stays clean.
if not text.strip() and role == "assistant":
continue
messages.append({
"message_id": f"m{row.get('id', len(messages))}"
if isinstance(row.get("id"), (int, str)) else None,
"role": role,
"text": text,
"ts": row.get("timestamp") if isinstance(
row.get("timestamp"), str) else None,
})
found = True
except Exception:
logger.debug("[pheby] transcript load failed", exc_info=True)
# A router-known conversation with no messages yet is still "found" so a
# fresh client can open it as an empty chat.
if not found:
server = _current_server()
if server is not None:
name = await server.router.get_name(conversation_id)
if name is not None:
found = True
return messages, found
async def delete_conversation(conversation_id: str) -> bool:
"""Delete a conversation from the router + Hermes (best effort on DB).
Hermes limitation: the SessionStore has no public per-key delete; the
authoritative delete is ``SessionDB.delete_session`` on the current
session id. The routing entry is also reset so the next message starts
a fresh session. Documented approximation — see README limitations.
"""
server = _current_server()
router = server.router if server else None
if router is None:
return False
if not await router.forget(conversation_id):
return False
store = _session_store()
db = _session_db()
session_id = None
if store is not None:
try:
session_id = await asyncio.to_thread(
store.peek_session_id, _session_key_for(conversation_id))
except Exception:
session_id = None
if session_id and db is not None:
try:
await asyncio.to_thread(db.delete_session, session_id)
except Exception:
logger.debug("[pheby] session db delete failed", exc_info=True)
if store is not None:
try:
await asyncio.to_thread(store.reset_session,
_session_key_for(conversation_id),
None)
except Exception:
logger.debug("[pheby] store reset failed", exc_info=True)
_ACTIVE_RUNS.pop(conversation_id, None)
return True
# ═══════════════════════════════════════════════════════════════════════════
# Chat runs
# ═══════════════════════════════════════════════════════════════════════════
async def send_chat(server: Any, conversation_id: str, text: str,
client: Any, request_id: Optional[str]) -> None:
"""Deliver a user message into the Hermes gateway for this conversation.
The gateway's full pipeline (auth, sessions, tools, approvals, clarify,
deliverables, streaming) runs on the adapter's message handler. Pheby
adds nothing to the agent loop.
"""
adapter = _current_adapter()
if adapter is None or not hasattr(adapter, "handle_message"):
await client.send_json(proto.error_event(
proto.ERR_INTERNAL, "Gateway not connected yet", request_id))
return
# Register the conversation so it survives restarts.
await server.router.ensure_conversation(conversation_id)
run_id = uuid.uuid4().hex[:16]
source = _source_for(conversation_id)
from gateway.platforms.base import MessageEvent, MessageType
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id=uuid.uuid4().hex[:12],
metadata={"pheby_run_id": run_id},
)
_ACTIVE_RUNS[conversation_id] = {
"run_id": run_id,
"started": asyncio.get_event_loop().time(),
}
await client.send_json({
"type": proto.S_RUN_ACCEPTED,
"conversation_id": conversation_id,
"run_id": run_id,
**({"request_id": request_id} if request_id else {}),
})
draft_message_id = f"draft-{run_id}"
await server.broadcast({
"type": proto.S_MESSAGE_START,
"conversation_id": conversation_id,
"run_id": run_id,
"message_id": draft_message_id,
})
# Track the draft in the adapter so send()/edit_message() associate the
# final text with the announced draft message id.
if getattr(adapter, "_drafts", None) is not None:
adapter._drafts.setdefault(conversation_id, {
"message_id": draft_message_id, "text": ""})
# The base adapter's handle_message() spawns background tasks and
# returns quickly; the eventual reply arrives through adapter.send().
await adapter.handle_message(event)
def note_run_finished(conversation_id: str, status: str = "completed",
error: Optional[str] = None) -> None:
"""Called by the adapter when a turn completes/fails."""
run = _ACTIVE_RUNS.pop(conversation_id, None)
run_id = run["run_id"] if run else None
server = _current_server()
if server is None:
return
payload = {
"type": proto.S_RUN_FINISHED,
"conversation_id": conversation_id,
"status": status,
}
if run_id:
payload["run_id"] = run_id
if error:
payload["error"] = proto.safe_str(error, 300)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(server.broadcast(payload))
except RuntimeError:
pass
async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
"""Cancel an active run via Hermes's supported interrupt path."""
adapter = _current_adapter()
run = _ACTIVE_RUNS.get(conversation_id)
if run and run_id and run["run_id"] != run_id:
return False # stale run id — nothing to cancel
session_key = _session_key_for(conversation_id)
runner = _runner()
interrupted = False
if runner is not None:
# Preferred: gateway's own /stop dispatch (cancels task + drains).
running = getattr(runner, "_running_agents", {}).get(session_key)
agent = running if running is not None else None
if agent is not None and agent is not getattr(
type(runner), "_AGENT_PENDING_SENTINEL", object()):
try:
agent.interrupt("Cancelled by Pheby client")
invalidate = getattr(
runner, "_invalidate_session_run_generation", None)
if callable(invalidate):
invalidate(session_key, reason="pheby_cancel")
interrupted = True
except Exception:
logger.debug("[pheby] agent interrupt failed", exc_info=True)
if not interrupted and adapter is not None:
try:
await adapter.interrupt_session_activity(
session_key, conversation_id)
interrupted = True
except Exception:
logger.debug("[pheby] adapter interrupt failed", exc_info=True)
note_run_finished(conversation_id,
"cancelled" if interrupted else "idle")
return interrupted
# ═══════════════════════════════════════════════════════════════════════════
# Approvals
# ═══════════════════════════════════════════════════════════════════════════
async def push_approval(approval_data: Dict[str, Any],
session_key: str) -> None:
"""Adapter callback: a dangerous action needs a human decision."""
approval_id = uuid.uuid4().hex[:12]
from gateway.run import _redact_approval_command
command = _redact_approval_command(approval_data.get("command", ""))
choices: List[str] = ["once", "deny"]
if approval_data.get("allow_session", True):
choices.insert(1, "session")
if approval_data.get("allow_permanent", True):
choices.insert(-1, "always")
event = {
"type": proto.S_APPROVAL_REQUEST,
"approval_id": approval_id,
"session_key": session_key,
"command": proto.safe_str(command, 2000),
"description": proto.safe_str(
approval_data.get("description", ""), 1000),
"choices": choices,
"ts": proto.now_iso(),
}
_PENDING_APPROVALS[approval_id] = {
"session_key": session_key,
"created": asyncio.get_event_loop().time(),
}
server = _current_server()
if server is not None:
await server.broadcast(event)
async def resolve_approval(approval_id: str, choice: str,
reason: Optional[str]) -> bool:
"""Forward an approval decision to Hermes (tools.approval primitives)."""
pending = _PENDING_APPROVALS.pop(approval_id, None)
if pending is None:
return False
if choice not in ("once", "session", "always", "deny"):
return False
try:
from tools.approval import resolve_gateway_approval
count = await asyncio.to_thread(
resolve_gateway_approval,
pending["session_key"], choice, False, reason)
ok = count > 0
except Exception:
logger.error("[pheby] approval resolve failed", exc_info=True)
ok = False
server = _current_server()
if server is not None:
await server.broadcast({
"type": proto.S_APPROVAL_RESOLVED,
"approval_id": approval_id,
"choice": choice,
"accepted": ok,
})
return True
def fail_stale_approvals(max_age: float = 3600.0) -> None:
"""Drop approval IDs whose Hermes-side gate has surely timed out."""
now = asyncio.get_event_loop().time()
for aid in [a for a, p in _PENDING_APPROVALS.items()
if now - p["created"] > max_age]:
_PENDING_APPROVALS.pop(aid, None)
# ═══════════════════════════════════════════════════════════════════════════
# Clarifications
# ═══════════════════════════════════════════════════════════════════════════
async def push_clarify(clarify_id: str, session_key: str, question: str,
choices: Optional[List[str]]) -> None:
"""Adapter callback: the agent needs the user to choose."""
event = {
"type": proto.S_CLARIFY_REQUEST,
"clarify_id": clarify_id,
"session_key": session_key,
"question": proto.safe_str(question, 2000),
"choices": [proto.safe_str(c, 300) for c in choices]
if choices else None,
"allow_free_text": True, # Hermes clarify always permits "Other"
"ts": proto.now_iso(),
}
_PENDING_CLARIFIES[clarify_id] = {
"session_key": session_key,
"created": asyncio.get_event_loop().time(),
}
server = _current_server()
if server is not None:
await server.broadcast(event)
async def resolve_clarify(clarify_id: str, response: str) -> bool:
"""Forward a clarification answer to Hermes's clarify primitive."""
pending = _PENDING_CLARIFIES.pop(clarify_id, None)
if pending is None:
return False
try:
from tools.clarify_gateway import resolve_gateway_clarify
ok = await asyncio.to_thread(
resolve_gateway_clarify, clarify_id, response)
if not ok:
# Might be an awaiting-text open-ended clarify: route via the
# session text path instead.
from tools.clarify_gateway import \
resolve_text_response_for_session
ok = await asyncio.to_thread(
resolve_text_response_for_session,
pending["session_key"], response)
except Exception:
logger.error("[pheby] clarify resolve failed", exc_info=True)
ok = False
server = _current_server()
if server is not None:
await server.broadcast({
"type": proto.S_CLARIFY_RESOLVED,
"clarify_id": clarify_id,
"accepted": bool(ok),
})
return bool(ok)
# ═══════════════════════════════════════════════════════════════════════════
# Models & reasoning
# ═══════════════════════════════════════════════════════════════════════════
async def models_snapshot() -> Dict[str, Any]:
"""Providers + models Hermes currently exposes (credential-aware)."""
def _collect() -> Dict[str, Any]:
from hermes_cli.model_switch import list_picker_providers
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
current_model = str(model_cfg.get("default", "") or "")
current_provider = str(model_cfg.get("provider", "openrouter") or "")
providers = list_picker_providers(
current_provider=current_provider,
current_model=current_model,
user_providers=cfg.get("providers") if isinstance(cfg, dict) else None,
probe_custom_providers=False, # don't block on offline endpoints
)
return {"providers": providers, "current_model": current_model,
"current_provider": current_provider}
try:
data = await asyncio.to_thread(_collect)
except Exception:
logger.error("[pheby] model listing failed", exc_info=True)
data = {"providers": [], "current_model": "", "current_provider": "",
"error": "Model catalog unavailable"}
data["supported_reasoning_efforts"] = list(proto.REASONING_EFFORTS)
data["ts"] = proto.now_iso()
return data
async def current_model_snapshot() -> Dict[str, Any]:
def _collect() -> Dict[str, Any]:
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
return {"model": str(model_cfg.get("default", "") or ""),
"provider": str(model_cfg.get("provider", "") or "")}
try:
data = await asyncio.to_thread(_collect)
except Exception:
data = {"model": "", "provider": "", "error": "Config unavailable"}
data["ts"] = proto.now_iso()
return data
async def set_model(model: str, provider: Optional[str],
conversation_id: Optional[str]) -> Dict[str, Any]:
"""Change the active model via Hermes's session/global override path."""
if not model:
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": "model is required"}
try:
from hermes_cli.model_switch import switch_model
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
result = await asyncio.to_thread(
switch_model,
model,
str(model_cfg.get("provider", "openrouter") or "openrouter"),
str(model_cfg.get("default", "") or ""),
str(model_cfg.get("base_url", "") or ""),
"", # current_api_key — runtime resolution handles credentials
False, # is_global → session-scoped when conversation given
provider or "",
cfg.get("providers") if isinstance(cfg, dict) else None,
None,
)
except Exception as exc:
logger.error("[pheby] switch_model failed", exc_info=True)
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": proto.safe_str(exc, 200)}
ok = bool(getattr(result, "success", False))
if not ok:
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": proto.safe_str(getattr(result, "error", ""),
300)}
resolved_model = getattr(result, "model", model)
resolved_provider = getattr(result, "provider", provider or "")
override = {"model": resolved_model}
if resolved_provider:
override["provider"] = resolved_provider
store = _session_store()
if conversation_id and store is not None:
try:
await asyncio.to_thread(store.set_model_override,
_session_key_for(conversation_id),
override)
return {"ok": True, "model": resolved_model,
"provider": resolved_provider, "scope": "conversation"}
except Exception:
logger.debug("[pheby] session model override failed",
exc_info=True)
# Global fallback: persist via Hermes config save (same path /model
# --global uses).
try:
await asyncio.to_thread(_save_global_model, resolved_model,
resolved_provider)
return {"ok": True, "model": resolved_model,
"provider": resolved_provider, "scope": "global"}
except Exception as exc:
logger.error("[pheby] global model save failed", exc_info=True)
return {"ok": False, "code": proto.ERR_INTERNAL,
"message": proto.safe_str(exc, 200)}
def _save_global_model(model: str, provider: str) -> None:
from hermes_cli.config import load_config, save_config_value
save_config_value("model.default", model)
if provider:
save_config_value("model.provider", provider)
async def reasoning_snapshot() -> Dict[str, Any]:
def _collect() -> Dict[str, Any]:
from hermes_constants import resolve_reasoning_config
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
resolved = resolve_reasoning_config(
cfg, str(model_cfg.get("default", "") or ""))
if resolved is None:
return {"effort": None, "enabled": None}
if resolved.get("enabled") is False:
return {"effort": "none", "enabled": False}
return {"effort": resolved.get("effort"), "enabled": True}
try:
data = await asyncio.to_thread(_collect)
except Exception:
data = {"effort": None, "enabled": None,
"error": "Config unavailable"}
data["supported_efforts"] = ["none"] + list(proto.REASONING_EFFORTS)
data["ts"] = proto.now_iso()
return data
async def set_reasoning(effort: str,
conversation_id: Optional[str]) -> Dict[str, Any]:
"""Set reasoning effort (Hermes levels + 'none' to disable)."""
if effort not in ("none",) + proto.REASONING_EFFORTS:
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": f"effort must be one of: none, "
f"{', '.join(proto.REASONING_EFFORTS)}"}
parsed = {"enabled": False} if effort == "none" else {
"enabled": True, "effort": effort}
runner = _runner()
if runner is not None and conversation_id:
try:
await asyncio.to_thread(
runner._set_session_reasoning_override,
_session_key_for(conversation_id), parsed)
return {"ok": True, "effort": effort, "scope": "conversation"}
except Exception:
logger.debug("[pheby] session reasoning override failed",
exc_info=True)
try:
await asyncio.to_thread(_save_global_reasoning, effort)
return {"ok": True, "effort": effort, "scope": "global"}
except Exception as exc:
return {"ok": False, "code": proto.ERR_INTERNAL,
"message": proto.safe_str(exc, 200)}
def _save_global_reasoning(effort: str) -> None:
from hermes_cli.config import save_config_value
save_config_value("agent.reasoning_effort",
False if effort == "none" else effort)
def _load_cfg() -> Dict[str, Any]:
from hermes_cli.config import load_config
return load_config() or {}
# ═══════════════════════════════════════════════════════════════════════════
# Server context
# ═══════════════════════════════════════════════════════════════════════════
_SERVER_CTX: contextvars.ContextVar = contextvars.ContextVar(
"pheby_server", default=None)
def set_server(server: Any) -> None:
_SERVER_CTX.set(server)
def _current_server() -> Any:
return _SERVER_CTX.get()
__all__ = [
"set_adapter", "set_server", "list_conversations", "conversation_history",
"delete_conversation", "send_chat", "cancel_run", "note_run_finished",
"push_approval", "resolve_approval", "fail_stale_approvals",
"push_clarify", "resolve_clarify", "models_snapshot",
"current_model_snapshot", "set_model", "reasoning_snapshot",
"set_reasoning",
]
+47
View File
@@ -0,0 +1,47 @@
name: pheby
label: Pheby
kind: platform
version: 1.0.0
description: >
Pheby platform adapter for Hermes Agent — serves a WebSocket + HTTPS
protocol for the Pheby native Android client (Kotlin/Compose) behind a
Caddy reverse proxy. Exposes multiple conversations (Hermes sessions),
streamed chat, structured tool events, native approval/clarification
round-trips, model + reasoning-effort selection, and agent-generated
attachments with 7-day adapter-managed retention. Single-user, shared-secret
auth (PHEBY_SECRET). No Hermes core modifications required.
author: Pheby (for Chris)
requires_env:
- name: PHEBY_SECRET
description: "Shared credential every WS connection and attachment download must present (generate with: openssl rand -hex 32)"
prompt: "Pheby shared secret"
password: true
optional_env:
- name: PHEBY_BIND_HOST
description: "Bind address (default: 127.0.0.1 — safe behind a local Caddy)"
prompt: "Bind host (or empty for 127.0.0.1)"
password: false
- name: PHEBY_PORT
description: "Listen port (default: 8620)"
prompt: "Listen port (or empty for 8620)"
password: false
- name: PHEBY_DEBUG
description: "Verbose protocol logging (true/false, default false)"
prompt: "Enable debug protocol logging? (true/false)"
password: false
- name: PHEBY_LOG_CHAT_CONTENT
description: "Debug logs may include chat text and tool previews (default false)"
prompt: "Log chat content in debug mode? (true/false)"
password: false
- name: PHEBY_HOME_CHANNEL
description: "Conversation ID receiving cron/scheduled deliveries by default"
prompt: "Home conversation ID (or empty)"
password: false
- name: PHEBY_ALLOWED_USERS
description: "Comma-separated allowlist treated as user IDs by the gateway (optional)"
prompt: "Allowed user IDs (or empty)"
password: false
- name: PHEBY_ALLOW_ALL_USERS
description: "Allow any authenticated client (dev only)"
prompt: "Allow all users? (true/false)"
password: false
+193
View File
@@ -0,0 +1,193 @@
"""Pheby protocol v1 — message types, error codes, and (de)serialization helpers.
The protocol is JSON-over-WebSocket. Every message (both directions) has a
``type`` field. Client → server requests may carry a ``request_id`` (any
string) which is echoed on the direct reply so the client can correlate
RPC-style calls. Server → client events are broadcast to all authenticated
connections and carry the IDs needed to associate them with a conversation,
message, run, tool call, approval, clarification, or attachment.
This module is intentionally dependency-free (stdlib only) so it can be unit
tested without Hermes/aiohttp installed.
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
# ── Protocol version ─────────────────────────────────────────────────────────
PROTOCOL_VERSION = 1
# Bump when a wire-incompatible change lands. Clients negotiate via the
# ``hello`` handshake; the server refuses mismatches with a ``version_mismatch``
# error instead of guessing.
# ── Limits ───────────────────────────────────────────────────────────────────
MAX_TEXT_CHARS = 64_000 # client chat message body limit
MAX_WS_MESSAGE_BYTES = 2 * 1024 * 1024 # inbound WebSocket frame cap (aiohttp)
MAX_HISTORY_MESSAGES = 500 # per conversation.open fetch cap
AUTH_TIMEOUT_SECONDS = 10.0 # hello must arrive within this window
AUTH_FAILURE_LOCKOUT_SECONDS = 60.0 # repeated auth failures lock the source
AUTH_FAILURE_THRESHOLD = 5 # failures before lockout
# ── Error codes (machine-readable) ───────────────────────────────────────────
ERR_UNAUTHORIZED = "unauthorized"
ERR_AUTH_TIMEOUT = "auth_timeout"
ERR_VERSION_MISMATCH = "version_mismatch"
ERR_BAD_REQUEST = "bad_request"
ERR_INVALID_JSON = "invalid_json"
ERR_UNKNOWN_TYPE = "unknown_type"
ERR_NOT_FOUND = "not_found"
ERR_CONVERSATION_NOT_FOUND = "conversation_not_found"
ERR_APPROVAL_NOT_FOUND = "approval_not_found"
ERR_CLARIFY_NOT_FOUND = "clarify_not_found"
ERR_TOO_LARGE = "too_large"
ERR_RATE_LIMITED = "rate_limited"
ERR_INTERNAL = "internal_error"
ERR_NOT_IMPLEMENTED = "not_implemented"
# ── Client → server message types ────────────────────────────────────────────
C_HELLO = "hello"
C_PING = "ping"
C_CONVERSATION_LIST = "conversation.list"
C_CONVERSATION_OPEN = "conversation.open"
C_CONVERSATION_CREATE = "conversation.create"
C_CONVERSATION_RENAME = "conversation.rename"
C_CONVERSATION_DELETE = "conversation.delete"
C_CHAT_SEND = "chat.send"
C_RUN_CANCEL = "run.cancel"
C_APPROVAL_RESPOND = "approval.respond"
C_CLARIFY_RESPOND = "clarify.respond"
C_MODELS_LIST = "models.list"
C_MODEL_SET = "model.set"
C_MODEL_CURRENT = "models.current"
C_REASONING_SET = "reasoning.set"
C_REASONING_CURRENT = "reasoning.current"
# ── Server → client message types ────────────────────────────────────────────
S_READY = "ready"
S_PONG = "pong"
S_ERROR = "error"
S_CONVERSATION_SNAPSHOT = "conversation.snapshot" # reply to conversation.list
S_CONVERSATION_CREATED = "conversation.created"
S_CONVERSATION_RENAMED = "conversation.renamed"
S_CONVERSATION_UPDATED = "conversation.updated" # auto-title etc.
S_CONVERSATION_DELETED = "conversation.deleted"
S_CONVERSATION_HISTORY = "conversation.history" # reply to conversation.open
S_RUN_ACCEPTED = "run.accepted"
S_RUN_FINISHED = "run.finished"
S_MESSAGE_START = "message.start" # streaming draft opened
S_MESSAGE_DELTA = "message.delta" # cumulative streamed draft text
S_MESSAGE_COMPLETE = "message.complete" # final assistant message
S_TOOL_EVENT = "tool.event" # structured tool activity
S_APPROVAL_REQUEST = "approval.request"
S_APPROVAL_RESOLVED = "approval.resolved"
S_CLARIFY_REQUEST = "clarify.request"
S_CLARIFY_RESOLVED = "clarify.resolved"
S_ATTACHMENT_ADDED = "attachment.added"
S_MODELS_SNAPSHOT = "models.snapshot" # reply to models.list
S_MODEL_CURRENT_SNAPSHOT = "model.current" # reply to models.current
S_MODEL_CHANGED = "model.changed" # after model.set accepted
S_REASONING_SNAPSHOT = "reasoning.snapshot" # reply to reasoning.current
S_REASONING_CHANGED = "reasoning.changed" # after reasoning.set accepted
# Reasoning effort levels supported by Hermes (hermes_constants).
REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
_ATTACHMENT_ID_RE = re.compile(r"^[0-9a-f]{32}$")
def now_iso() -> str:
"""UTC timestamp in ISO-8601 format."""
return datetime.now(timezone.utc).isoformat()
def new_id() -> str:
"""Generate an opaque 32-hex identifier (also the attachment ID shape)."""
return uuid.uuid4().hex
def is_valid_attachment_id(value: str) -> bool:
"""True when *value* looks like one of our opaque attachment IDs."""
return bool(isinstance(value, str) and _ATTACHMENT_ID_RE.match(value))
def encode_message(payload: Dict[str, Any]) -> str:
"""Serialize a protocol message to a JSON string (compact, UTF-8)."""
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def decode_message(raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Parse one inbound WebSocket text frame.
Returns ``(message, None)`` on success or ``(None, error_code)`` when the
frame is not a valid JSON object. Never raises.
"""
try:
data = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError):
return None, ERR_INVALID_JSON
if not isinstance(data, dict):
return None, ERR_INVALID_JSON
if not isinstance(data.get("type"), str) or not data["type"]:
return None, ERR_INVALID_JSON
return data, None
def error_event(code: str, message: str, request_id: Optional[str] = None,
**extra: Any) -> Dict[str, Any]:
"""Build a server → client error event with a machine-readable code."""
event: Dict[str, Any] = {
"type": S_ERROR,
"error": {"code": code, "message": str(message)[:500]},
"ts": now_iso(),
}
if request_id is not None:
event["request_id"] = request_id
if extra:
event.update(extra)
return event
def safe_str(value: Any, max_len: int = 500) -> str:
"""Coerce *value* to a bounded string for logs and summaries."""
if value is None:
return ""
text = value if isinstance(value, str) else json.dumps(
value, ensure_ascii=False, default=str)
return text[:max_len]
__all__ = [
"PROTOCOL_VERSION", "MAX_TEXT_CHARS", "MAX_WS_MESSAGE_BYTES",
"MAX_HISTORY_MESSAGES", "AUTH_TIMEOUT_SECONDS",
"AUTH_FAILURE_LOCKOUT_SECONDS", "AUTH_FAILURE_THRESHOLD",
"ERR_UNAUTHORIZED", "ERR_AUTH_TIMEOUT", "ERR_VERSION_MISMATCH",
"ERR_BAD_REQUEST", "ERR_INVALID_JSON", "ERR_UNKNOWN_TYPE", "ERR_NOT_FOUND",
"ERR_CONVERSATION_NOT_FOUND", "ERR_APPROVAL_NOT_FOUND",
"ERR_CLARIFY_NOT_FOUND", "ERR_TOO_LARGE", "ERR_RATE_LIMITED",
"ERR_INTERNAL", "ERR_NOT_IMPLEMENTED",
"C_HELLO", "C_PING", "C_CONVERSATION_LIST", "C_CONVERSATION_OPEN",
"C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE",
"C_CHAT_SEND", "C_RUN_CANCEL", "C_APPROVAL_RESPOND", "C_CLARIFY_RESPOND",
"C_MODELS_LIST", "C_MODEL_SET", "C_MODEL_CURRENT", "C_REASONING_SET",
"C_REASONING_CURRENT",
"S_READY", "S_PONG", "S_ERROR", "S_CONVERSATION_SNAPSHOT",
"S_CONVERSATION_CREATED", "S_CONVERSATION_RENAMED", "S_CONVERSATION_UPDATED",
"S_CONVERSATION_DELETED", "S_CONVERSATION_HISTORY", "S_RUN_ACCEPTED",
"S_RUN_FINISHED", "S_MESSAGE_START", "S_MESSAGE_DELTA",
"S_MESSAGE_COMPLETE", "S_TOOL_EVENT", "S_APPROVAL_REQUEST",
"S_APPROVAL_RESOLVED", "S_CLARIFY_REQUEST", "S_CLARIFY_RESOLVED",
"S_ATTACHMENT_ADDED", "S_MODELS_SNAPSHOT", "S_MODEL_CURRENT_SNAPSHOT",
"S_MODEL_CHANGED", "S_REASONING_SNAPSHOT", "S_REASONING_CHANGED",
"REASONING_EFFORTS",
"now_iso", "new_id", "is_valid_attachment_id", "encode_message",
"decode_message", "error_event", "safe_str",
]
+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"]
+72
View File
@@ -0,0 +1,72 @@
"""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
+4
View File
@@ -0,0 +1,4 @@
[pytest]
addopts =
-o asyncio_mode=auto
testpaths = tests
+28
View File
@@ -0,0 +1,28 @@
"""Pytest fixtures: redirect HERMES_HOME to a temp dir and fix sys.path."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
_PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin"
_HERMES_SRC = os.environ.get("PHEBY_HERMES_SRC", "/opt/hermes")
for _p in (str(_PLUGIN_DIR), _HERMES_SRC):
if _p not in sys.path:
sys.path.insert(0, _p)
@pytest.fixture(autouse=True)
def _isolated_hermes_home(tmp_path, monkeypatch):
"""Point HERMES_HOME at a per-test temp dir (Hermes tests' convention)."""
home = tmp_path / "hermes-home"
home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(home))
# Also neutralize any ambient PHEBY config so tests are deterministic.
monkeypatch.delenv("PHEBY_SECRET", raising=False)
monkeypatch.delenv("PHEBY_BIND_HOST", raising=False)
monkeypatch.delenv("PHEBY_PORT", raising=False)
yield str(home)
+773
View File
@@ -0,0 +1,773 @@
"""Pheby plugin test suite.
Run with the Hermes venv's pytest from the repo root:
/opt/hermes/.venv/bin/python -m pytest tests/ -o 'addopts=' -q
All tests use fakes for the Hermes gateway — no LLM calls, no network beyond
localhost, no real HERMES_HOME writes (HERMES_HOME is redirected to a tmp dir
by ``conftest.py``).
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
from aiohttp import web
# Make the plugin package importable regardless of install layout.
import sys
_PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin"
if str(_PLUGIN_DIR) not in sys.path:
sys.path.insert(0, str(_PLUGIN_DIR))
from pheby import protocol as proto # noqa: E402
from pheby.attachments import AttachmentStore, constant_time_equals # noqa: E402
from pheby.config import load_config # noqa: E402
from pheby.conversations import ConversationRouter # noqa: E402
from pheby.server import PhebyServer # noqa: E402
# ═══════════════════════════════════════════════════════════════════════════
# Fakes
# ═══════════════════════════════════════════════════════════════════════════
class FakeWS:
"""Minimal WebSocketResponse stand-in for server-loop tests."""
def __init__(self):
self.sent: List[str] = []
self.inbox: "asyncio.Queue[str]" = asyncio.Queue()
self.closed = False
self.close_code: Optional[int] = None
async def send_str(self, data: str) -> None:
if self.closed:
raise ConnectionError("closed")
self.sent.append(data)
async def receive(self, timeout: Optional[float] = None):
class _Msg:
def __init__(self, data: str):
self.type = "text"
self.data = data
try:
return _Msg(await asyncio.wait_for(self.inbox.get(),
timeout=timeout))
except asyncio.TimeoutError:
raise
async def close(self, code: Optional[int] = None, message=None):
self.closed = True
self.close_code = code
def events(self) -> List[Dict[str, Any]]:
out = []
for raw in self.sent:
try:
out.append(json.loads(raw))
except json.JSONDecodeError:
pass
return out
class FakeClientConnection:
"""Wraps FakeWS with the ClientConnection interface the server expects."""
def __init__(self):
self.ws = FakeWS()
self.conn_id = "test-conn"
self.authenticated = False
self.protocol_version = None
self.connected_at = time.time()
self.closed = False
self._send_lock = asyncio.Lock()
async def send_json(self, payload: Dict[str, Any]) -> bool:
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
class FakeAdapter:
"""Adapter stand-in: enough surface for bridge tests."""
def __init__(self):
self.platform = type("P", (), {"value": "pheby"})()
self.gateway_runner = None
self._active_sessions: Dict[str, Any] = {}
self.handled: List[Any] = []
def build_source(self, **kwargs):
from gateway.session import SessionSource # real Hermes type
return SessionSource(
platform=self.platform, chat_id=kwargs.get("chat_id", "x"),
chat_type="dm", user_id="pheby-client")
async def handle_message(self, event) -> None:
self.handled.append(event)
async def interrupt_session_activity(self, session_key, chat_id,
metadata=None):
self.interrupted = (session_key, chat_id)
class FakeRunner:
"""Gateway runner stand-in for session-key + interrupt tests."""
def __init__(self):
self.session_store = None
self._session_db = None
self._running_agents: Dict[str, Any] = {}
self.generations: Dict[str, int] = {}
def _generate_session_key(self, source):
return f"agent:main:pheby:dm:{source.chat_id}"
def _invalidate_session_run_generation(self, session_key, reason=""):
self.generations[session_key] = \
self.generations.get(session_key, 0) + 1
class FakeAgent:
def __init__(self):
self.interrupts: List[str] = []
def interrupt(self, message=None):
self.interrupts.append(message or "")
def make_server(tmp_path: Path, **overrides) -> PhebyServer:
cfg = load_config({
"secret": "test-secret-abc123",
"port": overrides.pop("port", 0), # 0 unused in handler tests
**overrides,
})
cfg.secret = overrides.get("secret", cfg.secret or "test-secret-abc123")
from hermes_constants import get_hermes_home # conftest redirects home
root = Path(tmp_path) / "attachments"
server = PhebyServer(cfg, adapter=FakeAdapter())
server.store = AttachmentStore(root=root, retention_days=7)
hermes_bridge_set(server)
return server
def hermes_bridge_set(server: PhebyServer) -> None:
from pheby import hermes_bridge
hermes_bridge.set_server(server)
if server.adapter is not None:
hermes_bridge.set_adapter(server.adapter)
# ═══════════════════════════════════════════════════════════════════════════
# Protocol serialization
# ═══════════════════════════════════════════════════════════════════════════
class TestProtocol:
def test_roundtrip(self):
msg = {"type": proto.C_CHAT_SEND, "conversation_id": "abc",
"text": "héllo 🐱", "request_id": "r1"}
data = proto.encode_message(msg)
parsed, err = proto.decode_message(data)
assert err is None and parsed == msg
def test_invalid_json_rejected(self):
for bad in ("{not json", "[]", '"str"', "42", '{"no_type": 1}', ""):
parsed, err = proto.decode_message(bad)
assert parsed is None and err == proto.ERR_INVALID_JSON
def test_error_event_shape(self):
ev = proto.error_event(proto.ERR_BAD_REQUEST, "boom", request_id="r9")
assert ev["type"] == proto.S_ERROR
assert ev["error"]["code"] == proto.ERR_BAD_REQUEST
assert ev["request_id"] == "r9"
def test_attachment_id_format(self):
aid = proto.new_id()
assert len(aid) == 32 and proto.is_valid_attachment_id(aid)
assert not proto.is_valid_attachment_id("../etc/passwd")
assert not proto.is_valid_attachment_id("")
# ═══════════════════════════════════════════════════════════════════════════
# Authentication
# ═══════════════════════════════════════════════════════════════════════════
class TestAuth:
@pytest.mark.asyncio
async def test_hello_success(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.ws.inbox.put_nowait(proto.encode_message({
"type": proto.C_HELLO, "secret": "test-secret-abc123",
"protocol_version": proto.PROTOCOL_VERSION}))
ok = await server._authenticate(client, "peer1")
assert ok and client.authenticated
@pytest.mark.asyncio
async def test_hello_wrong_secret(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
bad_hello = proto.encode_message(
{"type": proto.C_HELLO, "secret": "wrong"})
client.ws.inbox.put_nowait(bad_hello)
ok = await server._authenticate(client, "peer2")
assert not ok and not client.authenticated
# 5 failures → lockout (each attempt needs its own hello frame)
for _ in range(proto.AUTH_FAILURE_THRESHOLD - 1):
client.ws.inbox.put_nowait(bad_hello)
await server._authenticate(client, "peer2")
assert server._is_locked_out("peer2")
@pytest.mark.asyncio
async def test_first_message_not_hello(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.ws.inbox.put_nowait(proto.encode_message(
{"type": proto.C_PING}))
ok = await server._authenticate(client, "peer3")
assert not ok
@pytest.mark.asyncio
async def test_version_mismatch_refused(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.ws.inbox.put_nowait(proto.encode_message({
"type": proto.C_HELLO, "secret": "test-secret-abc123",
"protocol_version": 99}))
ok = await server._authenticate(client, "peer4")
assert not ok and not client.authenticated
def test_constant_time_equals(self):
assert constant_time_equals("abc", "abc")
assert not constant_time_equals("abc", "abd")
assert not constant_time_equals("abc", "abcd")
assert not constant_time_equals("", "x")
# ═══════════════════════════════════════════════════════════════════════════
# Conversation operations
# ═══════════════════════════════════════════════════════════════════════════
class TestConversations:
@pytest.mark.asyncio
async def test_create_list_rename_delete(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
await server._handle_conversation_create(client, {
"type": proto.C_CONVERSATION_CREATE, "name": "Project X"}, "r1")
created = client.ws.events()[-1]
cid = created["conversation_id"]
assert created["type"] == proto.S_CONVERSATION_CREATED
assert created["name"] == "Project X"
# list includes it
await server._handle_conversation_list(client, {
"type": proto.C_CONVERSATION_LIST}, "r2")
snap = client.ws.events()[-1]
assert any(c["conversation_id"] == cid for c in snap["conversations"])
# rename
await server._handle_conversation_rename(client, {
"type": proto.C_CONVERSATION_RENAME,
"conversation_id": cid, "name": "Renamed"}, "r3")
renamed = client.ws.events()[-1]
assert renamed["type"] == proto.S_CONVERSATION_RENAMED
assert renamed["name"] == "Renamed"
# open (empty history, conversation exists in router)
await server._handle_conversation_open(client, {
"type": proto.C_CONVERSATION_OPEN,
"conversation_id": cid}, "r4")
hist = client.ws.events()[-1]
assert hist["type"] == proto.S_CONVERSATION_HISTORY
assert hist["messages"] == []
# delete
await server._handle_conversation_delete(client, {
"type": proto.C_CONVERSATION_DELETE,
"conversation_id": cid}, "r5")
deleted = client.ws.events()[-1]
assert deleted["type"] == proto.S_CONVERSATION_DELETED
# open after delete → not found
await server._handle_conversation_open(client, {
"type": proto.C_CONVERSATION_OPEN,
"conversation_id": cid}, "r6")
err = client.ws.events()[-1]
assert err["type"] == proto.S_ERROR
assert err["error"]["code"] == proto.ERR_CONVERSATION_NOT_FOUND
@pytest.mark.asyncio
async def test_invalid_id_rejected(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
await server._handle_conversation_open(client, {
"type": proto.C_CONVERSATION_OPEN,
"conversation_id": "../../etc"}, "r1")
ev = client.ws.events()[-1]
assert ev["error"]["code"] == proto.ERR_BAD_REQUEST
@pytest.mark.asyncio
async def test_ids_survive_router_reload(self, tmp_path):
server = make_server(tmp_path)
cid = await server.router.new_conversation("Persisted")
# New router instance (simulates restart) sees the same ID.
router2 = ConversationRouter()
assert await router2.get_name(cid) == "Persisted"
# ═══════════════════════════════════════════════════════════════════════════
# Attachments
# ═══════════════════════════════════════════════════════════════════════════
class TestAttachments:
@pytest.mark.asyncio
async def test_register_describe_download_path(self, tmp_path):
src = Path(tmp_path) / "report.pdf"
src.write_bytes(b"%PDF-1.4 fake")
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(str(src), conversation_id="conv1")
assert desc is not None
assert desc["filename"] == "report.pdf"
assert desc["mime_type"] == "application/pdf"
assert desc["inline_image"] is False
assert desc["download_path"].startswith("/attachments/")
# Blob resolves only via the registered ID
blob = store.resolve_blob(desc["attachment_id"])
assert blob is not None and blob.read_bytes() == b"%PDF-1.4 fake"
@pytest.mark.asyncio
async def test_image_detection(self, tmp_path):
src = Path(tmp_path) / "pic.png"
src.write_bytes(b"\x89PNG fake")
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(str(src), conversation_id="c")
assert desc["kind"] == "image" and desc["inline_image"] is True
@pytest.mark.asyncio
async def test_unknown_and_traversal_ids(self, tmp_path):
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
assert store.resolve_blob("f" * 32) is None
assert store.resolve_blob("../../etc/passwd") is None
assert store.resolve_blob("../" + "a" * 32) is None
assert store.resolve_blob("") is None
@pytest.mark.asyncio
async def test_seven_day_expiry(self, tmp_path):
src = Path(tmp_path) / "old.txt"
src.write_text("expired soon")
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(str(src), conversation_id="c")
aid = desc["attachment_id"]
assert store.resolve_blob(aid) is not None
# Force age beyond retention.
store._meta[aid]["created_epoch"] = time.time() - 8 * 86400
assert store.resolve_blob(aid) is None # expired → unavailable
removed = await store.cleanup_expired()
assert removed == 1
# Blob actually gone from disk; metadata index updated.
assert store._meta.get(aid) is None
@pytest.mark.asyncio
async def test_cleanup_never_touches_unrelated_files(self, tmp_path):
root = Path(tmp_path) / "att"
store = AttachmentStore(root=root, retention_days=7)
stranger = root / "blobs" / "zz" / "unrelated.txt"
stranger.parent.mkdir(parents=True)
stranger.write_text("keep me")
await store.cleanup_expired()
assert stranger.exists() # untouched
@pytest.mark.asyncio
async def test_metadata_survives_restart(self, tmp_path):
src = Path(tmp_path) / "doc.md"
src.write_text("# hi")
root = Path(tmp_path) / "att"
store1 = AttachmentStore(root=root, retention_days=7)
desc = await store1.register_file(str(src), conversation_id="c")
store2 = AttachmentStore(root=root, retention_days=7)
store2.hydrate_legacy_meta() # no-op for index-file storage
assert store2.resolve_blob(desc["attachment_id"]) is not None
@pytest.mark.asyncio
async def test_missing_source_file(self, tmp_path):
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(
str(Path(tmp_path) / "nope.bin"), conversation_id="c")
assert desc is None
# ═══════════════════════════════════════════════════════════════════════════
# Tool events / approvals / clarifications / cancellation (bridge)
# ═══════════════════════════════════════════════════════════════════════════
class TestBridge:
@pytest.mark.asyncio
async def test_tool_start_event_is_structured_not_text(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
# Use the real PhebyAdapter for the tool-event path (FakeAdapter has
# no format_tool_event; the real one is what we're testing).
from pheby.adapter import PhebyAdapter
from gateway.config import PlatformConfig
real = PhebyAdapter(PlatformConfig(
enabled=True, extra={"secret": "test-secret-abc123"}))
real._pcfg = server.config
real._server = server
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
# Tool-call dedup state is created lazily via _tool_state()
from gateway.stream_events import ToolCallChunk
marker = real.format_tool_event(
ToolCallChunk(tool_name="web_search", preview="cats",
args={"query": "cats"}, index=0),
mode="all")
assert marker is None # never rendered as chat text
await asyncio.sleep(0) # let ensure_future broadcast run
events = client.ws.events()
tool_events = [e for e in events if e["type"] == proto.S_TOOL_EVENT]
assert len(tool_events) == 1
ev = tool_events[0]
assert ev["tool_name"] == "web_search"
assert ev["status"] == "running"
assert ev["tool_call_id"]
assert ev["conversation_id"] == "conv1"
# No fake prose leaked into a message event
assert not any(e.get("type") == proto.S_MESSAGE_COMPLETE
for e in events)
@pytest.mark.asyncio
async def test_post_tool_call_completion(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby.adapter import PhebyAdapter
from gateway.config import PlatformConfig
real = PhebyAdapter(PlatformConfig(
enabled=True, extra={"secret": "test-secret-abc123"}))
real._server = server
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
real.on_post_tool_call(
tool_name="terminal", tool_call_id="call_9",
status="ok", duration_ms=1234)
await asyncio.sleep(0) # let ensure_future run
ev = [e for e in client.ws.events()
if e["type"] == proto.S_TOOL_EVENT][-1]
assert ev["tool_call_id"] == "call_9"
assert ev["status"] == "completed"
assert ev["duration_ms"] == 1234
@pytest.mark.asyncio
async def test_approval_push_and_resolve_roundtrip(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby import hermes_bridge
await hermes_bridge.push_approval(
{"command": "rm -rf /tmp/x", "description": "Destructive command",
"allow_permanent": True, "allow_session": True},
session_key="agent:main:pheby:dm:conv1")
req = [e for e in client.ws.events()
if e["type"] == proto.S_APPROVAL_REQUEST][-1]
assert req["choices"] == ["once", "session", "always", "deny"]
assert req["description"] == "Destructive command"
# Resolve: with no real Hermes queue the resolve call fails-open to
# accepted=False but the pending entry must be consumed either way.
from pheby import hermes_bridge as hb
ok = await hb.resolve_approval(req["approval_id"], "deny", None)
assert ok is True # pending entry existed; resolution attempted
# Double resolve → not found
ok2 = await hb.resolve_approval(req["approval_id"], "once", None)
assert ok2 is False
# Client-facing error path via server handler
await server._handle_approval_respond(client, {
"type": proto.C_APPROVAL_RESPOND,
"approval_id": "nope", "choice": "once"}, "r1")
ev = client.ws.events()[-1]
assert ev["error"]["code"] == proto.ERR_APPROVAL_NOT_FOUND
@pytest.mark.asyncio
async def test_clarify_push_and_resolve_roundtrip(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby import hermes_bridge
await hermes_bridge.push_clarify(
"clar1", "sk", "Deploy where?", ["staging", "prod"])
req = [e for e in client.ws.events()
if e["type"] == proto.S_CLARIFY_REQUEST][-1]
assert req["question"] == "Deploy where?"
assert req["choices"] == ["staging", "prod"]
assert req["allow_free_text"] is True
# Register the clarify in Hermes's real gateway primitive so the
# full resolve path (tools.clarify_gateway) is exercised.
from tools import clarify_gateway as cg
cg.register(clarify_id="clar1", session_key="sk",
question="Deploy where?", choices=["staging", "prod"])
from pheby import hermes_bridge as hb
ok = await hb.resolve_clarify("clar1", "staging")
assert ok is True
ok2 = await hb.resolve_clarify("clar1", "staging")
assert ok2 is False # entry consumed
cg.clear_session("sk")
@pytest.mark.asyncio
async def test_chat_send_creates_message_event(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby import hermes_bridge as hb
await hb.send_chat(server, "conv77", "hello Hermes", client, "r1")
adapter = server.adapter
assert len(adapter.handled) == 1
assert adapter.handled[0].text == "hello Hermes"
assert adapter.handled[0].source.chat_id == "conv77"
events = client.ws.events()
assert events[0]["type"] == proto.S_RUN_ACCEPTED
assert events[1]["type"] == proto.S_MESSAGE_START
@pytest.mark.asyncio
async def test_cancel_run_interrupts_agent(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
runner = FakeRunner()
agent = FakeAgent()
runner._running_agents["agent:main:pheby:dm:convX"] = agent
server.adapter.gateway_runner = runner
from pheby import hermes_bridge as hb
ok = await hb.cancel_run("convX", None)
assert ok is True
assert agent.interrupts # agent.interrupt called, not thread-kill
assert runner.generations.get("agent:main:pheby:dm:convX") == 1
@pytest.mark.asyncio
async def test_cancel_stale_run_id_rejected(self, tmp_path):
server = make_server(tmp_path)
from pheby import hermes_bridge as hb
server._bridge_active("convY") if hasattr(
server, "_bridge_active") else None
hb._ACTIVE_RUNS["convY"] = {"run_id": "run1", "started": 0}
ok = await hb.cancel_run("convY", "wrong-run")
assert ok is False
@pytest.mark.asyncio
async def test_reasoning_set_validation(self, tmp_path):
from pheby import hermes_bridge as hb
bad = await hb.set_reasoning("turbo", None)
assert bad["ok"] is False
assert bad["code"] == proto.ERR_BAD_REQUEST
# ═══════════════════════════════════════════════════════════════════════════
# Reconnect / recovery semantics
# ═══════════════════════════════════════════════════════════════════════════
class TestRecovery:
@pytest.mark.asyncio
async def test_history_resync_after_reconnect(self, tmp_path):
"""Conversation state is authoritative server-side: a fresh client
connection re-opening a conversation gets the same history."""
server = make_server(tmp_path)
cid = await server.router.new_conversation("Sync")
# Seed transcript via the fake DB path is covered in bridge tests
# through Hermes; here assert the contract: open is idempotent.
c1, c2 = FakeClientConnection(), FakeClientConnection()
for c in (c1, c2):
c.authenticated = True
await server._handle_conversation_open(c1, {
"type": proto.C_CONVERSATION_OPEN, "conversation_id": cid}, "a")
await server._handle_conversation_open(c2, {
"type": proto.C_CONVERSATION_OPEN, "conversation_id": cid}, "b")
h1 = c1.ws.events()[-1]
h2 = c2.ws.events()[-1]
assert h1["messages"] == h2["messages"]
assert h1["conversation_id"] == h2["conversation_id"] == cid
@pytest.mark.asyncio
async def test_broadcast_reaches_multiple_clients(self, tmp_path):
server = make_server(tmp_path)
clients = []
for i in range(3):
c = FakeClientConnection()
c.authenticated = True
server._clients[f"c{i}"] = c
clients.append(c)
await server.broadcast({"type": proto.S_PONG, "ts": "t"})
for c in clients:
assert any(e["type"] == proto.S_PONG for e in c.ws.events())
# ═══════════════════════════════════════════════════════════════════════════
# Config
# ═══════════════════════════════════════════════════════════════════════════
class TestConfig:
def test_env_secret_wins(self, monkeypatch):
monkeypatch.setenv("PHEBY_SECRET", "env-secret")
cfg = load_config({"secret": "yaml-secret", "port": 9999})
assert cfg.secret == "env-secret"
def test_yaml_fallback_and_defaults(self, monkeypatch):
monkeypatch.delenv("PHEBY_SECRET", raising=False)
cfg = load_config({"secret": "yaml-secret"})
assert cfg.secret == "yaml-secret"
assert cfg.bind_host == "127.0.0.1"
assert cfg.port == 8620
assert cfg.retention_days == 7
assert cfg.enabled
def test_disabled_without_secret(self, monkeypatch):
monkeypatch.delenv("PHEBY_SECRET", raising=False)
cfg = load_config({})
assert not cfg.enabled
def test_bad_port_falls_back(self, monkeypatch):
monkeypatch.delenv("PHEBY_SECRET", raising=False)
cfg = load_config({"secret": "s", "port": "not-a-port"})
assert cfg.port == 8620
# ═══════════════════════════════════════════════════════════════════════════
# Live HTTP+WS smoke (localhost only)
# ═══════════════════════════════════════════════════════════════════════════
class TestLiveServer:
@pytest.mark.asyncio
async def test_health_and_ws_roundtrip(self, tmp_path):
try:
import aiohttp
except ImportError:
pytest.skip("aiohttp unavailable")
server = make_server(tmp_path, port=0)
# Bind on an ephemeral port by patching TCPSite port choice.
cfg = server.config
cfg.port = 0 # let OS choose
ok = await server.start()
if not ok:
pytest.skip("could not bind test server")
try:
port = server._site._server.sockets[0].getsockname()[1]
base = f"http://127.0.0.1:{port}"
async with aiohttp.ClientSession() as http:
# health: no auth
async with http.get(f"{base}/health") as resp:
assert resp.status == 200
data = await resp.json()
assert data["status"] == "ok"
# attachment without auth → 401
async with http.get(
f"{base}/attachments/{'a'*32}") as resp:
assert resp.status == 401
# WS handshake with bad secret → server sends error event
async with http.ws_connect(f"{base}/ws") as ws:
await ws.send_str(json.dumps(
{"type": "hello", "secret": "bad"}))
msg = await ws.receive()
reply = json.loads(msg.data)
assert reply["type"] == proto.S_ERROR
assert reply["error"]["code"] == proto.ERR_UNAUTHORIZED
finally:
await server.stop()
@pytest.mark.asyncio
async def test_full_ws_flow(self, tmp_path):
"""hello → ready → ping/pong → conversation create → list."""
try:
import aiohttp
except ImportError:
pytest.skip("aiohttp unavailable")
server = make_server(tmp_path)
cfg = server.config
cfg.port = 0
ok = await server.start()
if not ok:
pytest.skip("could not bind test server")
try:
port = server._site._server.sockets[0].getsockname()[1]
async with aiohttp.ClientSession() as http:
async with http.ws_connect(
f"http://127.0.0.1:{port}/ws") as ws:
await ws.send_str(json.dumps({
"type": "hello",
"secret": "test-secret-abc123",
"protocol_version": proto.PROTOCOL_VERSION}))
ready = json.loads((await ws.receive()).data)
assert ready["type"] == proto.S_READY
await ws.send_str(json.dumps({"type": "ping"}))
pong = json.loads((await ws.receive()).data)
assert pong["type"] == proto.S_PONG
await ws.send_str(json.dumps({
"type": "conversation.create", "name": "Live",
"request_id": "r1"}))
created = json.loads((await ws.receive()).data)
assert created["type"] == proto.S_CONVERSATION_CREATED
assert created["request_id"] == "r1"
cid = created["conversation_id"]
# The handler also broadcasts a conversation.updated event
updated = json.loads((await ws.receive()).data)
assert updated["type"] == proto.S_CONVERSATION_UPDATED
await ws.send_str(json.dumps({
"type": "conversation.list", "request_id": "r2"}))
snap = json.loads((await ws.receive()).data)
assert any(c["conversation_id"] == cid
for c in snap["conversations"])
# unknown type → structured error
await ws.send_str(json.dumps({"type": "bogus.thing"}))
err = json.loads((await ws.receive()).data)
assert err["type"] == proto.S_ERROR
assert err["error"]["code"] == proto.ERR_UNKNOWN_TYPE
finally:
await server.stop()
# ═══════════════════════════════════════════════════════════════════════════
# Adapter unit checks
# ═══════════════════════════════════════════════════════════════════════════
class TestAdapterUnits:
def test_redact_args(self):
from pheby.adapter import _redact_args
out = _redact_args({"query": "cats", "api_key": "sk-123",
"token": "t", "long": "x" * 900})
assert out["api_key"] == "[redacted]"
assert out["token"] == "[redacted]"
assert out["query"] == "cats"
assert out["long"].endswith("…")
def test_sanitize_filename(self):
from pheby.attachments import AttachmentStore
assert AttachmentStore._sanitize_filename("../../etc/passwd") == "passwd"
# Path separators (either flavor) collapse to the final component.
assert AttachmentStore._sanitize_filename("a/b\\c.txt") == "c.txt"
assert AttachmentStore._sanitize_filename("") == "file.bin"