From 53ffdb8aa2794b970145b7c65442e12b159f976b Mon Sep 17 00:00:00 2001 From: Pheby Date: Wed, 2 Sep 2026 19:31:17 +0000 Subject: [PATCH] feat: Pheby platform adapter/plugin for Hermes (protocol v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 12 + .gitignore | 24 + README.md | 17 + docs/PROTOCOL.md | 408 +++++++++ docs/README.md | 309 +++++++ .../Pheby-Platform-Plugin-SPEC-2026-09-02.txt | 274 +++++++ plugin/pheby/__init__.py | 81 ++ plugin/pheby/adapter.py | 485 +++++++++++ plugin/pheby/attachments.py | 357 ++++++++ plugin/pheby/config.py | 179 ++++ plugin/pheby/conversations.py | 148 ++++ plugin/pheby/hermes_bridge.py | 715 ++++++++++++++++ plugin/pheby/plugin.yaml | 47 ++ plugin/pheby/protocol.py | 193 +++++ plugin/pheby/server.py | 562 +++++++++++++ plugin/pheby/ws_client.py | 72 ++ pytest.ini | 4 + tests/conftest.py | 28 + tests/test_pheby.py | 773 ++++++++++++++++++ 19 files changed, 4688 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/PROTOCOL.md create mode 100644 docs/README.md create mode 100644 docs/spec-source/Pheby-Platform-Plugin-SPEC-2026-09-02.txt create mode 100644 plugin/pheby/__init__.py create mode 100644 plugin/pheby/adapter.py create mode 100644 plugin/pheby/attachments.py create mode 100644 plugin/pheby/config.py create mode 100644 plugin/pheby/conversations.py create mode 100644 plugin/pheby/hermes_bridge.py create mode 100644 plugin/pheby/plugin.yaml create mode 100644 plugin/pheby/protocol.py create mode 100644 plugin/pheby/server.py create mode 100644 plugin/pheby/ws_client.py create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/test_pheby.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e43d785 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4114cc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..483ada1 --- /dev/null +++ b/README.md @@ -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` diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..6680675 --- /dev/null +++ b/docs/PROTOCOL.md @@ -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:///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": "", + "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 (or ApiKey , or X-Pheby-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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6fd8737 --- /dev/null +++ b/docs/README.md @@ -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:`; 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: /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 `. + +--- + +## 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 + (`/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. diff --git a/docs/spec-source/Pheby-Platform-Plugin-SPEC-2026-09-02.txt b/docs/spec-source/Pheby-Platform-Plugin-SPEC-2026-09-02.txt new file mode 100644 index 0000000..9cb0979 --- /dev/null +++ b/docs/spec-source/Pheby-Platform-Plugin-SPEC-2026-09-02.txt @@ -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. \ No newline at end of file diff --git a/plugin/pheby/__init__.py b/plugin/pheby/__init__.py new file mode 100644 index 0000000..cac8bdd --- /dev/null +++ b/plugin/pheby/__init__.py @@ -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__"] diff --git a/plugin/pheby/adapter.py b/plugin/pheby/adapter.py new file mode 100644 index 0000000..ced1c20 --- /dev/null +++ b/plugin/pheby/adapter.py @@ -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: + 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"] diff --git a/plugin/pheby/attachments.py b/plugin/pheby/attachments.py new file mode 100644 index 0000000..715e004 --- /dev/null +++ b/plugin/pheby/attachments.py @@ -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//__``.""" + 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", +] diff --git a/plugin/pheby/config.py b/plugin/pheby/config.py new file mode 100644 index 0000000..2dbac58 --- /dev/null +++ b/plugin/pheby/config.py @@ -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.`` 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", +] diff --git a/plugin/pheby/conversations.py b/plugin/pheby/conversations.py new file mode 100644 index 0000000..f7a1859 --- /dev/null +++ b/plugin/pheby/conversations.py @@ -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:``. 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:``. 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"] diff --git a/plugin/pheby/hermes_bridge.py b/plugin/pheby/hermes_bridge.py new file mode 100644 index 0000000..a0f8bda --- /dev/null +++ b/plugin/pheby/hermes_bridge.py @@ -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::dm:``). + """ + 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", +] diff --git a/plugin/pheby/plugin.yaml b/plugin/pheby/plugin.yaml new file mode 100644 index 0000000..a72a126 --- /dev/null +++ b/plugin/pheby/plugin.yaml @@ -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 diff --git a/plugin/pheby/protocol.py b/plugin/pheby/protocol.py new file mode 100644 index 0000000..8fe1359 --- /dev/null +++ b/plugin/pheby/protocol.py @@ -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", +] diff --git a/plugin/pheby/server.py b/plugin/pheby/server.py new file mode 100644 index 0000000..dd7d57c --- /dev/null +++ b/plugin/pheby/server.py @@ -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"] diff --git a/plugin/pheby/ws_client.py b/plugin/pheby/ws_client.py new file mode 100644 index 0000000..8db4456 --- /dev/null +++ b/plugin/pheby/ws_client.py @@ -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 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..fc21501 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = + -o asyncio_mode=auto +testpaths = tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c537e71 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_pheby.py b/tests/test_pheby.py new file mode 100644 index 0000000..cf3e4e1 --- /dev/null +++ b/tests/test_pheby.py @@ -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"