Files
Pheby 53ffdb8aa2 feat: Pheby platform adapter/plugin for Hermes (protocol v1)
Third-party Hermes platform plugin serving the Pheby WebSocket+HTTPS
protocol for the native Android client, behind Caddy:

- Platform adapter (BasePlatformAdapter subclass) registered via the
  documented plugin system (plugins.platforms pattern, kind: platform)
- Multiple conversations mapped to Hermes sessions (authoritative state)
- Streaming chat via GatewayStreamConsumer edit path (message.delta)
- Structured tool events (never fake tool text in chat), incl.
  post_tool_call hook relay with Hermes tool_call_ids
- Native approval + clarification round-trips via tools.approval /
  tools.clarify_gateway primitives
- Attachment delivery: adapter-owned copies, opaque IDs, persistent
  metadata, 7-day retention + safe cleanup, authenticated HTTPS download
- Model + reasoning-effort query/change via Hermes picker data and
  session/global overrides
- Shared-secret auth (constant-time, lockout), size limits, path-
  traversal-proof attachment resolution, minimal health endpoint
- Protocol v1 spec with JSON examples (docs/PROTOCOL.md)
- Install/config/Caddy/security docs + discovered Hermes limitations
- 37 passing tests (auth, conversations, protocol, attachments incl.
  expiry/traversal, tool events, approvals, clarifications, cancel,
  reconnect re-sync, live WS smoke tests) — gateway-free fakes

No Hermes core modifications required.
2026-09-02 19:31:17 +00:00

180 lines
5.9 KiB
Python

"""Pheby plugin configuration.
Secrets come from environment variables (Hermes convention: ``~/.hermes/.env``
is loaded by Hermes itself before plugins load). Non-secret behavior lives in
the platform's ``extra`` block in ``config.yaml`` under ``platforms.pheby``.
Resolution precedence for every key (highest wins):
1. environment variable (secrets *must* come from env)
2. ``platforms.pheby.extra.<key>`` in config.yaml
3. built-in default
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
# Environment variable names
ENV_SECRET = "PHEBY_SECRET" # shared credential (required to serve)
ENV_BIND_HOST = "PHEBY_BIND_HOST"
ENV_PORT = "PHEBY_PORT"
ENV_DEBUG = "PHEBY_DEBUG"
ENV_LOG_CHAT_CONTENT = "PHEBY_LOG_CHAT_CONTENT"
# Defaults
DEFAULT_BIND_HOST = "127.0.0.1" # safe behind a local Caddy reverse proxy
DEFAULT_PORT = 8620
DEFAULT_RETENTION_DAYS = 7
DEFAULT_MAX_SECRET_LEN = 1024
@dataclass
class PhebyConfig:
"""Resolved runtime configuration for the Pheby server + adapter."""
# Shared secret for WS/HTTP auth. Empty disables the adapter entirely.
secret: str = ""
bind_host: str = DEFAULT_BIND_HOST
port: int = DEFAULT_PORT
# Attachment storage directory; a per-instance subdir is created inside.
storage_dir: str = ""
# Attachment retention in days (0 = keep forever — not recommended).
retention_days: int = DEFAULT_RETENTION_DAYS
# Verbose protocol logging (still never logs secrets).
debug: bool = False
# When True, debug logs MAY include chat text and tool previews.
log_chat_content: bool = False
# Path to a persistent JSON index of registered attachments. Empty =
# derive from storage_dir.
index_path: str = ""
# Extra config passthrough (whole ``extra`` dict) for future keys.
extra: Dict[str, Any] = field(default_factory=dict)
# ------------------------------------------------------------------
@property
def enabled(self) -> bool:
"""The adapter only serves when a secret is configured."""
return bool(self.secret)
@property
def attachments_root(self) -> str:
if self.storage_dir:
return self.storage_dir
# Resolved lazily by the attachment store (needs get_hermes_home).
return ""
def _env_bool(name: str) -> bool:
return os.getenv(name, "").strip().lower() in ("1", "true", "yes", "on")
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def load_config(extra: Optional[Dict[str, Any]] = None) -> PhebyConfig:
"""Build a :class:`PhebyConfig` from env + ``platforms.pheby.extra``.
Environment variables win over YAML ``extra`` keys. Never raises.
"""
extra = dict(extra or {})
def _pick(env_name: str, key: str, default: Any = "") -> Any:
env = os.getenv(env_name, "")
if env.strip():
return env.strip()
val = extra.get(key)
if val is None or (isinstance(val, str) and not val.strip()):
return default
return val
secret = os.getenv(ENV_SECRET, "").strip()
if not secret:
# A YAML secret is allowed for local testing but strongly discouraged;
# env always wins and docs recommend env-only.
secret = str(extra.get("secret", "") or "").strip()
if len(secret) > DEFAULT_MAX_SECRET_LEN:
secret = secret[:DEFAULT_MAX_SECRET_LEN]
port = _coerce_int(
_pick(ENV_PORT, "port", DEFAULT_PORT), DEFAULT_PORT)
if not (0 < port < 65536):
port = DEFAULT_PORT
retention = _coerce_int(
_pick("", "attachment_retention_days", DEFAULT_RETENTION_DAYS),
DEFAULT_RETENTION_DAYS)
if retention < 0:
retention = DEFAULT_RETENTION_DAYS
debug = _env_bool(ENV_DEBUG) or bool(extra.get("debug", False))
log_chat = _env_bool(ENV_LOG_CHAT_CONTENT) or bool(
extra.get("log_chat_content", False))
cfg = PhebyConfig(
secret=secret,
bind_host=str(_pick(ENV_BIND_HOST, "bind_host", DEFAULT_BIND_HOST)),
port=port,
storage_dir=str(_pick("", "attachment_storage_dir", "") or ""),
retention_days=retention,
debug=bool(debug),
log_chat_content=bool(log_chat),
index_path=str(_pick("", "attachment_index_path", "") or ""),
extra=extra,
)
return cfg
def check_requirements() -> bool:
"""Platform-entry dependency check: aiohttp available + secret set."""
try:
import aiohttp # noqa: F401
except ImportError:
return False
return bool(os.getenv(ENV_SECRET, "").strip())
def validate_config(config: Any) -> bool:
"""Gateway config validation: at minimum a secret must be resolvable."""
extra = getattr(config, "extra", {}) or {}
if os.getenv(ENV_SECRET, "").strip():
return True
return bool(str(extra.get("secret", "") or "").strip())
def is_connected(config: Any) -> bool:
"""True when Pheby is configured (env or config.yaml)."""
return validate_config(config)
def env_enablement() -> Optional[Dict[str, Any]]:
"""Seed ``PlatformConfig.extra`` from env for env-only setups.
Mirrors the ntfy adapter pattern so ``hermes gateway status`` reflects a
PHEBY_SECRET-only deployment without instantiating the server.
"""
secret = os.getenv(ENV_SECRET, "").strip()
if not secret:
return None
seed: Dict[str, Any] = {}
host = os.getenv(ENV_BIND_HOST, "").strip()
if host:
seed["bind_host"] = host
port = os.getenv(ENV_PORT, "").strip()
if port:
seed["port"] = port
return seed
__all__ = [
"ENV_SECRET", "ENV_BIND_HOST", "ENV_PORT", "ENV_DEBUG",
"ENV_LOG_CHAT_CONTENT", "DEFAULT_BIND_HOST", "DEFAULT_PORT",
"DEFAULT_RETENTION_DAYS", "PhebyConfig", "load_config",
"check_requirements", "validate_config", "is_connected", "env_enablement",
]