Files
iris_x_hermes/docs/03-gateway-plugin.md
ARIA b1c9bac7d8
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
docs+plugin: HTTP-only transport cleanup, install guide, review fixes
- docs/install.md: new end-to-end guide for non-technical users
  (gateway install, app install, LAN/TLS/remote connection, push,
  options, troubleshooting); docs/setup.md now points to it
- README: new 'Install the gateway' section; pairing section updated
  for HTTP transport (8791, QR scan on Android)
- rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat
  fallback); drop dead DEFAULT_PORT=8790
- setup.py: advertise https:// in the printed/QR server URL when
  IRIS_HTTP_CERT is set
- ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env
  IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme
- plugin.yaml: IRIS_HTTP_* env names, description no longer says
  'WebSocket server'
- docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites,
  8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the
  only transport)
- AGENTS.md: symlink name android -> iris (matches actual install)
- test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy
  IRIS_WS_* names are not consulted (95/95 pass)
2026-08-24 22:22:02 +02:00

271 lines
13 KiB
Markdown

# 03 — Gateway Plugin (Python)
The plugin is a **community-style hermes platform plugin** named `iris`.
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
`httpx` are already core deps).
> Source map for every hermes integration point: see `15-hermes-reference.md`.
## 3.1 `plugin.yaml` (manifest)
```yaml
name: iris-platform
label: Iris
kind: platform
version: 0.1.0
description: >
Native Android / Desktop client gateway adapter for Hermes Agent.
Runs a WebSocket server inside the gateway; the app connects with a
pairing token. Supports streaming, reasoning, structured tool events,
channels/threads, media, FTS5 search, and FCM/ntfy push.
author: <you>
requires_env:
- name: IRIS_TOKEN
description: "Shared pairing token the app presents on connect"
prompt: "Iris pairing token"
password: true
optional_env:
- name: IRIS_HTTP_HOST
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "HTTP host"
password: false
- name: IRIS_HTTP_PORT
description: "HTTP port (default 8791)"
prompt: "HTTP port"
password: false
- name: IRIS_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default default)"
prompt: "Home channel"
password: false
- name: IRIS_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids"
password: false
- name: IRIS_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)"
password: false
- name: IRIS_PUSH_BACKEND
description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
prompt: "Push backend"
password: false
- name: IRIS_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path"
password: true
- name: IRIS_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key"
password: true
- name: NTFY_TOPIC
description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
prompt: "ntfy topic"
password: false
- name: NTFY_SERVER_URL
description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL"
password: false
- name: IRIS_HTTP_CERT
description: "TLS cert path for HTTPS (optional)"
prompt: "HTTPS cert"
password: false
- name: IRIS_HTTP_KEY
description: "TLS key path for HTTPS (optional)"
prompt: "HTTPS key"
password: false
```
Behavioral (non-secret) settings live in `config.yaml` under
`gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
only.)
## 3.2 `register(ctx)` entry point
```python
def register(ctx):
ctx.register_platform(
name="iris",
label="Iris",
adapter_factory=lambda cfg: IrisAdapter(cfg),
check_fn=check_requirements, # passive: websockets importable + token set
validate_config=validate_config, # host/port/token present
is_connected=is_connected,
required_env=["IRIS_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)",
setup_fn=interactive_setup, # hermes gateway setup flow
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
cron_deliver_env_var="IRIS_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
allowed_users_env="IRIS_ALLOWED_USERS",
allow_all_env="IRIS_ALLOW_ALL_USERS",
max_message_length=0, # 0 = no limit (WS has none)
emoji="📱",
pii_safe=False,
platform_hint=(
"You are chatting with the user through their native Iris app "
"(Android/Desktop). It renders Markdown, inline code, images, "
"audio and video, and shows your reasoning and tool activity. "
"Conversations are organized into channels and optional threads. "
"Keep formatting rich but readable."
),
)
```
Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
`adapter_factory`, `check_fn`, `validate_config`, `is_connected`, `required_env`,
`install_hint`, `setup_fn`, `env_enablement_fn`, `apply_yaml_config_fn`,
`cron_deliver_env_var`, `parse_target_ref_fn`, `allowed_users_env`,
`allow_all_env`, `max_message_length`, `pii_safe`, `platform_hint`, `emoji`,
`ensure_deps_fn`.
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
`IRIS_TOKEN` is set. Never installs.
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
(host/port/home_channel/push_backend) + a `home_channel` key
`{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
and cron home-channel resolution work without instantiating the adapter.
- **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
`ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional
`:t_<n>` thread suffix; friendly names resolve via the channel directory.
Returns `(chat_id, thread_id)` or `None`.
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
push backend + credentials, prints a QR code (pairing) and the app URL.
## 3.3 `IrisAdapter(BasePlatformAdapter)`
Constructor: `super().__init__(config=config, platform=Platform("iris"))`.
Reads `config.extra` (env overrides win). Initializes: WS server (not started
until `connect()`), connection registry, outbox (SQLite under
`get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
### Lifecycle
- **`connect(*, is_reconnect=False) -> bool`**
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`)
so two profiles can't bind the same port/identity.
- Start the `websockets` server on `host:port` (TLS if cert/key set).
- `_mark_connected()`; return True.
- **`disconnect()`**
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
### Inbound (app → agent)
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
media_urls=[cached paths], media_types=[…], reply_to_message_id=…)` →
`await self.handle_message(event)`.
- Slash commands arrive as plain text starting with `/`; the gateway's command
pipeline resolves + dispatches them (no special handling needed).
- `picker.select {picker_id, value}` → route to the gateway-side resolvers
(model picker / choice picker / clarify / approval / slash-confirm) using the
shared callback-id conventions (`cl:<id>:<idx>`, `appr:<id>:<choice>`,
`sc:<choice>:<id>`).
- `channel.create` / `channel.rename` / `channel.set_default` → mutate the
channel directory (SQLite) + emit `channel.*` frames to all devices.
- `search {query, scope, chat_id?, thread_id?}` → `search.py` → `search.results`.
- `media.upload` (chunked) → `media.py` → `cache_*_from_bytes` → `media_ref`.
- `media.pull {media_id}` → stream cached bytes as binary frames.
- `fcm.register {token}` / `hello` → update device registry.
- `read.receipt {message_id}` → mark delivered/read (drives ✓✓), ack.
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
### Outbound (agent → app)
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
- If **any** device is connected: broadcast `message` frame to all.
- Else (no live devices): append to **outbox** + fire **push** (FCM/ntfy).
- Return `SendResult(success=True, message_id=<id>)`.
- **`edit_message(chat_id, message_id, content)`** → `message.update` frame
(drives streaming). If no device, no-op (outbox holds the final `send`).
- **`send_typing(chat_id, metadata=None)`** → `typing` frame.
- **`get_chat_info(chat_id) -> dict`** → `{"name": <channel name>, "type": "dm"|"channel"}`
from the channel directory.
- **Media send** — `send_image / send_video / send_document / send_voice /
send_image_file / send_multiple_images`: stage the file in the media cache,
mint a `media_id`, emit `media.offer {media_id, mime, size, filename, kind}`,
serve bytes on `media.pull`. (Base-class `extract_media`/`extract_images`
already pull `MEDIA:`/image tags out of agent text and call these.)
- **Interactive pickers** — `send_model_picker(...)`, `send_choice_picker(...)`,
`send_clarify(...)`, `send_exec_approval(...)`, `send_slash_confirm(...)`:
emit `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` /
`picker.confirm` frames with options; store pending state keyed by
`picker_id`; resolve on `picker.select`.
- **`create_handoff_thread(chat_id, name)`** → create a thread id, register in
channel directory, return it (used by cron "continuable" threads).
### Streaming hooks
The main gateway drives delivery through the **legacy callback path**:
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
`edit_message()` (updates) → `message.start` / `message.update`.
- `tool_progress_callback` → progress queue → `send_progress_messages` →
`send()` → `tool.*` frames (structured; classified in the adapter).
- `interim_assistant_callback` → consumer `on_commentary` → `send()` →
`commentary` frames.
The adapter tracks per-chat **turn state** (in-turn, current streaming
`message_id`, last tool index) to classify outbound `send()` calls into
`message` vs `tool.*` vs `commentary`. The exact classification markers are
verified empirically in M2 (see `13-testing.md`).
## 3.4 HTTP server (`http_server.py`)
- Library: **stdlib `http.server`** (`ThreadingHTTPServer` +
`BaseHTTPRequestHandler`) in a daemon thread; bridges into the gateway's
asyncio loop via `asyncio.run_coroutine_threadsafe`. Optional TLS via
`ssl.SSLContext` (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`). Full design:
`19-http-fallback-transport.md`.
- **Auth:** `Authorization: Bearer <token>` (constant-time `verify_token`)
- device allowlist via `X-Iris-Device`; `401` on failure.
- **Endpoints:** `GET /v1/health` (unauthenticated liveness),
`POST /v1/frame` (any JSON frame the protocol accepts),
`GET /v1/events?cursor=N` (SSE: outbox catch-up + live frames),
`GET /v1/poll?cursor=N` (long-poll fallback), `POST /v1/media` +
`GET /v1/media/{id}` (media upload/pull).
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
devices (no per-chat subscribe; single-user model). Global frames
(`channel.*`, `status`) also broadcast to all.
- **Limits:** 64 KiB request body cap, per-device token-bucket rate limit
(20/s, burst 40) → `429`; media uploads bounded by the per-upload total
cap. No CORS (app clients only).
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
> Never hardcode `~/.hermes`.
- `devices.db` — device registry (device_id, name, caps, fcm_token, ntfy_topic,
last_seen, created).
- `channels.db` — channel directory (chat_id, name, kind: default|channel|thread,
parent_chat_id, created, is_default).
- `outbox.db` — undelivered frames per chat_id + monotonic cursor.
- `media/` — inbound + outbound media cache (reuse hermes `cache_*_from_bytes`
dirs where possible).
## 3.6 Config resolution
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`IRIS_FCM_SERVER_KEY`, `IRIS_HTTP_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
`http_port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `http_cert`/`http_key`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
so multiplexed profiles don't leak each other's tokens.
## 3.7 Failure & lifecycle safety
- HTTP server bind failure → non-fatal: log a warning, disable the HTTP leg,
show it in the inspector (the plugin keeps working for other platforms).
- All outbound sends are best-effort; a dead stream latches and the frame
falls to the outbox.
- `disconnect()` stops the HTTP server and closes streams cleanly.
- Token/PII redaction in all logs (hermes PII policy).