Files
iris_x_hermes/docs/setup.md
T
ARIA 597a28050f
CI / Gateway plugin tests (push) Successful in 5m29s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m22s
TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14)
docs/09 §9.4 promised a SSH-host-key-style fingerprint confirm on first
pair, but the app built a default OkHttpClient with no certificate
handling — self-signed gateway certs were simply rejected.

Build the flow:
- TlsPinning.kt: PinningTrustManager wraps the platform default trust
  manager; a rejected cert is accepted only when its SHA-256 fingerprint
  matches the user-confirmed pin, anything else fails with
  TlsFingerprintRequired (hostname verification still applies).
- SecureStore.pinnedCertFingerprint (Android EncryptedSharedPreferences +
  desktop settings.json), cleared on forget().
- GatewayClient: pinning socket factory on the shared client (all legs
  inherit it), new terminal State.TlsConfirmRequired, unwrap the nested
  TlsFingerprintRequired in connect loop / watchdog / SSE / poll /
  testHello.
- ConnectScreen: confirm dialog showing the fingerprint ("Confirm &
  pin" re-runs the connect); IrisApp routes TlsConfirmRequired there;
  ChatScreen + desktop tray handle the new state.
- Docs: §9.4 now describes the real flow (incl. SAN requirement), gap
  table item 6 → implemented, setup.md limitation note updated.
- Tests: TlsPinningTest (fingerprint vs openssl, pin accept/reject,
  live pin read, unwrap) + TlsPinningIntegrationTest (real TLS
  handshake: unpinned → confirm data, pinned → 200).

Live E2E verified on the phone: first pair against a self-signed
IRIS_HTTP_CERT gateway shows the dialog, confirm pins, chat works,
auto-reconnect after gateway restart uses the pin.
2026-08-24 21:30:42 +02:00

7.2 KiB

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, 08-push.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)
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.

1. Gateway setup (on the gateway host)

Install the plugin into the live hermes home (dev: a symlink from the monorepo root):

mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status        # should list "iris"

Run the interactive setup:

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:

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. Android app

Build and install (ADB device connected):

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

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):

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.