Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s

This commit is contained in:
ARIA committed 2026-08-22 22:43:13 +02:00
1 parent 27dc7917f2
commit 7a6d922d12
63 files changed
+2073 -630

No files matched your search

+42 -35
View File
@@ -1,6 +1,6 @@
# 03 — Gateway Plugin (Python)
The plugin is a **community-style hermes platform plugin** named `android`.
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
@@ -11,7 +11,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
## 3.1 `plugin.yaml` (manifest)
```yaml
name: android-platform
name: iris-platform
label: Android
kind: platform
version: 0.1.0
@@ -22,63 +22,63 @@ description: >
channels/threads, media, FTS5 search, and FCM/ntfy push.
author: <you>
requires_env:
- name: ANDROID_TOKEN
- name: IRIS_TOKEN
description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token"
password: true
optional_env:
- name: ANDROID_WS_HOST
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: ANDROID_WS_PORT
- name: IRIS_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
password: false
- name: ANDROID_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default android:default)"
description: "Default chat id for cron/notification delivery (default default)"
prompt: "Home channel"
password: false
- name: ANDROID_ALLOWED_USERS
- name: IRIS_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids"
password: false
- name: ANDROID_ALLOW_ALL_USERS
- name: IRIS_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)"
password: false
- name: ANDROID_PUSH_BACKEND
- name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend"
password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT
- name: IRIS_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path"
password: true
- name: ANDROID_FCM_SERVER_KEY
- 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 ANDROID_PUSH_BACKEND=ntfy)"
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: ANDROID_WS_CERT
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: ANDROID_WS_KEY
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
```
Behavioral (non-secret) settings live in `config.yaml` under
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention,
`gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
only.)
@@ -87,21 +87,21 @@ only.)
```python
def register(ctx):
ctx.register_platform(
name="android",
label="Android",
adapter_factory=lambda cfg: AndroidAdapter(cfg),
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=["ANDROID_TOKEN"],
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="ANDROID_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
allowed_users_env="ANDROID_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS",
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,
@@ -123,26 +123,29 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
`ensure_deps_fn`.
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
`ANDROID_TOKEN` is set. Never installs.
`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": "android:default", "name": "Default"}` so `hermes gateway status`
`{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
and cron home-channel resolution work without instantiating the adapter.
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`.
- **`_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 `AndroidAdapter(BasePlatformAdapter)`
## 3.3 `IrisAdapter(BasePlatformAdapter)`
Constructor: `super().__init__(config=config, platform=Platform("android"))`.
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()/"android"`), push backend, pairing store, channel directory.
`get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
### Lifecycle
- **`connect(*, is_reconnect=False) -> bool`**
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`)
- 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.
@@ -150,6 +153,7 @@ until `connect()`), connection registry, outbox (SQLite under
- 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=…,
@@ -171,6 +175,7 @@ until `connect()`), connection registry, outbox (SQLite under
- `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.
@@ -195,7 +200,9 @@ until `connect()`), connection registry, outbox (SQLite under
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` →
@@ -230,7 +237,7 @@ verified empirically in M2 (see `13-testing.md`).
`message.update` (coalesce to latest) under pressure, never drop
`message`/`tool.end`/`notification`.
## 3.5 State & storage (all under `get_hermes_home()/"android"`)
## 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`.
@@ -245,9 +252,9 @@ verified empirically in M2 (see `13-testing.md`).
## 3.6 Config resolution
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the
@@ -260,4 +267,4 @@ verified empirically in M2 (see `13-testing.md`).
- All outbound sends are best-effort; a dead socket latches and the frame falls
to the outbox.
- `disconnect()` cancels the server task and closes sockets cleanly.
- Token/PII redaction in all logs (hermes PII policy).
- Token/PII redaction in all logs (hermes PII policy).