M7: polish + E2E + docs (layout pass, theming, states, e2e driver, schema, setup.md, security)

This commit is contained in:
ARIA committed 2026-08-20 12:00:13 +02:00
1 parent 0cc8b7aafe
commit bf6bf7e8bd
26 files changed
+2225 -327

No files matched your search

+14 -3
View File
@@ -183,10 +183,21 @@ Agent-sent media is available; app pulls bytes.
"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,"filename":"clip.mp4"}}
```
### `status`
Gateway lifecycle / session info.
### `read.receipt`
The gateway acknowledges that the agent has received and started processing
the user's message. The app uses it to show ✓✓ on user bubbles.
```json
{"type":"status","payload":{"state":"online","session":{"chat_id":"…","model":"…","tokens":11}}}
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}}
```
Emitted to the originating connection when a `message.send` is accepted for
processing (at the moment it is handed to the agent), for user-originated
messages only.
### `status`
Gateway health state. Broadcast to all connected clients at startup
(`state: "online"`); `restarting` / `degraded` are reserved for future use.
```json
{"type":"status","payload":{"state":"online"}}
```
`state` ∈ `online | restarting | degraded`.
+23 -1
View File
@@ -92,4 +92,26 @@ security principal (the token is).
- [ ] Redact all secrets in logs.
- [ ] WSS + cert pinning for remote.
- [ ] Outbox retention cap + prune.
- [ ] Fail-closed secret reads under multiplexing.
- [ ] Fail-closed secret reads under multiplexing.
## M7 verification (2026-08-20)
Status of the §9.7 hardening checklist plus the related gaps found in the
M7 research pass. "verified" = implemented and covered by
`hermes-agent/tests/gateway/test_android.py` (35 tests) or the app build;
"gap" = known limitation with the planned mitigation.
| # | 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` |
| 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 | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default |
| 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 `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_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 |
| 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 | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later |
+33 -5
View File
@@ -179,18 +179,46 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
## M7 — Polish + E2E + docs
**Goal:** ship-quality.
- [ ] Telegram-style layout pass (per reference image): header, bubbles, date
- [x] Telegram-style layout pass (per reference image): header, bubbles, date
separators, ✓✓, model/token footer, banner, bottom bar.
- [ ] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
reconnecting/degraded states with honest copy.
- [ ] Full E2E suite (`13-testing.md` scenarios 1–12) automated where possible.
- [ ] Docs: `docs/protocol/frames.schema.json` finalized; `docs/setup.md`
- [x] Full E2E suite (`13-testing.md` scenarios 1–12) automated where possible.
- [x] Docs: `docs/protocol/frames.schema.json` finalized; `docs/setup.md`
(user-facing pairing + FCM/ntfy + remote access); root README.
- [ ] Security hardening checklist (`09-pairing-security.md`) verified.
- [x] Security hardening checklist (`09-pairing-security.md`) verified.
- **Demo:** end-to-end on phone + desktop simultaneously; cron into a channel;
push; media; search.
- **Accept:** all feature-checklist items pass on-device; E2E green; docs
complete; `hermes-agent/` still never committed.
- **Status (2026-08-20):** Layout pass verified on-device (MIX 2S): header
with avatar + "Bot" subtitle + overflow menu (rename channel, forget
pairing), centered date-separator pill, bubbles with in-bubble timestamps,
user ✓/✓✓ driven by the new `read.receipt` frame (gateway emits it when the
agent takes the message; late-joining clients also get the current `status`
state on hello.ack), model/token footer, letter-avatar channel rail/drawer
with active highlight, restyled bottom bar. Theming centralized in
`ui/theme/Theme.kt` (dark default, single accent; all hard-coded colors
replaced). States: dedicated connecting screen, reconnecting +
degraded/restarting banners (new `status` frame), send-failure rollback
with tap-to-retry. E2E: `tests/e2e.py` driver automates scenarios 1–12
against the live gateway — 9 PASS / 2 PARTIAL (push device-notification
leg + gateway-kill leg are manual) / 1 SKIP (commentary is
model-dependent) / 0 FAIL; `ws_probe.py` gained `--assert-turn/
reasoning/tools/commentary/read-receipt/status` plus `--search`,
`--channel-*`, `--watch` modes. Docs: `frames.schema.json` finalized
(mirrors code exactly; 17 unimplemented frames moved to
`x-planned-frames`; 6 deltas + 3 drift fixes), `docs/setup.md` added
(pairing + FCM/ntfy + remote access + troubleshooting), root README
quickstart + status updated. Security: inbound JSON-frame rate limit
(20/s, burst 40, binary upload chunks exempt) with `rate_limited` error +
close; Android token moved to EncryptedSharedPreferences with one-time
plain→encrypted migration; `.pre-commit-config.yaml` commits the
hermes-agent guard; `09-pairing-security.md` M7 verification table (5
items verified, 3 documented gaps: in-app QR scan, WSS cert pinning,
mechanical log redaction). Known: commentary scenario is model-dependent
(SKIP); FCM path needs a Firebase project to exercise; M6's formal
desktop parity pass + macOS/Windows packaging remain open.
---
+1 -1
View File
@@ -62,6 +62,6 @@ project** (`app/`) with a shared KMP module (`app/shared`).
## Status
- **Phase:** Planning complete → ready to implement (Milestone M0).
- **Phase:** M0–M6 complete; M7 (polish + E2E + docs) in progress.
- **Owner decisions locked:** see [`16-open-questions.md`](16-open-questions.md).
- **Last updated:** 2026-08-19.
+31 -27
View File
@@ -20,7 +20,7 @@
"hello.ack": {
"description": "Pairing succeeded.",
"payload": {
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "pickers": {"type":"boolean"} } },
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } },
"sync_cursor": { "type": "integer" },
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
}
@@ -41,30 +41,21 @@
},
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" } } },
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } },
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
"typing": { "payload": { "on": { "type": "boolean" } } },
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
"picker.model": { "payload": { "picker_id": { "type": "string" }, "current_model": { "type": "string" }, "current_provider": { "type": "string" }, "providers": { "type": "array", "items": { "type": "object", "properties": { "id": {"type":"string"}, "label": {"type":"string"}, "models": { "type": "array", "items": { "type": "object", "properties": { "id": {"type":"string"}, "label": {"type":"string"} } } } } } } } },
"picker.choice": { "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object", "properties": { "value": {"type":"string"}, "label": {"type":"string"}, "is_current": {"type":"boolean"} } } } } },
"picker.clarify": { "payload": { "picker_id": { "type": "string" }, "question": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object" } } } },
"picker.approval": { "payload": { "picker_id": { "type": "string" }, "command": { "type": "string" }, "description": { "type": "string" } } },
"picker.confirm": { "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "message": { "type": "string" } } },
"channel.list": { "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
"channel.created": { "payload": { "$ref": "#/definitions/channel" } },
"channel.renamed": { "payload": { "chat_id": { "type": "string" }, "name": { "type": "string" } } },
"channel.renamed": { "description": "Also the response to channel.set_default (carries the full entry incl. is_default).", "payload": { "$ref": "#/definitions/channel" } },
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
"history": { "description": "Response to history request; page of messages oldest→newest.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string"}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"} } } }, "has_more": { "type": "boolean" }, "oldest_message_id": { "type": "string" } } },
"commands.catalog": { "description": "Full slash-command catalog.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": {"type":"string"}, "description": {"type":"string"}, "args_hint": {"type":"string"}, "category": {"type":"string"} } } } } },
"commands.complete": { "description": "Autocomplete matches for a typed prefix.", "payload": { "prefix": { "type": "string" }, "matches": { "type": "array", "items": { "type": "object", "properties": { "name": {"type":"string"}, "description": {"type":"string"}, "args_hint": {"type":"string"} } } } } },
"agent.busy": { "description": "Agent is processing; app shows thinking indicator.", "payload": { "reason": { "type": "string", "enum": ["processing", "tool", "waiting_input", "cron"] } } },
"agent.idle": { "description": "Agent turn complete; clear thinking indicator.", "payload": {} },
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
"status": { "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] }, "session": { "type": "object" } } },
"read.receipt": { "description": "Agent received and started processing the user's message; app shows ✓✓ on user bubbles. Emitted to the originating connection when a message.send is accepted for processing.", "payload": { "message_id": { "type": "string" } } },
"status": { "description": "Gateway health state; broadcast to all connected clients at startup (state=online).", "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] } } },
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
"pong": { "payload": { "ts": { "type": "integer" } } },
"sync.done": { "payload": { "cursor": { "type": "integer" } } },
@@ -77,18 +68,12 @@
"media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } },
"media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
"media.pull": { "payload": { "media_id": { "type": "string" } } },
"picker.select": { "payload": { "picker_id": { "type": "string" }, "value": { "type": "string" } } },
"channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
"channel.rename": { "payload": { "name": { "type": "string" } } },
"channel.set_default": { "payload": {} },
"channel.delete": { "payload": {} },
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" } } },
"history": { "description": "Load a page of messages (initial open / scroll-up).", "payload": { "before_message_id": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 200 } } },
"commands.catalog": { "description": "Fetch full slash-command catalog.", "payload": {} },
"commands.complete": { "description": "Autocomplete for typed /prefix.", "payload": { "prefix": { "type": "string" } } },
"agent.stop": { "description": "Abort current agent turn.", "payload": {} },
"agent.steer": { "description": "Inject steering message mid-turn.", "payload": { "text": { "type": "string" } } },
"read.receipt": { "description": "User viewed message; server stores + broadcasts to other devices.", "payload": { "chat_id": { "type": "string" }, "message_id": { "type": "string" } } },
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"ping": { "payload": { "ts": { "type": "integer" } } }
@@ -96,12 +81,31 @@
},
"definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"} } }
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }
},
"x-planned-frames": [
{ "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." },
{ "name": "picker.choice", "direction": "server_to_app", "note": "Generic choice picker prompt. Planned, not implemented." },
{ "name": "picker.clarify", "direction": "server_to_app", "note": "Clarify picker prompt. Planned, not implemented (clarifies arrive as notification + message)." },
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
{ "name": "history", "direction": "server_to_app", "note": "Paged history response. Planned, not implemented (catch-up is sync/outbox replay)." },
{ "name": "history", "direction": "app_to_server", "note": "Paged history request. Planned, not implemented (catch-up is sync/outbox replay)." },
{ "name": "commands.catalog", "direction": "server_to_app", "note": "Slash-command catalog. Planned, not implemented." },
{ "name": "commands.catalog", "direction": "app_to_server", "note": "Slash-command catalog request. Planned, not implemented." },
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented." },
{ "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." },
{ "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." },
{ "name": "agent.idle", "direction": "server_to_app", "note": "Agent-idle indicator. Planned, not implemented." },
{ "name": "agent.stop", "direction": "app_to_server", "note": "Abort current agent turn. Planned, not implemented." },
{ "name": "agent.steer", "direction": "app_to_server", "note": "Steer the agent mid-turn. Planned, not implemented." },
{ "name": "read.receipt", "direction": "app_to_server", "note": "User-viewed receipt (multi-device read state). Planned, not implemented (read.receipt is server→app only)." }
],
"reliability": {
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
"never_dropped": ["message", "message.stop", "tool.end", "notification", "picker.*", "channel.*", "agent.busy", "agent.idle", "history", "commands.catalog", "commands.complete", "search.results", "error"],
"never_dropped": ["message", "message.stop", "tool.end", "notification", "channel.*", "search.results", "error"],
"coalescable_under_backpressure": ["message.update", "tool.progress"],
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
}
+170
View File
@@ -0,0 +1,170 @@
# 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/android
hermes gateway status # should list "android"
```
Run the interactive setup:
```bash
hermes gateway setup
```
What it does:
- Generates `ANDROID_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 (`fcm` or `ntfy`, default `fcm`).
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
string) 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
> `ANDROID_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
## 2. Android app
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 ANDROID_TOKEN ~/.hermes/.env` on the gateway host.
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
pairing and connects.
> **Honest limitation:** QR scanning is **not** supported in the app yet. The
> server prints a QR payload, but pairing is manual URL + token entry only.
## 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.
### FCM (default; needs a Firebase project)
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`:
`ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default).
Without a Firebase project the FCM path is **inert** (the app's FCM service
does nothing) — use ntfy below, or add Firebase later.
**What you see:** system notifications for new messages when the app is
backgrounded; tapping one deep-links to the chat.
### ntfy (zero-config fallback)
```
ANDROID_PUSH_BACKEND=ntfy
```
- 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`.
**What you see:** a low-priority foreground "ntfy listener" notification while
the app is off; incoming pushes trigger a silent sync.
## 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 `ANDROID_WS_CERT` / `ANDROID_WS_KEY` (paths, in
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
> **Honest limitation:** the app has **no certificate pinning** yet, so
> self-signed certs won't work — remote access requires **CA-signed** WSS for
> now. 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 `ANDROID_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":"<ANDROID_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.