M7: polish + E2E + docs (layout pass, theming, states, e2e driver, schema, setup.md, security)
This commit is contained in:
1 parent
0cc8b7aafe
commit
bf6bf7e8bd
26 files changed
+2225
-327
No files matched your search
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user