diff --git a/AGENTS.md b/AGENTS.md index 4dc9710..4c1675f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` → `/gateway-plugin` (already set up on this machine). +- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `/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`. diff --git a/README.md b/README.md index 0990b70..2745a82 100644 --- a/README.md +++ b/README.md @@ -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://:8790/ws` (printed by `hermes gateway setup`). -2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env` +1. **Server URL** — `http://: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 diff --git a/app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt b/app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt index 5e9cb25..71c7106 100644 --- a/app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt +++ b/app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt @@ -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 diff --git a/docs/03-gateway-plugin.md b/docs/03-gateway-plugin.md index 3fd0c02..7980e1f 100644 --- a/docs/03-gateway-plugin.md +++ b/docs/03-gateway-plugin.md @@ -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 ` (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). diff --git a/docs/09-pairing-security.md b/docs/09-pairing-security.md index 07a9f17..78add6c 100644 --- a/docs/09-pairing-security.md +++ b/docs/09-pairing-security.md @@ -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` | diff --git a/docs/12-toolchain.md b/docs/12-toolchain.md index 099bc65..8581193 100644 --- a/docs/12-toolchain.md +++ b/docs/12-toolchain.md @@ -101,8 +101,8 @@ IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json # IRIS_FCM_SERVER_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":"","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 " -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. diff --git a/docs/19-http-fallback-transport.md b/docs/19-http-fallback-transport.md index 1fc8485..848059a 100644 --- a/docs/19-http-fallback-transport.md +++ b/docs/19-http-fallback-transport.md @@ -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 diff --git a/docs/README.md b/docs/README.md index a509a24..7d3e4fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..87c6ab5 --- /dev/null +++ b/docs/install.md @@ -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`. diff --git a/docs/setup.md b/docs/setup.md index d379f78..5c80ebc 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -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://: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://: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://: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":"","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) diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 2c8f0cb..84f8225 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -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)) diff --git a/gateway-plugin/defaults.py b/gateway-plugin/defaults.py index 8c0e0cc..d4cb0f9 100644 --- a/gateway-plugin/defaults.py +++ b/gateway-plugin/defaults.py @@ -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" diff --git a/gateway-plugin/plugin.yaml b/gateway-plugin/plugin.yaml index 21e417f..6f5b1f7 100644 --- a/gateway-plugin/plugin.yaml +++ b/gateway-plugin/plugin.yaml @@ -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 diff --git a/gateway-plugin/setup.py b/gateway-plugin/setup.py index 5e30cb4..2be9921 100644 --- a/gateway-plugin/setup.py +++ b/gateway-plugin/setup.py @@ -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}") diff --git a/gateway-plugin/tests/README.md b/gateway-plugin/tests/README.md index d396465..2097616 100644 --- a/gateway-plugin/tests/README.md +++ b/gateway-plugin/tests/README.md @@ -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 diff --git a/gateway-plugin/tests/e2e.py b/gateway-plugin/tests/e2e.py index ca2de4e..d1258b3 100644 --- a/gateway-plugin/tests/e2e.py +++ b/gateway-plugin/tests/e2e.py @@ -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)") diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py index 4208d71..ee8daa6 100644 --- a/gateway-plugin/tests/test_android.py +++ b/gateway-plugin/tests/test_android.py @@ -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). diff --git a/gateway-plugin/tests/ws_probe.py b/gateway-plugin/tests/ws_probe.py index 39878d5..7fce85f 100644 --- a/gateway-plugin/tests/ws_probe.py +++ b/gateway-plugin/tests/ws_probe.py @@ -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-) --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)