docs+plugin: HTTP-only transport cleanup, install guide, review fixes
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s

- 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)
This commit is contained in:
ARIA committed 2026-08-24 22:22:02 +02:00
1 parent a61b47a947
commit b1c9bac7d8
18 files changed
+472 -317

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
- **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine).
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout
@@ -26,7 +26,7 @@
## Environment / pairing quirks
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_HOST` to the gateway's LAN IP.
- HTTP default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_HTTP_HOST` to the gateway's LAN IP.
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
+44 -19
View File
@@ -2,7 +2,7 @@
A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
Iris pairs with your running `hermes gateway` over a private WebSocket and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
Iris pairs with your running `hermes gateway` over a private, token-authenticated connection and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
## Features
@@ -29,11 +29,11 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
## How it works
```
hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (Android / Desktop)
hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android / Desktop)
```
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
`hermes gateway` process and opens a WebSocket server the apps connect to.
`hermes gateway` process and opens an HTTP server the apps connect to.
Zero new Python dependencies, zero hermes-core changes.
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
@@ -55,9 +55,31 @@ outbox, so nothing is lost.
metadata (notification title, device token) is routed through **Google's
servers**. If you want truly private communication, use ntfy instead.
Setup: [`docs/setup.md`](docs/setup.md) §4; details:
Setup: [`docs/install.md`](docs/install.md); details:
[`docs/08-push.md`](docs/08-push.md).
## Install the gateway
Three commands on the machine where hermes runs:
```bash
cd hermes-agent && uv sync # 1. hermes with its venv (separate project, not this repo)
# 2. install the Iris plugin — the #gateway-plugin suffix points the
# installer at the plugin subfolder of this monorepo
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
# 3. generate the pairing token + server URL, then run the gateway
hermes gateway setup
hermes gateway
```
`hermes gateway setup` prints the **server URL** and **pairing token / QR**
the app needs on its Connect screen.
All options (LAN binding, TLS, push, device allowlist) and the full app
pairing walkthrough: [`docs/install.md`](docs/install.md).
## Build from source
### Prerequisites
@@ -72,19 +94,21 @@ No system Gradle needed — both apps use the project wrapper (`./gradlew`).
### 1. Gateway (on the gateway host)
If you're developing from a checkout, skip `hermes plugins install` and
symlink the plugin so it always tracks your working tree:
```bash
# hermes-agent is a separate project (not part of this repo)
cd hermes-agent && uv sync
# install the Iris plugin into the live hermes home
mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
hermes gateway status # should list "android"
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list the Iris platform
hermes gateway setup # generates ANDROID_TOKEN, prints the server URL
hermes gateway setup # generates IRIS_TOKEN, prints server URL + pairing QR
hermes gateway # run the gateway
```
(Otherwise see [Install the gateway](#install-the-gateway) above.)
### 2. Android app
```bash
@@ -106,21 +130,22 @@ cd app
On the app's **Connect** screen:
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`).
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env`
1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
on the gateway host.
3. **Test & Connect.**
Notes:
- The app has **no QR scanner** — pairing is manual URL + token entry.
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
- **Android** has a **Scan QR** button that reads the QR printed by
`hermes gateway setup` and pre-fills URL + token; desktop uses manual entry.
- The default bind is `127.0.0.1` (desktop on the same machine only). For a
phone on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
- Remote access: Tailscale/WireGuard, or a reverse proxy/tunnel with TLS
(`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`).
Full walkthrough, push setup (ntfy/FCM), and troubleshooting:
[`docs/setup.md`](docs/setup.md).
Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
[`docs/install.md`](docs/install.md).
## Contributing
@@ -14,7 +14,7 @@ import javax.net.ssl.X509TrustManager
/**
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
*
* A gateway behind `IRIS_HTTP_CERT`/`IRIS_WS_CERT` may present a
* A gateway behind `IRIS_HTTP_CERT` may present a
* self-signed certificate the platform doesn't trust. Instead of forcing the
* user to install it into the system trust store, the app shows the
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
+36 -36
View File
@@ -27,13 +27,13 @@ requires_env:
prompt: "Iris pairing token"
password: true
optional_env:
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
- 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_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
- 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)"
@@ -67,13 +67,13 @@ optional_env:
description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL"
password: false
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
- name: IRIS_HTTP_CERT
description: "TLS cert path for HTTPS (optional)"
prompt: "HTTPS cert"
password: false
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
- name: IRIS_HTTP_KEY
description: "TLS key path for HTTPS (optional)"
prompt: "HTTPS key"
password: false
```
@@ -215,27 +215,26 @@ The adapter tracks per-chat **turn state** (in-turn, current streaming
`message` vs `tool.*` vs `commentary`. The exact classification markers are
verified empirically in M2 (see `13-testing.md`).
## 3.4 WebSocket server (`ws_server.py`)
## 3.4 HTTP server (`http_server.py`)
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host,
port, ssl=ctx)`.
- **Handler** per connection:
1. Await first frame; must be `hello {token, device_id, device_name, caps,
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
`error {code:"auth"}` and close.
2. On success: register in connection registry
(`device_id → {ws, caps, fcm_token}`), send
`hello.ack {server_caps, sync_cursor, channels[]}`.
3. Loop: decode frames, dispatch to adapter inbound handlers.
4. On close: deregister; if no devices remain, ensure pending outbox
frames have push fired.
- 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.
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
- **Backpressure:** per-connection send queue with a bounded buffer; drop
`message.update` (coalesce to latest) under pressure, never drop
`message`/`tool.end`/`notification`.
- **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"`)
@@ -253,18 +252,19 @@ verified empirically in M2 (see `13-testing.md`).
## 3.6 Config resolution
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
`IRIS_FCM_SERVER_KEY`, `IRIS_HTTP_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`.
`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
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
- 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.
- 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).
+15 -15
View File
@@ -87,9 +87,9 @@ security principal (the token is).
## 9.4 Transport security
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
- **Default (LAN/dev):** plain `http://` on the trusted LAN. Fine for a home
network.
- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY`
- **HTTPS (recommended for remote):** set `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`
(self-signed or CA-signed). CA-signed certs work out of the box.
For a **self-signed** cert the app shows its SHA-256 fingerprint on first
pair (like a SSH host key); once the user confirms it, the fingerprint is
@@ -105,14 +105,14 @@ security principal (the token is).
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
app connects over the private mesh. No public exposure.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
at the edge, forward WS to `127.0.0.1:8790`.
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's
fallback transport. It is a *second door with the same lock*: the same
Bearer token (constant-time `verify_token`) + the same device allowlist
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
the WS. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
at the edge, forward to `127.0.0.1:8791`.
- **Public bind** (`0.0.0.0`) + HTTPS + strong token — last resort.
- **HTTP transport (docs/19):** the gateway serves the same frames over plain
HTTP (`IRIS_HTTP_PORT`, default 8791) — the only device-facing transport.
It shares the same lock as everything else: the same Bearer token
(constant-time `verify_token`) + the same device allowlist
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit.
Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
`GET /v1/health` is unauthenticated by design (liveness only — it must
not reflect tokens, device ids, or versions).
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
@@ -159,14 +159,14 @@ M7 research pass. "verified" = implemented and covered by
| # | Item | Status | Evidence / mitigation |
| --- | ------ | -------- | ----------------------- |
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) |
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`http_server.py`, `broadcast`/`send_to`). Inbound: per-device token bucket on JSON frames (20/s, burst 40) → `429` on exceed (`http_server.py`, `_rate_limited`); media uploads bounded by the per-request body cap + per-upload total cap (see gap 1) |
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | Per-request body cap in `http_server.py`; per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
| 6 | WSS + cert pinning for remote | implemented | WSS supported server-side (`IRIS_WS_CERT`/`IRIS_WS_KEY`, `ws_server.py:122`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `ws://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
| 6 | HTTPS + cert pinning for remote | implemented | TLS supported server-side (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`, `http_server.py`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `http://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_WS_CERT`/`IRIS_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap |
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: per-device token bucket in `http_server.py` (`_rate_limited`). Media uploads are bounded by the per-request body cap + the per-upload total cap |
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
| 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
+14 -15
View File
@@ -101,8 +101,8 @@ IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
# NTFY_TOPIC=iris-push # when ntfy
# NTFY_SERVER_URL=https://ntfy.sh
# IRIS_WS_CERT=/path/cert.pem # WSS
# IRIS_WS_KEY=/path/key.pem
# IRIS_HTTP_CERT=/path/cert.pem # HTTPS
# IRIS_HTTP_KEY=/path/key.pem
```
**Behavioral (`~/.hermes/config.yaml`):**
@@ -114,7 +114,7 @@ gateway:
enabled: true
extra:
host: 127.0.0.1 # 0.0.0.0 for LAN
port: 8790
http_port: 8791
home_channel: default
push_backend: fcm
outbox_retention_hours: 72
@@ -134,18 +134,17 @@ display:
# 1. gateway up with plugin
hermes gateway status | grep -i iris
# 2. a raw WS client can pair + echo
python - <<'PY'
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
PY
# 2. the HTTP server answers (unauthenticated liveness)
curl -s http://127.0.0.1:8791/v1/health
# -> {"ok": true}
# 3. a frame round-trip with the pairing token
curl -s -X POST http://127.0.0.1:8791/v1/frame \
-H "Authorization: Bearer <IRIS_TOKEN>" -H "X-Iris-Device: probe" \
-H "Content-Type: application/json" \
-d '{"v":1,"type":"commands.catalog","id":1,"payload":{}}'
```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
Expect `{"ok": true}` from the health probe and a `commands.catalog` reply
frame from the POST. If you get `401`, the token/host/port is
wrong.
+7 -7
View File
@@ -113,13 +113,13 @@ to the WS server.
scale). The handler thread never touches adapter state directly; it bridges
into the gateway's asyncio loop with
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
start, same loop the WS server runs on).
- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
(`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
trusted LAN by default, TLS for remote/Tailscale setups.
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
HTTP leg, show it in the inspector. The plugin must keep working WS-only.
start).
- **Config:** `IRIS_HTTP_PORT` (default **8791**), bind host `IRIS_HTTP_HOST`.
Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
(`ssl.SSLContext` on the server): plaintext on a trusted LAN by default,
TLS for remote/Tailscale setups.
- **Bind failure is NON-fatal:** log a warning, disable the HTTP leg, show it
in the inspector.
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
### Endpoints
+5
View File
@@ -18,6 +18,11 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
---
## User-facing guides
- [`install.md`](install.md) — **install the gateway + connect the app** (non-technical walkthrough, all options, TLS, push).
- [`setup.md`](setup.md) — moved; pointer to `install.md`.
## Reading order
| # | File | When to read |
+246
View File
@@ -0,0 +1,246 @@
# Install — Gateway & App
A step-by-step guide for getting **Iris** (the Android / Desktop app) talking
to your **hermes gateway**. Written for people who just want to *use* it, not
build it. If you only want the short version, the [README](../README.md) has
the three commands that matter.
The whole setup has two halves:
1. **The gateway** — a small plugin that runs *inside* your existing hermes
install and opens a door for the app to connect through.
2. **The app** — on your phone or desktop, where you enter the gateway's
address and a pairing token.
> **Old guides?** Earlier versions of Iris used a WebSocket on port `8790`
> (`ws://…/ws`). The transport is now plain HTTP on port **`8791`**
> (see [`19-http-fallback-transport.md`](19-http-fallback-transport.md)).
> The app still accepts old `ws://` URLs and converts them automatically, but
> new setups should use the `http://` URL printed by `hermes gateway setup`.
---
## What you need
| Where | What |
| --- | --- |
| Gateway host (any always-on computer: home server, Raspberry Pi, laptop) | [hermes-agent](https://github.com/NousResearch/hermes-agent) installed with its venv (`cd hermes-agent && uv sync`) |
| Phone / desktop | Android 8+ or Linux / macOS / Windows |
| Only if you build the app yourself | JDK 17 (+ Android SDK for Android) — see [`12-toolchain.md`](12-toolchain.md) |
---
## Part 1 — Install the gateway plugin (one-time)
Iris is a regular hermes **platform plugin**, so it installs with the normal
plugin command. This repo is a *monorepo* (the plugin lives in the
`gateway-plugin/` subfolder, next to the app), so you point the installer at
that subfolder with a `#subfolder` suffix:
```bash
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
```
That's it. The installer clones the repo, copies just the `gateway-plugin/`
folder into `~/.hermes/plugins/`, and asks whether to enable it now (say
**yes**).
Notes:
- Any git URL works with the `#gateway-plugin` suffix — e.g.
`https://gitea.zephyre.one/ARIA/iris_x_hermes.git#gateway-plugin` if you
prefer HTTPS.
- **Developing from a checkout?** Skip the install and symlink instead — the
plugin then always tracks your working tree:
```bash
mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
```
- Check it was picked up:
```bash
hermes gateway status # the Iris platform should be listed
```
## Part 2 — Gateway setup (one-time)
Run the interactive setup:
```bash
hermes gateway setup
```
It walks you through four things:
| Prompt | What it means | Default |
| --- | --- | --- |
| **Iris pairing token** | A long random secret the app must present to connect. Generated for you; stored in `~/.hermes/.env` as `IRIS_TOKEN`. **It is printed only once** — write it down. | auto-generated |
| **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` |
| **Port** | The port the app connects to. | `8791` |
| **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` |
When it finishes it prints two things you need for the app:
- **Server URL** — e.g. `http://192.168.1.10:8791`
- **Pairing QR + URL** — an `iris://pair?…` string with a scannable QR code
Then start the gateway:
```bash
hermes gateway # (or: hermes gateway restart after changes)
```
## Part 3 — Install the app
### Android
Build a debug APK on any machine with JDK 17 + the Android SDK:
```bash
cd app
./gradlew :androidApp:assembleDebug
# → app/androidApp/build/outputs/apk/debug/androidApp-debug.apk
```
Copy the APK to the phone (cable, LAN share, or any file transfer) and open
it — Android will ask to allow installs from unknown sources.
*Shortcut for developers with a USB-connected phone:*
`./gradlew :androidApp:installDebug` installs it directly.
### Desktop
```bash
cd app
./gradlew :desktopApp:jpackage # → app/desktopApp/build/…/ (native app, JRE bundled)
```
On Linux the launcher may print a `pure virtual method called` warning —
it's a known, harmless jpackage bug (JDK-8348560); the app works fine.
## Part 4 — Connect the app
Open the app. The first screen is **Connect**. You need the **Server URL**
and the **pairing token** from Part 2.
### Option A — Same home network (no encryption, simplest)
Works out of the box on a trusted home network:
1. **Server URL:** the one printed by `hermes gateway setup`,
e.g. `http://192.168.1.10:8791`.
- On a phone, use the gateway's **LAN IP** — not `127.0.0.1` (that only
means "this device" and won't reach your server).
- If you set the host to `127.0.0.1` during setup, re-run
`hermes gateway setup` and enter the LAN IP instead.
2. **Pairing token:** the long token from the setup output (or
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host).
3. **Test & Connect.**
> **Android shortcut:** the Connect screen has a **Scan QR** button — point
> the camera at the QR printed by `hermes gateway setup` and the URL + token
> fill themselves in. (Desktop has no camera, so it's manual entry.)
### Option B — Encrypted (TLS) — recommended for anything beyond your LAN
Plain `http://` is fine on a home network you trust, but for remote access
you want the traffic encrypted. The gateway can serve `https://` itself:
1. Create a certificate + key. Two flavors:
- **CA-signed** (Let's Encrypt, or your own CA): works out of the box.
- **Self-signed** (e.g. `openssl req -x509 -newkey rsa:2048 -nodes
-keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris"
-addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`):
the certificate **must** carry a SAN entry matching the host you'll
type in the app.
2. Put the paths in `~/.hermes/.env` on the gateway host:
```ini
IRIS_HTTP_CERT=/path/to/iris.crt
IRIS_HTTP_KEY=/path/to/iris.key
```
3. `hermes gateway restart`.
4. In the app, use the **`https://`** URL, e.g. `https://iris.example.com:8791`.
**Self-signed certificates:** the app won't trust them automatically (by
design). On first connect it shows the certificate's SHA-256 fingerprint and
asks you to confirm it — exactly like an SSH host key. Compare the
fingerprint with the one on the gateway host
(`openssl x509 -fingerprint -sha256 -noout -in iris.crt`), confirm, and it's
pinned in the app's secure storage from then on. If the certificate ever
changes, you'll be asked to confirm again. No system trust-store installs
needed.
### Reaching the gateway from outside your home network
Pick one (in order of preference):
- **Tailscale / WireGuard (recommended).** Install Tailscale on the gateway
host; the app connects to the stable tailnet IP, e.g.
`http://100.x.y.z:8791`. No public exposure at all — and since the traffic
travels inside the encrypted mesh, plain `http://` is acceptable here.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok). Terminate TLS
at the edge and forward to `127.0.0.1:8791` on the gateway host.
- **Public bind + TLS + strong token** (`IRIS_HTTP_HOST=0.0.0.0` + Option B).
Last resort — the port is then reachable from the internet; the token and
TLS are what protect it.
## Part 5 — Push notifications (optional)
Push wakes a backgrounded or offline phone so you see replies even when the
app is closed. Nothing is lost either way — on reconnect the app syncs its
outbox.
- **ntfy (default)** — the phone generates its own topic automatically; the
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy**
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata
stays on your own infrastructure — this is the private option.
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
metadata (notification title, device token) is routed through **Google's
servers**. Needs a Firebase project + `google-services.json` in the app
build. Without it, FCM is inert and ntfy is the path.
Details: [`08-push.md`](08-push.md).
---
## Gateway options (reference)
Everything is configured in `~/.hermes/.env` on the gateway host (or via the
prompts of `hermes gateway setup`). After changes: `hermes gateway restart`.
| Variable | What it does | Default |
| --- | --- | --- |
| `IRIS_TOKEN` | Pairing token the app must present. | — (required) |
| `IRIS_HTTP_HOST` | Bind address. `127.0.0.1` = local only; LAN IP = same network; `0.0.0.0` = all interfaces. | `127.0.0.1` |
| `IRIS_HTTP_PORT` | Port the app connects to. | `8791` |
| `IRIS_HOME_CHANNEL` | Default chat for cron/notification delivery. | `default` |
| `IRIS_ALLOWED_USERS` | Comma-separated device ids allowed to connect (empty = token-only auth). | empty |
| `IRIS_ALLOW_ALL_USERS` | Allow any paired device (**dev only**). | `false` |
| `IRIS_PUSH_BACKEND` | `ntfy` or `fcm`. | `ntfy` |
| `IRIS_FCM_SERVICE_ACCOUNT` | Path to Firebase service-account JSON (FCM). | — |
| `IRIS_FCM_SERVER_KEY` | Legacy FCM server key (fallback). | — |
| `NTFY_SERVER_URL` | ntfy server. Self-hosting recommended. | `https://ntfy.sh` |
| `NTFY_AUTH_TOKEN` | Auth token for a private ntfy topic (real trust boundary). | — |
| `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY` | TLS cert/key paths → serves `https://` (see Part 4, Option B). | — |
Security model (tokens, device allowlist, transport):
[`09-pairing-security.md`](09-pairing-security.md).
---
## Troubleshooting
| Symptom | Likely cause / fix |
| --- | --- |
| `auth failed` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` (setup prints it only when it generates it). |
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8791`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
| Phone can't reach the gateway | Gateway bound to `127.0.0.1` — re-run `hermes gateway setup` and set the LAN IP; or the phone is on a different network/VLAN. |
| TLS handshake fails | Cert has no SAN matching the URL host; or the pinned fingerprint is stale after a cert change (re-confirm in the app). |
| Push not arriving | Backend not configured (check `~/.hermes/logs/gateway.log`); ntfy.sh flakiness — self-host ntfy. |
| Start over on a phone | `adb shell pm clear dev.iris.app` wipes the app's pairing state. |
Logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
+8 -178
View File
@@ -1,180 +1,10 @@
# Setup — Pairing a Device
User-facing guide: get a phone or desktop talking to your hermes gateway in
under 10 minutes. Design rationale lives in the numbered docs
([`09-pairing-security.md`](09-pairing-security.md),
[`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is
just the steps.
## Prerequisites
| Where | You need |
| --- | --- |
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) |
| Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device |
| Desktop build machine | JDK 17 only |
Gradle needs no system install — both apps use the project wrapper
(`./gradlew`). First-time machine setup: [`12-toolchain.md`](12-toolchain.md).
## 1. Gateway setup (on the gateway host)
Install the plugin into the live hermes home (dev: a symlink from the monorepo
root):
```bash
mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "iris"
```
Run the interactive setup:
```bash
hermes gateway setup
```
What it does:
- Generates `IRIS_TOKEN` (64 hex chars) if none exists and stores it in
`~/.hermes/.env` (it prints the token once, at generation).
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
and push backend (`ntfy` or `fcm`, default `ntfy`); warns when `fcm` is
chosen (push metadata via Google's servers).
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
string), a scannable QR of that payload, and the server URL
(`ws://<host>:8790/ws`).
Then start the gateway:
```bash
hermes gateway # or: hermes gateway restart after config changes
```
> **Note:** the default bind host `127.0.0.1` only accepts connections from the
> gateway host itself (e.g. a desktop app on the same machine). For a phone on
> the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set
> `IRIS_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
## 2. Iris app (Android)
Build and install (ADB device connected):
```bash
cd app
./gradlew :androidApp:installDebug
```
First run opens the **Connect** screen:
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (the URL printed by
`hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone).
2. **Pairing token** — from the `hermes gateway setup` output, or
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host.
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
pairing and connects.
> **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX +
> ML Kit) that reads the QR printed by `hermes gateway setup` and pre-fills the
> URL + token. Desktop has no camera, so it uses manual entry. An `iris://pair`
> deep link (from any scanner) pre-fills the same way.
## 3. Desktop app
```bash
cd app
./gradlew :desktopApp:run # dev run
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
```
Pairing is the same Connect screen (URL + token); the token is stored in the OS
keyring (with an encrypted-file fallback). Desktop push is tray icon + OS
notifications (no FCM).
> **Known issue:** on Linux with JDK 17 the jpackage launcher prints a
> non-fatal `pure virtual method called` warning (JDK-8348560, a
> jpackage/Linux launcher bug). The app runs and connects regardless.
## 4. Push notifications
Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost. Push fires when the device is offline, plus for
high-priority events (approvals, clarifies, cron) even when a device is live.
### ntfy (default; zero-config)
```
IRIS_PUSH_BACKEND=ntfy # the default — can be left unset
```
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
the server publishes to it.
- `NTFY_SERVER_URL` defaults to `https://ntfy.sh`. **Self-hosted ntfy is
recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its
web UI instead of the stream), while a self-hosted instance gives reliable
SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`.
- **Privacy:** ntfy keeps push metadata (title, topic) on your own
infrastructure — this is the backend for truly private communication.
**What you see:** a low-priority foreground "ntfy listener" notification while
the app is off; incoming pushes trigger a silent sync.
### FCM (opt-in; needs a Firebase project)
> **Privacy note:** FCM push metadata (notification title, device token) is
> routed through **Google's servers**. If you want truly private
> communication, use ntfy (self-hosted) instead — it is the default.
1. Create a Firebase project (console.firebase.google.com) and add an Android
app with the app's applicationId; download `google-services.json` into
`app/androidApp/`.
2. Create a service account (Project settings → Service accounts → Generate new
private key) and store the JSON path in `~/.hermes/.env`:
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Set `IRIS_PUSH_BACKEND=fcm`.
Without a Firebase project the FCM path is **inert** (the app's FCM service
does nothing) — use ntfy (the default), or add Firebase later.
## 5. Remote access
- **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP;
the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
the edge, forward the WebSocket to `127.0.0.1:8790`.
- **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
> **Self-signed certs:** the app has a fingerprint-confirm flow (docs/09
> §9.4): on first pair it shows the gateway cert's SHA-256 fingerprint; once
> you confirm it, the cert is pinned in secure storage (like an SSH host
> key). The cert needs a SAN for the URL host. CA-signed certs work out of
> the box. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the
> default.
## 6. Troubleshooting
| Symptom | Likely cause / fix |
| --- | --- |
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
Smoke test without the app (from the gateway host):
```bash
python - <<'PY'
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
PY
```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
wrong.
> **Moved.** The user-facing setup guide now lives in
> [`install.md`](install.md) — gateway install, all options, app install,
> and connecting (LAN / TLS / remote). This file is kept so old links keep
> working.
>
> - Push details: [`08-push.md`](08-push.md)
> - Security model: [`09-pairing-security.md`](09-pairing-security.md)
> - Toolchain (first-time machine setup): [`12-toolchain.md`](12-toolchain.md)
+4 -5
View File
@@ -45,14 +45,14 @@ Configuration in config.yaml::
enabled: true
extra:
host: 127.0.0.1
port: 8790
http_port: 8791
home_channel: default
push_backend: fcm
outbox_retention_hours: 72
max_upload_bytes: 104857600
Or via environment variables (overrides config.yaml; secrets live in .env):
IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL,
IRIS_TOKEN, IRIS_HTTP_HOST, IRIS_HTTP_PORT, IRIS_HOME_CHANNEL,
IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
"""
@@ -175,9 +175,8 @@ class IrisAdapter(
extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml). The bind host
# is shared with the (legacy) WS-era env var name for compatibility.
self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
# Connection settings (env vars override config.yaml).
self.host = os.getenv("IRIS_HTTP_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
self.http_port = _parse_port(
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
+1 -2
View File
@@ -1,8 +1,7 @@
"""Platform defaults (config.yaml ``extra`` / env fallbacks)."""
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8790
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP is the only transport
DEFAULT_HOME_CHANNEL = "default"
DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "ntfy"
+15 -15
View File
@@ -4,9 +4,9 @@ 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.
Runs an HTTP server (optional TLS) 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: Iris x Hermes
# ``requires_env`` / ``optional_env`` entries are surfaced in the
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
@@ -17,13 +17,13 @@ requires_env:
prompt: "Iris pairing token"
password: true
optional_env:
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
- 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_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
- 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)"
@@ -61,11 +61,11 @@ optional_env:
description: "ntfy auth token for a private topic (trust boundary)"
prompt: "ntfy auth token"
password: true
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
- name: IRIS_HTTP_CERT
description: "TLS cert path for HTTPS (optional)"
prompt: "HTTPS cert"
password: false
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
- name: IRIS_HTTP_KEY
description: "TLS key path for HTTPS (optional)"
prompt: "HTTPS key"
password: false
+9 -9
View File
@@ -21,7 +21,6 @@ from .defaults import (
DEFAULT_HOME_CHANNEL_NAME,
DEFAULT_HOST,
DEFAULT_HTTP_PORT,
DEFAULT_PORT,
DEFAULT_PUSH_BACKEND,
)
from .pairing import (
@@ -89,7 +88,7 @@ def _env_enablement() -> dict | None:
# clobber user YAML. Unset keys fall through to config.yaml / adapter
# defaults.
seed: dict[str, Any] = {}
host = os.getenv("IRIS_WS_HOST", "").strip()
host = os.getenv("IRIS_HTTP_HOST", "").strip()
if host:
seed["host"] = host
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
@@ -111,7 +110,7 @@ def _parse_port(raw: str) -> int:
try:
return int((raw or "").strip())
except (ValueError, TypeError):
return DEFAULT_PORT
return DEFAULT_HTTP_PORT
# ---------------------------------------------------------------------------
@@ -342,10 +341,8 @@ def interactive_setup() -> None:
# off a lost/compromised device before continuing with the config.
_offer_device_removal()
host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST)
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
# HTTP default must be applied explicitly (docs/19: 8791).
host = prompt("Bind host", default=get_env_value("IRIS_HTTP_HOST") or DEFAULT_HOST)
save_env_value("IRIS_HTTP_HOST", host or DEFAULT_HOST)
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
port = prompt(
"HTTP port",
@@ -370,8 +367,11 @@ def interactive_setup() -> None:
# replaced by the default-route LAN IP so the QR points somewhere a phone
# can actually reach (the user can still override the Server URL in-app).
advertised = advertise_host(host or DEFAULT_HOST)
url = pairing_url(advertised, _parse_port(port))
pairing = qr_payload(advertised, _parse_port(port), token)
# Advertise https when TLS is configured, so the printed/QR Server URL
# matches the scheme the gateway actually serves.
secure = bool((get_env_value("IRIS_HTTP_CERT") or "").strip())
url = pairing_url(advertised, _parse_port(port), secure=secure)
pairing = qr_payload(advertised, _parse_port(port), token, secure=secure)
print_info("Pair your device (enter this on the app's Connect screen):")
print_info(f"Pairing URL: {pairing}")
print_info(f"Server URL: {url}")
+1 -1
View File
@@ -70,7 +70,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url http://host:8791
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
`~/.hermes/.env`. The gateway must already be running (the driver never
+5 -5
View File
@@ -9,7 +9,7 @@ Usage::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url http://host:8791
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
~/.hermes/.env. The gateway must already be running (this driver never
@@ -40,7 +40,7 @@ REPO = HERE.parent.parent
PY = REPO / "hermes-agent" / ".venv" / "bin" / "python"
PROBE = HERE / "ws_probe.py"
HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes"
DEFAULT_URL = "ws://127.0.0.1:8790/ws"
DEFAULT_URL = "http://127.0.0.1:8791"
PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
@@ -309,8 +309,8 @@ def s13_http_fallback(env, url, token):
health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
u = urlparse(url)
scheme = "https" if u.scheme == "wss" else "http"
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
scheme = "https" if u.scheme in ("wss", "https") else "http"
http_port = u.port or int(os.getenv("IRIS_HTTP_PORT", "8791"))
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
"--send", "Reply with exactly: e2e http fallback OK",
@@ -352,7 +352,7 @@ def main() -> int:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
p.add_argument("--url", default=os.getenv("IRIS_HTTP_URL", DEFAULT_URL))
p.add_argument("--token", default="")
p.add_argument("--skip", default="",
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
+50 -2
View File
@@ -100,11 +100,11 @@ def adapter(plugin, monkeypatch):
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
# Clear any IRIS transport overrides leaked into the process env by earlier
# tests (e.g. test_interactive_setup_prints_qr runs the real
# interactive_setup(), which save_env_value()s IRIS_HTTP_PORT/IRIS_WS_HOST).
# interactive_setup(), which save_env_value()s IRIS_HTTP_PORT/IRIS_HTTP_HOST).
# Without this, a later adapter would bind the leaked port (8791) instead of
# the ephemeral 0 below, colliding with a live gateway on that port.
monkeypatch.delenv("IRIS_HTTP_PORT", raising=False)
monkeypatch.delenv("IRIS_WS_HOST", raising=False)
monkeypatch.delenv("IRIS_HTTP_HOST", raising=False)
from gateway.platform_registry import PlatformEntry, platform_registry
# Platform("iris") resolves only once the platform is registered
@@ -139,6 +139,54 @@ def adapter(plugin, monkeypatch):
pass
def test_adapter_reads_http_env_overrides(plugin, monkeypatch, tmp_path):
"""IRIS_HTTP_HOST / IRIS_HTTP_CERT / IRIS_HTTP_KEY are read from the env
(overriding config.yaml extra), and the legacy IRIS_WS_* names are NOT
consulted (HTTP-only transport, docs/19)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
monkeypatch.setenv("IRIS_HTTP_HOST", "192.168.1.50")
monkeypatch.setenv("IRIS_HTTP_CERT", str(tmp_path / "cert.pem"))
monkeypatch.setenv("IRIS_HTTP_KEY", str(tmp_path / "key.pem"))
# Legacy WS-era names must be ignored, even if present.
monkeypatch.setenv("IRIS_WS_HOST", "10.9.9.9")
monkeypatch.setenv("IRIS_WS_CERT", str(tmp_path / "old.pem"))
from gateway.platform_registry import platform_registry
if not platform_registry.is_registered("iris"):
from gateway.platform_registry import PlatformEntry
platform_registry.register(
PlatformEntry(
name="iris",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
)
)
config = SimpleNamespace(
extra={"host": "127.0.0.1", "http_port": 0},
home_channel=None,
)
a = plugin.adapter.IrisAdapter(config)
try:
assert a.host == "192.168.1.50" # env wins over extra
assert a.http_cert == str(tmp_path / "cert.pem")
assert a.http_key == str(tmp_path / "key.pem")
# Legacy names are not read: host/cert are not the old values.
assert a.host != "10.9.9.9"
assert a.http_cert != str(tmp_path / "old.pem")
finally:
try:
a._devices.close()
except Exception:
pass
try:
a._outbox.close()
except Exception:
pass
class HttpTestClient:
"""Mimics the old WS client interface over the HTTP transport (docs/19).
+9 -5
View File
@@ -12,7 +12,9 @@ Usage::
--send "hello"
Options:
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
--url http(s)://host:port (default http://127.0.0.1:8791)
(legacy ws(s)://host:8790/ws URLs are still accepted and
converted to the HTTP base automatically)
--token IRIS_TOKEN (default: $IRIS_TOKEN)
--device device_id (default: probe-<rand>)
--send TEXT send this message after pairing (default: "hello")
@@ -666,7 +668,7 @@ def run_http(args, base: str) -> int:
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--url", default=os.getenv("IRIS_HTTP_URL", "http://127.0.0.1:8791"))
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
p.add_argument("--send", default="hello")
@@ -766,15 +768,17 @@ def main() -> int:
if args.assert_read_receipt and not args.send:
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
# HTTP is the only transport (docs/19): derive the http(s) base from the
# --url (ws://host:8790/ws -> http://host:8791) unless --http-url is given.
# --url (legacy ws(s)://host:8790/ws -> http(s)://host:8791) unless
# --http-url is given.
if args.http_url:
base = args.http_url
else:
from urllib.parse import urlparse
u = urlparse(args.url)
scheme = "https" if u.scheme == "wss" else "http"
base = f"{scheme}://{u.hostname or '127.0.0.1'}:8791"
scheme = "https" if u.scheme in ("wss", "https") else "http"
port = u.port or 8791
base = f"{scheme}://{u.hostname or '127.0.0.1'}:{port}"
return run_http(args, base)