docs+plugin: HTTP-only transport cleanup, install guide, review fixes
- docs/install.md: new end-to-end guide for non-technical users (gateway install, app install, LAN/TLS/remote connection, push, options, troubleshooting); docs/setup.md now points to it - README: new 'Install the gateway' section; pairing section updated for HTTP transport (8791, QR scan on Android) - rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat fallback); drop dead DEFAULT_PORT=8790 - setup.py: advertise https:// in the printed/QR server URL when IRIS_HTTP_CERT is set - ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme - plugin.yaml: IRIS_HTTP_* env names, description no longer says 'WebSocket server' - docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites, 8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the only transport) - AGENTS.md: symlink name android -> iris (matches actual install) - test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy IRIS_WS_* names are not consulted (95/95 pass)
This commit is contained in:
1 parent
a61b47a947
commit
b1c9bac7d8
18 files changed
+472
-317
No files matched your search
@@ -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.
|
- `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).
|
- **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
|
## Layout
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
## Environment / pairing quirks
|
## 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.
|
- 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.
|
- `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.
|
- `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`.
|
- **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`.
|
||||||
|
|||||||
@@ -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).
|
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
|
## Features
|
||||||
|
|
||||||
@@ -29,11 +29,11 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
|
|||||||
## How it works
|
## 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
|
- `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.
|
Zero new Python dependencies, zero hermes-core changes.
|
||||||
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
|
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
|
||||||
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
|
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
|
metadata (notification title, device token) is routed through **Google's
|
||||||
servers**. If you want truly private communication, use ntfy instead.
|
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).
|
[`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
|
## Build from source
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -72,19 +94,21 @@ No system Gradle needed — both apps use the project wrapper (`./gradlew`).
|
|||||||
|
|
||||||
### 1. Gateway (on the gateway host)
|
### 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
|
```bash
|
||||||
# hermes-agent is a separate project (not part of this repo)
|
|
||||||
cd hermes-agent && uv sync
|
cd hermes-agent && uv sync
|
||||||
|
|
||||||
# install the Iris plugin into the live hermes home
|
|
||||||
mkdir -p ~/.hermes/plugins
|
mkdir -p ~/.hermes/plugins
|
||||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
|
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||||
hermes gateway status # should list "android"
|
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
|
hermes gateway # run the gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(Otherwise see [Install the gateway](#install-the-gateway) above.)
|
||||||
|
|
||||||
### 2. Android app
|
### 2. Android app
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -106,21 +130,22 @@ cd app
|
|||||||
|
|
||||||
On the app's **Connect** screen:
|
On the app's **Connect** screen:
|
||||||
|
|
||||||
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`).
|
1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
|
||||||
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env`
|
2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
|
||||||
on the gateway host.
|
on the gateway host.
|
||||||
3. **Test & Connect.**
|
3. **Test & Connect.**
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The app has **no QR scanner** — pairing is manual URL + token entry.
|
- **Android** has a **Scan QR** button that reads the QR printed by
|
||||||
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone
|
`hermes gateway setup` and pre-fills URL + token; desktop uses manual entry.
|
||||||
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
- The default bind is `127.0.0.1` (desktop on the same machine only). For a
|
||||||
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
|
phone on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
||||||
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
|
- 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:
|
Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
|
||||||
[`docs/setup.md`](docs/setup.md).
|
[`docs/install.md`](docs/install.md).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import javax.net.ssl.X509TrustManager
|
|||||||
/**
|
/**
|
||||||
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
|
* 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
|
* 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
|
* 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
|
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
|
||||||
|
|||||||
+36
-36
@@ -27,13 +27,13 @@ requires_env:
|
|||||||
prompt: "Iris pairing token"
|
prompt: "Iris pairing token"
|
||||||
password: true
|
password: true
|
||||||
optional_env:
|
optional_env:
|
||||||
- name: IRIS_WS_HOST
|
- name: IRIS_HTTP_HOST
|
||||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||||
prompt: "WS host"
|
prompt: "HTTP host"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_PORT
|
- name: IRIS_HTTP_PORT
|
||||||
description: "WS port (default 8790)"
|
description: "HTTP port (default 8791)"
|
||||||
prompt: "WS port"
|
prompt: "HTTP port"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_HOME_CHANNEL
|
- name: IRIS_HOME_CHANNEL
|
||||||
description: "Default chat id for cron/notification delivery (default default)"
|
description: "Default chat id for cron/notification delivery (default default)"
|
||||||
@@ -67,13 +67,13 @@ optional_env:
|
|||||||
description: "ntfy server URL (default https://ntfy.sh)"
|
description: "ntfy server URL (default https://ntfy.sh)"
|
||||||
prompt: "ntfy server URL"
|
prompt: "ntfy server URL"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_CERT
|
- name: IRIS_HTTP_CERT
|
||||||
description: "TLS cert path for WSS (optional)"
|
description: "TLS cert path for HTTPS (optional)"
|
||||||
prompt: "WSS cert"
|
prompt: "HTTPS cert"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_KEY
|
- name: IRIS_HTTP_KEY
|
||||||
description: "TLS key path for WSS (optional)"
|
description: "TLS key path for HTTPS (optional)"
|
||||||
prompt: "WSS key"
|
prompt: "HTTPS key"
|
||||||
password: false
|
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
|
`message` vs `tool.*` vs `commentary`. The exact classification markers are
|
||||||
verified empirically in M2 (see `13-testing.md`).
|
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,
|
- Library: **stdlib `http.server`** (`ThreadingHTTPServer` +
|
||||||
port, ssl=ctx)`.
|
`BaseHTTPRequestHandler`) in a daemon thread; bridges into the gateway's
|
||||||
- **Handler** per connection:
|
asyncio loop via `asyncio.run_coroutine_threadsafe`. Optional TLS via
|
||||||
1. Await first frame; must be `hello {token, device_id, device_name, caps,
|
`ssl.SSLContext` (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`). Full design:
|
||||||
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
|
`19-http-fallback-transport.md`.
|
||||||
`error {code:"auth"}` and close.
|
- **Auth:** `Authorization: Bearer <token>` (constant-time `verify_token`)
|
||||||
2. On success: register in connection registry
|
- device allowlist via `X-Iris-Device`; `401` on failure.
|
||||||
(`device_id → {ws, caps, fcm_token}`), send
|
- **Endpoints:** `GET /v1/health` (unauthenticated liveness),
|
||||||
`hello.ack {server_caps, sync_cursor, channels[]}`.
|
`POST /v1/frame` (any JSON frame the protocol accepts),
|
||||||
3. Loop: decode frames, dispatch to adapter inbound handlers.
|
`GET /v1/events?cursor=N` (SSE: outbox catch-up + live frames),
|
||||||
4. On close: deregister; if no devices remain, ensure pending outbox
|
`GET /v1/poll?cursor=N` (long-poll fallback), `POST /v1/media` +
|
||||||
frames have push fired.
|
`GET /v1/media/{id}` (media upload/pull).
|
||||||
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
||||||
devices (no per-chat subscribe; single-user model). Global frames
|
devices (no per-chat subscribe; single-user model). Global frames
|
||||||
(`channel.*`, `status`) also broadcast to all.
|
(`channel.*`, `status`) also broadcast to all.
|
||||||
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
|
- **Limits:** 64 KiB request body cap, per-device token-bucket rate limit
|
||||||
- **Backpressure:** per-connection send queue with a bounded buffer; drop
|
(20/s, burst 40) → `429`; media uploads bounded by the per-upload total
|
||||||
`message.update` (coalesce to latest) under pressure, never drop
|
cap. No CORS (app clients only).
|
||||||
`message`/`tool.end`/`notification`.
|
|
||||||
|
|
||||||
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
|
## 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
|
## 3.6 Config resolution
|
||||||
|
|
||||||
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
|
- **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`,
|
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
|
||||||
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
`http_port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
||||||
`max_upload_bytes`, `tls`.
|
`max_upload_bytes`, `http_cert`/`http_key`.
|
||||||
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
||||||
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
||||||
so multiplexed profiles don't leak each other's tokens.
|
so multiplexed profiles don't leak each other's tokens.
|
||||||
|
|
||||||
## 3.7 Failure & lifecycle safety
|
## 3.7 Failure & lifecycle safety
|
||||||
|
|
||||||
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
|
- HTTP server bind failure → non-fatal: log a warning, disable the HTTP leg,
|
||||||
- All outbound sends are best-effort; a dead socket latches and the frame falls
|
show it in the inspector (the plugin keeps working for other platforms).
|
||||||
to the outbox.
|
- All outbound sends are best-effort; a dead stream latches and the frame
|
||||||
- `disconnect()` cancels the server task and closes sockets cleanly.
|
falls to the outbox.
|
||||||
|
- `disconnect()` stops the HTTP server and closes streams cleanly.
|
||||||
- Token/PII redaction in all logs (hermes PII policy).
|
- Token/PII redaction in all logs (hermes PII policy).
|
||||||
+15
-15
@@ -87,9 +87,9 @@ security principal (the token is).
|
|||||||
|
|
||||||
## 9.4 Transport security
|
## 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.
|
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.
|
(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
|
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
|
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;
|
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
|
||||||
app connects over the private mesh. No public exposure.
|
app connects over the private mesh. No public exposure.
|
||||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
||||||
at the edge, forward WS to `127.0.0.1:8790`.
|
at the edge, forward to `127.0.0.1:8791`.
|
||||||
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
- **Public bind** (`0.0.0.0`) + HTTPS + strong token — last resort.
|
||||||
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
|
- **HTTP transport (docs/19):** the gateway serves the same frames over plain
|
||||||
over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's
|
HTTP (`IRIS_HTTP_PORT`, default 8791) — the only device-facing transport.
|
||||||
fallback transport. It is a *second door with the same lock*: the same
|
It shares the same lock as everything else: the same Bearer token
|
||||||
Bearer token (constant-time `verify_token`) + the same device allowlist
|
(constant-time `verify_token`) + the same device allowlist
|
||||||
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
|
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit.
|
||||||
the WS. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
||||||
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
||||||
not reflect tokens, device ids, or versions).
|
not reflect tokens, device ids, or versions).
|
||||||
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
- 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 |
|
| # | Item | Status | Evidence / mitigation |
|
||||||
| --- | ------ | -------- | ----------------------- |
|
| --- | ------ | -------- | ----------------------- |
|
||||||
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
|
| 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) |
|
| 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 | `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` |
|
| 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` |
|
| 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 |
|
| 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` |
|
| 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`) |
|
| 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: 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 |
|
| 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`) |
|
| 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` |
|
| 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` |
|
| 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
@@ -101,8 +101,8 @@ IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
|||||||
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
||||||
# NTFY_TOPIC=iris-push # when ntfy
|
# NTFY_TOPIC=iris-push # when ntfy
|
||||||
# NTFY_SERVER_URL=https://ntfy.sh
|
# NTFY_SERVER_URL=https://ntfy.sh
|
||||||
# IRIS_WS_CERT=/path/cert.pem # WSS
|
# IRIS_HTTP_CERT=/path/cert.pem # HTTPS
|
||||||
# IRIS_WS_KEY=/path/key.pem
|
# IRIS_HTTP_KEY=/path/key.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
**Behavioral (`~/.hermes/config.yaml`):**
|
**Behavioral (`~/.hermes/config.yaml`):**
|
||||||
@@ -114,7 +114,7 @@ gateway:
|
|||||||
enabled: true
|
enabled: true
|
||||||
extra:
|
extra:
|
||||||
host: 127.0.0.1 # 0.0.0.0 for LAN
|
host: 127.0.0.1 # 0.0.0.0 for LAN
|
||||||
port: 8790
|
http_port: 8791
|
||||||
home_channel: default
|
home_channel: default
|
||||||
push_backend: fcm
|
push_backend: fcm
|
||||||
outbox_retention_hours: 72
|
outbox_retention_hours: 72
|
||||||
@@ -134,18 +134,17 @@ display:
|
|||||||
# 1. gateway up with plugin
|
# 1. gateway up with plugin
|
||||||
hermes gateway status | grep -i iris
|
hermes gateway status | grep -i iris
|
||||||
|
|
||||||
# 2. a raw WS client can pair + echo
|
# 2. the HTTP server answers (unauthenticated liveness)
|
||||||
python - <<'PY'
|
curl -s http://127.0.0.1:8791/v1/health
|
||||||
import asyncio, json, websockets
|
# -> {"ok": true}
|
||||||
async def main():
|
|
||||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
# 3. a frame round-trip with the pairing token
|
||||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
curl -s -X POST http://127.0.0.1:8791/v1/frame \
|
||||||
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
|
-H "Authorization: Bearer <IRIS_TOKEN>" -H "X-Iris-Device: probe" \
|
||||||
"caps":{"min_protocol":1}}}))
|
-H "Content-Type: application/json" \
|
||||||
print("recv:", await ws.recv())
|
-d '{"v":1,"type":"commands.catalog","id":1,"payload":{}}'
|
||||||
asyncio.run(main())
|
|
||||||
PY
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
wrong.
|
||||||
@@ -113,13 +113,13 @@ to the WS server.
|
|||||||
scale). The handler thread never touches adapter state directly; it bridges
|
scale). The handler thread never touches adapter state directly; it bridges
|
||||||
into the gateway's asyncio loop with
|
into the gateway's asyncio loop with
|
||||||
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
||||||
start, same loop the WS server runs on).
|
start).
|
||||||
- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
|
- **Config:** `IRIS_HTTP_PORT` (default **8791**), bind host `IRIS_HTTP_HOST`.
|
||||||
(`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
||||||
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
|
(`ssl.SSLContext` on the server): plaintext on a trusted LAN by default,
|
||||||
trusted LAN by default, TLS for remote/Tailscale setups.
|
TLS for remote/Tailscale setups.
|
||||||
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
|
- **Bind failure is NON-fatal:** log a warning, disable the HTTP leg, show it
|
||||||
HTTP leg, show it in the inspector. The plugin must keep working WS-only.
|
in the inspector.
|
||||||
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
|
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|||||||
@@ -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
|
## Reading order
|
||||||
|
|
||||||
| # | File | When to read |
|
| # | File | When to read |
|
||||||
|
|||||||
+246
@@ -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
@@ -1,180 +1,10 @@
|
|||||||
# Setup — Pairing a Device
|
# Setup — Pairing a Device
|
||||||
|
|
||||||
User-facing guide: get a phone or desktop talking to your hermes gateway in
|
> **Moved.** The user-facing setup guide now lives in
|
||||||
under 10 minutes. Design rationale lives in the numbered docs
|
> [`install.md`](install.md) — gateway install, all options, app install,
|
||||||
([`09-pairing-security.md`](09-pairing-security.md),
|
> and connecting (LAN / TLS / remote). This file is kept so old links keep
|
||||||
[`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is
|
> working.
|
||||||
just the steps.
|
>
|
||||||
|
> - Push details: [`08-push.md`](08-push.md)
|
||||||
## Prerequisites
|
> - Security model: [`09-pairing-security.md`](09-pairing-security.md)
|
||||||
|
> - Toolchain (first-time machine setup): [`12-toolchain.md`](12-toolchain.md)
|
||||||
| 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.
|
|
||||||
@@ -45,14 +45,14 @@ Configuration in config.yaml::
|
|||||||
enabled: true
|
enabled: true
|
||||||
extra:
|
extra:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8790
|
http_port: 8791
|
||||||
home_channel: default
|
home_channel: default
|
||||||
push_backend: fcm
|
push_backend: fcm
|
||||||
outbox_retention_hours: 72
|
outbox_retention_hours: 72
|
||||||
max_upload_bytes: 104857600
|
max_upload_bytes: 104857600
|
||||||
|
|
||||||
Or via environment variables (overrides config.yaml; secrets live in .env):
|
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, ...
|
IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -175,9 +175,8 @@ class IrisAdapter(
|
|||||||
|
|
||||||
extra = getattr(config, "extra", {}) or {}
|
extra = getattr(config, "extra", {}) or {}
|
||||||
|
|
||||||
# Connection settings (env vars override config.yaml). The bind host
|
# Connection settings (env vars override config.yaml).
|
||||||
# is shared with the (legacy) WS-era env var name for compatibility.
|
self.host = os.getenv("IRIS_HTTP_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||||
self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
|
||||||
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
|
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
|
||||||
self.http_port = _parse_port(
|
self.http_port = _parse_port(
|
||||||
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Platform defaults (config.yaml ``extra`` / env fallbacks)."""
|
"""Platform defaults (config.yaml ``extra`` / env fallbacks)."""
|
||||||
|
|
||||||
DEFAULT_HOST = "127.0.0.1"
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
DEFAULT_PORT = 8790
|
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP is the only transport
|
||||||
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
|
|
||||||
DEFAULT_HOME_CHANNEL = "default"
|
DEFAULT_HOME_CHANNEL = "default"
|
||||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||||
DEFAULT_PUSH_BACKEND = "ntfy"
|
DEFAULT_PUSH_BACKEND = "ntfy"
|
||||||
|
|||||||
+15
-15
@@ -4,9 +4,9 @@ kind: platform
|
|||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
description: >
|
description: >
|
||||||
Native Android / Desktop client gateway adapter for Hermes Agent.
|
Native Android / Desktop client gateway adapter for Hermes Agent.
|
||||||
Runs a WebSocket server inside the gateway; the app connects with a
|
Runs an HTTP server (optional TLS) inside the gateway; the app connects
|
||||||
pairing token. Supports streaming, reasoning, structured tool events,
|
with a pairing token. Supports streaming, reasoning, structured tool
|
||||||
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
events, channels/threads, media, FTS5 search, and FCM/ntfy push.
|
||||||
author: Iris x Hermes
|
author: Iris x Hermes
|
||||||
# ``requires_env`` / ``optional_env`` entries are surfaced in the
|
# ``requires_env`` / ``optional_env`` entries are surfaced in the
|
||||||
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
||||||
@@ -17,13 +17,13 @@ requires_env:
|
|||||||
prompt: "Iris pairing token"
|
prompt: "Iris pairing token"
|
||||||
password: true
|
password: true
|
||||||
optional_env:
|
optional_env:
|
||||||
- name: IRIS_WS_HOST
|
- name: IRIS_HTTP_HOST
|
||||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||||
prompt: "WS host"
|
prompt: "HTTP host"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_PORT
|
- name: IRIS_HTTP_PORT
|
||||||
description: "WS port (default 8790)"
|
description: "HTTP port (default 8791)"
|
||||||
prompt: "WS port"
|
prompt: "HTTP port"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_HOME_CHANNEL
|
- name: IRIS_HOME_CHANNEL
|
||||||
description: "Default chat id for cron/notification delivery (default: default)"
|
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)"
|
description: "ntfy auth token for a private topic (trust boundary)"
|
||||||
prompt: "ntfy auth token"
|
prompt: "ntfy auth token"
|
||||||
password: true
|
password: true
|
||||||
- name: IRIS_WS_CERT
|
- name: IRIS_HTTP_CERT
|
||||||
description: "TLS cert path for WSS (optional)"
|
description: "TLS cert path for HTTPS (optional)"
|
||||||
prompt: "WSS cert"
|
prompt: "HTTPS cert"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_KEY
|
- name: IRIS_HTTP_KEY
|
||||||
description: "TLS key path for WSS (optional)"
|
description: "TLS key path for HTTPS (optional)"
|
||||||
prompt: "WSS key"
|
prompt: "HTTPS key"
|
||||||
password: false
|
password: false
|
||||||
@@ -21,7 +21,6 @@ from .defaults import (
|
|||||||
DEFAULT_HOME_CHANNEL_NAME,
|
DEFAULT_HOME_CHANNEL_NAME,
|
||||||
DEFAULT_HOST,
|
DEFAULT_HOST,
|
||||||
DEFAULT_HTTP_PORT,
|
DEFAULT_HTTP_PORT,
|
||||||
DEFAULT_PORT,
|
|
||||||
DEFAULT_PUSH_BACKEND,
|
DEFAULT_PUSH_BACKEND,
|
||||||
)
|
)
|
||||||
from .pairing import (
|
from .pairing import (
|
||||||
@@ -89,7 +88,7 @@ def _env_enablement() -> dict | None:
|
|||||||
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
||||||
# defaults.
|
# defaults.
|
||||||
seed: dict[str, Any] = {}
|
seed: dict[str, Any] = {}
|
||||||
host = os.getenv("IRIS_WS_HOST", "").strip()
|
host = os.getenv("IRIS_HTTP_HOST", "").strip()
|
||||||
if host:
|
if host:
|
||||||
seed["host"] = host
|
seed["host"] = host
|
||||||
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
|
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
|
||||||
@@ -111,7 +110,7 @@ def _parse_port(raw: str) -> int:
|
|||||||
try:
|
try:
|
||||||
return int((raw or "").strip())
|
return int((raw or "").strip())
|
||||||
except (ValueError, TypeError):
|
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.
|
# off a lost/compromised device before continuing with the config.
|
||||||
_offer_device_removal()
|
_offer_device_removal()
|
||||||
|
|
||||||
host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
|
host = prompt("Bind host", default=get_env_value("IRIS_HTTP_HOST") or DEFAULT_HOST)
|
||||||
save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST)
|
save_env_value("IRIS_HTTP_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).
|
|
||||||
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
|
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
|
||||||
port = prompt(
|
port = prompt(
|
||||||
"HTTP port",
|
"HTTP port",
|
||||||
@@ -370,8 +367,11 @@ def interactive_setup() -> None:
|
|||||||
# replaced by the default-route LAN IP so the QR points somewhere a phone
|
# 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).
|
# can actually reach (the user can still override the Server URL in-app).
|
||||||
advertised = advertise_host(host or DEFAULT_HOST)
|
advertised = advertise_host(host or DEFAULT_HOST)
|
||||||
url = pairing_url(advertised, _parse_port(port))
|
# Advertise https when TLS is configured, so the printed/QR Server URL
|
||||||
pairing = qr_payload(advertised, _parse_port(port), token)
|
# 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("Pair your device (enter this on the app's Connect screen):")
|
||||||
print_info(f"Pairing URL: {pairing}")
|
print_info(f"Pairing URL: {pairing}")
|
||||||
print_info(f"Server URL: {url}")
|
print_info(f"Server URL: {url}")
|
||||||
|
|||||||
@@ -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
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
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
|
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
|
||||||
`~/.hermes/.env`. The gateway must already be running (the driver never
|
`~/.hermes/.env`. The gateway must already be running (the driver never
|
||||||
|
|||||||
@@ -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
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
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
|
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
|
||||||
~/.hermes/.env. The gateway must already be running (this driver never
|
~/.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"
|
PY = REPO / "hermes-agent" / ".venv" / "bin" / "python"
|
||||||
PROBE = HERE / "ws_probe.py"
|
PROBE = HERE / "ws_probe.py"
|
||||||
HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes"
|
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"
|
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
|
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)."""
|
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
|
||||||
u = urlparse(url)
|
u = urlparse(url)
|
||||||
scheme = "https" if u.scheme == "wss" else "http"
|
scheme = "https" if u.scheme in ("wss", "https") else "http"
|
||||||
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
|
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}"
|
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,
|
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
|
||||||
"--send", "Reply with exactly: e2e http fallback OK",
|
"--send", "Reply with exactly: e2e http fallback OK",
|
||||||
@@ -352,7 +352,7 @@ def main() -> int:
|
|||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(
|
||||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
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("--token", default="")
|
||||||
p.add_argument("--skip", default="",
|
p.add_argument("--skip", default="",
|
||||||
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
|
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ def adapter(plugin, monkeypatch):
|
|||||||
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
|
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
|
||||||
# Clear any IRIS transport overrides leaked into the process env by earlier
|
# Clear any IRIS transport overrides leaked into the process env by earlier
|
||||||
# tests (e.g. test_interactive_setup_prints_qr runs the real
|
# 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
|
# 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.
|
# the ephemeral 0 below, colliding with a live gateway on that port.
|
||||||
monkeypatch.delenv("IRIS_HTTP_PORT", raising=False)
|
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
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||||
|
|
||||||
# Platform("iris") resolves only once the platform is registered
|
# Platform("iris") resolves only once the platform is registered
|
||||||
@@ -139,6 +139,54 @@ def adapter(plugin, monkeypatch):
|
|||||||
pass
|
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:
|
class HttpTestClient:
|
||||||
"""Mimics the old WS client interface over the HTTP transport (docs/19).
|
"""Mimics the old WS client interface over the HTTP transport (docs/19).
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ Usage::
|
|||||||
--send "hello"
|
--send "hello"
|
||||||
|
|
||||||
Options:
|
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)
|
--token IRIS_TOKEN (default: $IRIS_TOKEN)
|
||||||
--device device_id (default: probe-<rand>)
|
--device device_id (default: probe-<rand>)
|
||||||
--send TEXT send this message after pairing (default: "hello")
|
--send TEXT send this message after pairing (default: "hello")
|
||||||
@@ -666,7 +668,7 @@ def run_http(args, base: str) -> int:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
p = argparse.ArgumentParser(description=__doc__)
|
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("--token", default=os.getenv("IRIS_TOKEN", ""))
|
||||||
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
||||||
p.add_argument("--send", default="hello")
|
p.add_argument("--send", default="hello")
|
||||||
@@ -766,15 +768,17 @@ def main() -> int:
|
|||||||
if args.assert_read_receipt and not args.send:
|
if args.assert_read_receipt and not args.send:
|
||||||
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
|
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
|
# 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:
|
if args.http_url:
|
||||||
base = args.http_url
|
base = args.http_url
|
||||||
else:
|
else:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
u = urlparse(args.url)
|
u = urlparse(args.url)
|
||||||
scheme = "https" if u.scheme == "wss" else "http"
|
scheme = "https" if u.scheme in ("wss", "https") else "http"
|
||||||
base = f"{scheme}://{u.hostname or '127.0.0.1'}:8791"
|
port = u.port or 8791
|
||||||
|
base = f"{scheme}://{u.hostname or '127.0.0.1'}:{port}"
|
||||||
return run_http(args, base)
|
return run_http(args, base)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user