7 Commits
Author SHA1 Message Date
Pakobbix 2c1d444348 Merge pull request 'fix(http): don't let a half-open TLS connection wedge the accept loop' (#16) from fix/iris-tls-handshake-wedge into master
CI / Kotlin tests (android host + desktop) (push) Successful in 5m47s
CI / Gateway plugin tests (push) Failing after 15m58s
Reviewed-on: #16
2026-09-23 13:10:53 +00:00
ARIA 2c20b1c8a5 fix(http): don't let a half-open TLS connection wedge the accept loop
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 15m7s
A client that completes TCP but vanishes mid-TLS-handshake (e.g. a
phone losing its network/VPN while traveling) blocked
ssl.SSLSocket.accept() inside serve_forever forever: the gateway
stopped accepting any new device connections (the app could not
reconnect), and on the next restart httpd.shutdown() froze the whole
event loop until the shutdown watchdog killed the process (ARIA
journal 2026-09-11 / 2026-09-23).

- Move the TLS handshake out of the accept loop: it now runs in the
  per-connection thread under a hard timeout (HANDSHAKE_TIMEOUT_S,
  10 s); a failed/timed-out handshake just closes the socket.
- stop() no longer blocks the event loop: shutdown()/server_close()/
  join run in an executor under asyncio.wait_for(10 s); if the bound
  expires the daemon threads are abandoned.
- Regression test: a silent half-open TCP connection must not stop
  fresh TLS connections from being served, and stop() must stay
  bounded.
2026-09-23 15:10:16 +02:00
ARIA 8657e6afc6 fix(http): test acquire_scoped_lock's bool, not the always-truthy tuple
CI / Kotlin tests (android host + desktop) (push) Successful in 5m48s
CI / Gateway plugin tests (push) Successful in 7m40s
acquire_scoped_lock returns (acquired, existing_record); the old
'if not acquire_scoped_lock(...)' tested the tuple, which is always
truthy, so the 'port in use by another profile' pre-check never fired
and a conflict surfaced as a generic bind failure. Unpack and test the
first element, matching gateway/platforms/base.py's canonical usage.

Bump VERSION / plugin.yaml to 0.1.3.
2026-08-31 23:17:30 +02:00
ARIA 5b78e1566f docs(AGENTS): refresh to match current codebase
CI / Gateway plugin tests (push) Canceled after 0s
CI / Kotlin tests (android host + desktop) (push) Canceled after 0s
- remove hardcoded ADB serial (differs per developer/machine)
- add second pre-commit hook (check_version_sync) to hard rules
- document tests/, scripts/, backdrops/, CI-SETUP.md in layout
- note committed tests/test_android.py copy + test_android_http.py
2026-08-29 00:12:37 +02:00
ARIA 0f5b5a16ab Fix gateway advertising 'unknown' version in production installs
CI / Gateway plugin tests (push) Successful in 5m56s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m29s
hermes plugins install <repo>#gateway-plugin ships ONLY the
gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so the
repo-root VERSION file is not present there and plugin_version()
fell back to 'unknown' — the app then showed a false
'App and gateway versions differ' warning.

- version.py: resolution chain repo-root VERSION (dev/symlink
  install) -> plugin.yaml version field (production install) ->
  'unknown'; stdlib-only parse, base= param for tests
- plugin.yaml: bump stale version 0.1.0 -> 0.1.2 (matches VERSION)
- scripts/check_version_sync.sh + pre-commit hook: fail commits
  where plugin.yaml drifts from the repo-root VERSION
- tests: regression test simulating the production layout
2026-08-27 09:33:31 +02:00
ARIA ea375fd88c Fix double notification + double unread count for cron deliveries
CI / Gateway plugin tests (push) Successful in 8m58s
CI / Kotlin tests (android host + desktop) (push) Failing after 13m28s
A cron delivery emits two frames (high-priority notification banner +
message). Backgrounded, each posted its own system notification
('Cron: <job_id>' and the channel name), and the message frame's
redelivery (live SSE + sync replay after the push-triggered reconnect)
incremented the unread badge twice.

- Record when a high-priority banner (cron/approval/clarify) was posted
  per lane; suppress the message frame's system notification within a
  5 s window (mirrors the gateway's push-coalesce window).
- Dedupe unread counting per lane by message id so a redelivered frame
  only counts once (recorded only when the message actually counts as
  unread).
2026-08-27 09:23:49 +02:00
ARIA b065d1783b Docs: clarify ntfy privacy — default server is public ntfy.sh
CI / Gateway plugin tests (push) Successful in 8m58s
CI / Kotlin tests (android host + desktop) (push) Failing after 13m32s
The docs claimed ntfy keeps push metadata on your own infrastructure,
but the default NTFY_SERVER_URL is the public https://ntfy.sh cloud
service. Make explicit that push metadata (topic, notification title)
passes through ntfy.sh unless you self-host ntfy.
2026-08-27 09:23:44 +02:00
14 changed files with 457 additions and 55 deletions

No files matched your search

+6
View File
@@ -13,3 +13,9 @@ repos:
language: system language: system
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: check-version-sync
name: check gateway-plugin/plugin.yaml version == repo-root VERSION
entry: scripts/check_version_sync.sh
language: system
pass_filenames: false
always_run: true
+7 -2
View File
@@ -3,6 +3,7 @@
## Hard rules ## Hard rules
- `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.
- A second pre-commit hook (`scripts/check_version_sync.sh`) fails any commit where the `gateway-plugin/plugin.yaml` version ≠ the repo-root `VERSION` file — keep them in sync.
- **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/iris` → `<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).
@@ -11,12 +12,16 @@
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`. - `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell). - `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`. - `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
- `tests/` — committed Python test suite: `test_android.py` (plugin tests; a copy of the hermes-agent mirror described below), `test_android_http.py` (HTTP fallback transport, see `docs/19-http-fallback-transport.md`), `ws_probe.py`, `e2e.py`, `README.md`.
- `scripts/` — pre-commit guards (`guard_hermes_agent.sh`, `check_version_sync.sh`) and `make_release_keystore.sh`.
- `backdrops/` — backdrop/wallpaper images (Pexels) used by the app theme.
- `CI-SETUP.md` — Gitea CI/release setup reference.
## Commands ## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`). - `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`. - Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below). - Android: check the device is connected first (`adb devices` → your device's serial listed as `device`; the serial differs per developer/machine); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb). - Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite). - Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set). - Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
@@ -34,7 +39,7 @@
## Testing quirks ## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`. - `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); the committed `tests/test_android.py` is a copy of it, and `tests/test_android_http.py` covers the HTTP fallback transport. HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design. - e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`. - ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates. - ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
+10 -6
View File
@@ -7,9 +7,11 @@ Iris pairs with your running `hermes gateway` over a private, token-authenticate
## Features ## Features
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app - **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
- **Absolute Privacy!** — everything stays on your own infrastructure - **Absolute Privacy!** — chat stays on your own infrastructure
(push: ntfy by default; FCM is opt-in and routes push metadata via Google — (push: ntfy by default, **but the default ntfy server is the public
see [Push notifications](#push-notifications)) `ntfy.sh`** — self-host ntfy to keep push metadata on your own machine;
FCM is opt-in and routes push metadata via Google — see
[Push notifications](#push-notifications))
- **100 MB file uploads by default** — configurable on the gateway via - **100 MB file uploads by default** — configurable on the gateway via
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set `max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
on the gateway side (hermes), not in the app on the gateway side (hermes), not in the app
@@ -48,9 +50,11 @@ hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android
Push wakes a backgrounded/offline device; on reconnect the app syncs the Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost. outbox, so nothing is lost.
- **ntfy (default)** — push metadata stays on your own infrastructure - **ntfy (default)** — the backend for truly private communication.
(self-hosted ntfy recommended). This is the backend for truly private ⚠️ **By default it uses the public `https://ntfy.sh` cloud service** — push
communication. metadata (topic, notification title) passes through ntfy.sh's servers.
Set `NTFY_SERVER_URL` to a **self-hosted ntfy** to keep push metadata on
your own infrastructure (recommended; public ntfy.sh SSE is also flaky).
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push - **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
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.
+1 -1
View File
@@ -1 +1 @@
0.1.2 0.1.3
@@ -95,6 +95,8 @@ import kotlinx.coroutines.launch
import java.util.Collections import java.util.Collections
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/** /**
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore, * App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
@@ -188,13 +190,34 @@ class IrisController(
_foreground.value = fg _foreground.value = fg
} }
/** M8: the last message id whose arrival incremented [lane]'s unread
* badge. The same finalized message can be delivered twice (live SSE
* plus the sync replay after a push-triggered reconnect) — only the
* first delivery may count. Frame-handler coroutine only. */
private val countedMessageIds = HashMap<String, String>()
/** M5/M8: when a high-priority notification banner (cron/approval/clarify)
* was last posted per lane. Cron delivery = notification frame + message
* frame; the banner already announced the delivery, so the accompanying
* message frame must not post a second system notification. Frame-handler
* coroutine only. */
private val lastHighPriorityBannerAt = HashMap<String, TimeMark>()
/** M8: a finalized assistant message arrived in [lane]. Count it as unread /** M8: a finalized assistant message arrived in [lane]. Count it as unread
* unless the user is actively reading that lane right now (it is the * unless the user is actively reading that lane right now (it is the
* current lane, the app is focused, and the newest content is at the * current lane, the app is focused, and the newest content is at the
* bottom of the viewport). */ * bottom of the viewport). [messageId] dedupes redeliveries of the same
private fun noteIncomingAssistantMessage(lane: String) { * frame (see [countedMessageIds]). */
private fun noteIncomingAssistantMessage(
lane: String,
messageId: String?,
) {
if (messageId != null && countedMessageIds[lane] == messageId) return
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
if (!beingRead) chat.markUnread(lane) if (!beingRead) {
if (messageId != null) countedMessageIds[lane] = messageId
chat.markUnread(lane)
}
} }
/** M8: the user is now viewing the current lane's newest content — clear /** M8: the user is now viewing the current lane's newest content — clear
@@ -332,6 +355,11 @@ class IrisController(
/** Max simultaneous banners; persistent ones are exempt from the cap. */ /** Max simultaneous banners; persistent ones are exempt from the cap. */
private const val MAX_BANNERS = 5 private const val MAX_BANNERS = 5
/** Window in which a high-priority banner suppresses the system
* notification for its accompanying message frame (mirrors the
* gateway's push-coalesce window, classify._PUSH_COALESCE_S). */
private const val BANNER_NOTIFY_SUPPRESS_MS = 5_000L
const val FONT_SCALE_MIN = 0.8f const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f const val FONT_SCALE_MAX = 1.5f
@@ -523,6 +551,13 @@ class IrisController(
) { ) {
if (isAppForeground()) return if (isAppForeground()) return
if (text.isBlank()) return if (text.isBlank()) return
// A high-priority banner (cron/approval/clarify) for this lane just
// announced this delivery — don't stack a second notification for the
// accompanying message frame.
chatId?.let { cid ->
val mark = lastHighPriorityBannerAt[chat.laneKey(cid, threadId)]
if (mark != null && mark.elapsedNow().inWholeMilliseconds < BANNER_NOTIFY_SUPPRESS_MS) return
}
val id = chatId ?: "default" val id = chatId ?: "default"
val chatName = channels.byId(id)?.name val chatName = channels.byId(id)?.name
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId) postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
@@ -647,7 +682,7 @@ class IrisController(
// content — count it as unread unless the // content — count it as unread unless the
// user is reading this lane right now. // user is reading this lane right now.
frame.chatId?.let { cid -> frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
} }
if (!alreadyConsumed && !isPushedReplay(frame)) { if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
@@ -660,7 +695,7 @@ class IrisController(
if (it.role == ROLE_ASSISTANT) { if (it.role == ROLE_ASSISTANT) {
// M8: a finalized (non-streaming) reply. // M8: a finalized (non-streaming) reply.
frame.chatId?.let { cid -> frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
} }
if (!alreadyConsumed && !isPushedReplay(frame)) { if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
@@ -787,6 +822,15 @@ class IrisController(
// sync replay) must not re-show the banner. // sync replay) must not re-show the banner.
if (!alreadyConsumed) { if (!alreadyConsumed) {
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
// Cron delivery = notification frame + message frame: remember
// that the banner announced this lane so the message frame
// doesn't post a second system notification.
if (p.kind in HIGH_PRIORITY_NOTIF_KINDS) {
p.chatId?.let { cid ->
lastHighPriorityBannerAt[chat.laneKey(cid, p.threadId)] =
TimeSource.Monotonic.markNow()
}
}
// M5: the connection is live but the app is backgrounded — the // M5: the connection is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system // in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when // notification (the push backend only fires when
+5 -2
View File
@@ -3,8 +3,11 @@
The gateway can't reach a sleeping phone directly. Push goes through a cloud The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`). relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
Privacy: FCM push metadata (notification title, device token) is routed Privacy: FCM push metadata (notification title, device token) is routed
through Google's servers — for truly private communication use ntfy through Google's servers. ntfy is the private option — **but note the default
(self-hosted), which keeps everything on your own infrastructure. `NTFY_SERVER_URL` is the public `https://ntfy.sh` cloud service**, so push
metadata passes through ntfy.sh's servers unless you self-host ntfy (set
`NTFY_SERVER_URL`); only a self-hosted ntfy keeps everything on your own
infrastructure.
## 8.1 When push fires ## 8.1 When push fires
+6 -3
View File
@@ -201,9 +201,12 @@ app is closed. Nothing is lost either way — on reconnect the app syncs its
outbox. outbox.
- **ntfy (default)** — the phone generates its own topic automatically; the - **ntfy (default)** — the phone generates its own topic automatically; the
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy** gateway publishes to it. ⚠️ **The default server is the public
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata `https://ntfy.sh` cloud service** — push metadata (topic, notification
stays on your own infrastructure — this is the private option. title) passes through ntfy.sh's servers. Set `NTFY_SERVER_URL` to a
**self-hosted ntfy** to keep push metadata on your own infrastructure —
that is the private option (and also more reliable: the public `ntfy.sh`
SSE endpoint is flaky).
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push - **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
metadata (notification title, device token) is routed through **Google's metadata (notification title, device token) is routed through **Google's
servers**. Needs a Firebase project + `google-services.json` in the app servers**. Needs a Firebase project + `google-services.json` in the app
+11 -8
View File
@@ -16,8 +16,9 @@ threads, media, search — Telegram-quality, on your own infrastructure.
**Absolute Privacy!** — everything stays on your own infrastructure: **Absolute Privacy!** — everything stays on your own infrastructure:
- Your gateway, your machine, your data. No cloud middleman for chat. - Your gateway, your machine, your data. No cloud middleman for chat.
- **Push notifications:** ntfy by default — push metadata stays on your own - **Push notifications:** ntfy by default. Note: out of the box it uses the
(self-hosted) ntfy server. public ntfy.sh service; self-host ntfy (one env var) to keep push metadata
on your own server.
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM - **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
push metadata (notification title, device token) is routed through push metadata (notification title, device token) is routed through
**Google's servers**. If you want truly private communication, use ntfy **Google's servers**. If you want truly private communication, use ntfy
@@ -32,10 +33,12 @@ Runs on Android and desktop (Linux, macOS, Windows) from one shared codebase.
## Privacy note (for the "Data safety" section / FAQ) ## Privacy note (for the "Data safety" section / FAQ)
Iris talks directly to your own hermes gateway over a private, token-authenticated Iris talks directly to your own hermes gateway over a private, token-authenticated
connection. By default, push notifications use ntfy, which you can self-host so connection. By default, push notifications use ntfy — out of the box via the
that push metadata never leaves your infrastructure. If you explicitly enable public ntfy.sh service (push metadata such as the topic and notification title
FCM, push metadata (notification title, device token) is sent via Google's FCM passes through ntfy.sh's servers); self-host ntfy (one env var) so that push
servers; chat content itself is not sent to Google — FCM only carries a short metadata never leaves your infrastructure. If you explicitly enable FCM, push
preview, and full content is fetched from your gateway over the authenticated metadata (notification title, device token) is sent via Google's FCM servers;
chat content itself is not sent to Google — FCM only carries a short preview,
and full content is fetched from your gateway over the authenticated
connection. For truly private communication, use the default ntfy backend connection. For truly private communication, use the default ntfy backend
(self-hosted). with a self-hosted ntfy server.
+88 -12
View File
@@ -45,6 +45,7 @@ import contextlib
import json import json
import logging import logging
import queue import queue
import socket
import ssl import ssl
import threading import threading
import time import time
@@ -199,7 +200,11 @@ class HttpServer:
from gateway.status import acquire_scoped_lock from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}" lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("iris", lock_key): # acquire_scoped_lock returns (acquired, existing_record); the
# tuple is always truthy, so test the first element (matching
# gateway/platforms/base.py's canonical usage).
acquired, _ = acquire_scoped_lock("iris", lock_key)
if not acquired:
logger.warning( logger.warning(
"iris: HTTP port %s:%s in use by another profile; server disabled", "iris: HTTP port %s:%s in use by another profile; server disabled",
host, host,
@@ -216,7 +221,14 @@ class HttpServer:
if self._adapter.http_cert and self._adapter.http_key: if self._adapter.http_cert and self._adapter.http_key:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key) ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) # The handshake runs in the per-connection thread with a
# hard timeout (see _ThreadingHTTPD.process_request).
# Wrapping the *listening* socket here instead would make
# serve_forever's accept() block inside do_handshake() on
# a half-open connection (TCP established, client gone
# mid-handshake), wedging ALL new device connections until
# the gateway is restarted.
httpd.set_tls(ctx)
except Exception as e: except Exception as e:
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e) logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
self._release_lock() self._release_lock()
@@ -241,17 +253,35 @@ class HttpServer:
s.q.put_nowait(_STOP) s.q.put_nowait(_STOP)
httpd = self._httpd httpd = self._httpd
self._httpd = None self._httpd = None
if httpd is not None:
# shutdown() must be called from a thread other than the one
# running serve_forever(); we are on the asyncio loop thread.
with contextlib.suppress(Exception):
httpd.shutdown()
with contextlib.suppress(Exception):
httpd.server_close()
t = self._thread t = self._thread
self._thread = None self._thread = None
if t is not None and t is not threading.current_thread(): if httpd is not None or t is not None:
t.join(timeout=5.0) # shutdown() blocks until the serve_forever loop exits and
# server_close() may join handler threads — both must run on a
# worker thread (never the asyncio loop thread) with a hard
# timeout, or a wedged server would freeze the whole gateway.
# The threads are daemons: if the bounded wait expires they die
# with the process and there is nothing left to do.
loop = asyncio.get_running_loop()
def _stop_httpd() -> None:
if httpd is not None:
with contextlib.suppress(Exception):
httpd.shutdown()
with contextlib.suppress(Exception):
httpd.server_close()
if t is not None and t is not threading.current_thread():
t.join(timeout=5.0)
try:
await asyncio.wait_for(
loop.run_in_executor(None, _stop_httpd),
timeout=10.0,
)
except Exception:
logger.warning(
"iris: HTTP server teardown did not finish in time; abandoning daemon threads"
)
self._release_lock() self._release_lock()
def _release_lock(self) -> None: def _release_lock(self) -> None:
@@ -779,14 +809,60 @@ class HttpServer:
class _ThreadingHTTPD(ThreadingHTTPServer): class _ThreadingHTTPD(ThreadingHTTPServer):
"""One thread per connection (fine at single-user scale); daemon """One thread per connection (fine at single-user scale); daemon
threads so a stuck handler can't block process exit.""" threads so a stuck handler can't block process exit.
With TLS enabled (``set_tls``) the handshake runs in the
per-connection thread under a hard timeout — never in the
``serve_forever`` accept loop. ``ssl.SSLSocket.accept()`` would
otherwise block that loop inside ``do_handshake()`` on a half-open
connection (TCP established but the client vanished mid-handshake,
e.g. a phone losing its network/VPN), and the gateway would stop
accepting any new device connections until it is restarted.
"""
daemon_threads = True daemon_threads = True
allow_reuse_address = True allow_reuse_address = True
# A client that completes TCP but never finishes the TLS handshake
# must not hold the connection open indefinitely.
HANDSHAKE_TIMEOUT_S = 10.0
def __init__(self, addr: tuple[str, int], http_server: HttpServer): def __init__(self, addr: tuple[str, int], http_server: HttpServer):
super().__init__(addr, _Handler) super().__init__(addr, _Handler)
self.http_server = http_server self.http_server = http_server
self._tls_ctx: ssl.SSLContext | None = None
def set_tls(self, ctx: ssl.SSLContext) -> None:
self._tls_ctx = ctx
def process_request( # noqa: A003 # type: ignore[override]
self, request: socket.socket, client_address: Any
) -> None:
"""Spawn the handler thread; with TLS, the handshake happens in
that thread first, under ``HANDSHAKE_TIMEOUT_S`` (see class
docstring). A failed/timed-out handshake just closes the socket —
the accept loop is never blocked by it."""
if self._tls_ctx is None:
super().process_request(request, client_address)
return
tls_ctx = self._tls_ctx
def _handshake_then_handle() -> None:
try:
request.settimeout(self.HANDSHAKE_TIMEOUT_S)
# wrap_socket() performs the handshake (default
# do_handshake_on_connect=True); restore blocking mode for
# the request handler afterwards.
tls_sock = tls_ctx.wrap_socket(request, server_side=True)
tls_sock.settimeout(None)
except OSError as e: # ssl.SSLError, timeout, reset, ...
with contextlib.suppress(OSError):
request.close()
logger.debug("iris http: TLS handshake failed (%s): %s", client_address, e)
return
super(_ThreadingHTTPD, self).process_request(tls_sock, client_address)
threading.Thread(target=_handshake_then_handle, name="iris-tls", daemon=True).start()
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
+5 -1
View File
@@ -1,7 +1,11 @@
name: iris-platform name: iris-platform
label: Iris label: Iris
kind: platform kind: platform
version: 0.1.0 # MUST match the repo-root VERSION file (checked by
# scripts/check_version_sync.sh on commit). This field is the version the
# gateway advertises in production installs, where only this plugin dir is
# shipped (see version.py).
version: 0.1.3
description: > description: >
Native Android / Desktop client gateway adapter for Hermes Agent. Native Android / Desktop client gateway adapter for Hermes Agent.
Runs an HTTP server (optional TLS) inside the gateway; the app connects Runs an HTTP server (optional TLS) inside the gateway; the app connects
+55 -14
View File
@@ -1,26 +1,67 @@
"""Version discovery -- the repo-root ``VERSION`` file is the single source """Version discovery.
of truth for the release version ("everything from here on out is vX.Y.Z"
= bump ``VERSION`` and commit).
The plugin lives at ``<repo>/gateway-plugin`` (installed into The repo-root ``VERSION`` file is the single source of truth for the release
``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION`` version ("everything from here on out is vX.Y.Z" = bump ``VERSION`` and
file is one directory up. The value is advertised to the app in commit). It is advertised to the app in ``hello.ack``
``hello.ack`` (``server_caps.app_version``) so the app can show which (``server_caps.app_version``) so the app can show which gateway version it is
gateway version it is talking to. talking to.
Resolution order (first hit wins):
1. ``<repo>/VERSION`` — dev checkout / symlink install: the plugin lives at
``<repo>/gateway-plugin``, so the ``VERSION`` file is one directory up.
2. ``version:`` in the plugin's own ``plugin.yaml`` — production install:
``hermes plugins install <repo>#gateway-plugin`` moves ONLY the
``gateway-plugin/`` subdirectory into ``~/.hermes/plugins/iris``, so the
repo-root ``VERSION`` is not present there. ``plugin.yaml`` ships with the
plugin dir; a pre-commit hook (``scripts/check_version_sync.sh``) keeps its
``version:`` field in sync with the repo-root ``VERSION``.
3. ``"unknown"``.
""" """
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path from pathlib import Path
_FALLBACK = "unknown" _FALLBACK = "unknown"
_VERSION_RE = re.compile(r"^version:\s*[\"']?([^\"'\s]+)")
def plugin_version() -> str:
"""The release version from ``<repo>/VERSION``, or ``"unknown"``.""" def _read_version_file(path: Path) -> str:
candidate = Path(__file__).resolve().parent.parent / "VERSION"
try: try:
version = candidate.read_text().strip() return path.read_text().strip()
except OSError: except OSError:
return _FALLBACK return ""
return version or _FALLBACK
def _plugin_yaml_version(plugin_dir: Path) -> str:
"""The top-level ``version:`` field of ``plugin.yaml`` (stdlib-only parse)."""
try:
text = (plugin_dir / "plugin.yaml").read_text()
except OSError:
return ""
for line in text.splitlines():
m = _VERSION_RE.match(line)
if m:
return m.group(1)
return ""
def plugin_version(base: Path | None = None) -> str:
"""The release version, or ``"unknown"`` if it cannot be found.
``base`` overrides the plugin directory (tests); by default it is the
directory containing this file.
"""
plugin_dir = base if base is not None else Path(__file__).resolve().parent
# 1. Repo-root VERSION (dev checkout / symlink install).
version = _read_version_file(plugin_dir.parent / "VERSION")
if version:
return version
# 2. plugin.yaml (production install ships only the plugin dir).
version = _plugin_yaml_version(plugin_dir)
if version:
return version
return _FALLBACK
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Fail the commit if gateway-plugin/plugin.yaml's `version:` field drifts
# from the repo-root VERSION file (the single source of truth).
#
# Why: `hermes plugins install <repo>#gateway-plugin` ships ONLY the
# gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so in production
# the gateway advertises the version from plugin.yaml (see
# gateway-plugin/version.py). If the two drift, the app shows a false
# "versions differ" warning.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
version_file="$repo_root/VERSION"
plugin_yaml="$repo_root/gateway-plugin/plugin.yaml"
[ -f "$version_file" ] || {
echo "check_version_sync: missing $version_file" >&2
exit 1
}
[ -f "$plugin_yaml" ] || {
echo "check_version_sync: missing $plugin_yaml" >&2
exit 1
}
root_version="$(tr -d '[:space:]' <"$version_file")"
# Mirrors gateway-plugin/version.py's _VERSION_RE: optional single OR double
# quote, at least one captured character.
yaml_version="$(sed -n "s/^version:[[:space:]]*[\"']\{0,1\}\([^\"'[:space:]]\{1,\}\).*/\1/p" "$plugin_yaml" | head -n1)"
if [ -z "$yaml_version" ]; then
echo "check_version_sync: no top-level 'version:' field in gateway-plugin/plugin.yaml" >&2
exit 1
fi
if [ "$root_version" != "$yaml_version" ]; then
echo "check_version_sync: version drift" >&2
echo " VERSION (repo root) = $root_version" >&2
echo " gateway-plugin/plugin.yaml = $yaml_version" >&2
echo "Bump both to the same value (VERSION is the source of truth)." >&2
exit 1
fi
+40
View File
@@ -486,6 +486,46 @@ async def test_hello_ack_advertises_app_version_and_sse_header_is_stored(
assert device2["caps"].get("app_version") == "9.9.9" assert device2["caps"].get("app_version") == "9.9.9"
def test_plugin_version_falls_back_to_plugin_yaml_in_production_layout(plugin, tmp_path):
"""Production install (``hermes plugins install <repo>#gateway-plugin``)
ships ONLY the ``gateway-plugin/`` subdirectory into
``~/.hermes/plugins/iris`` — the repo-root ``VERSION`` file is not
present there. The advertised version must then come from
``plugin.yaml``, not "unknown" (regression: the app showed
'Gateway vunknown' against a production gateway)."""
v = plugin.version
# Dev checkout / symlink install: repo-root VERSION is the source of truth.
root_version = Path(v.__file__).resolve().parent.parent / "VERSION"
root_value = root_version.read_text().strip() if root_version.is_file() else ""
if root_value:
assert v.plugin_version() == root_value
# Simulate the production layout: a plugin dir with a plugin.yaml but no
# VERSION file one level up.
prod_dir = tmp_path / "plugins" / "iris"
prod_dir.mkdir(parents=True)
(prod_dir / "plugin.yaml").write_text(
(Path(v.__file__).parent / "plugin.yaml").read_text()
)
advertised = v.plugin_version(base=prod_dir)
assert advertised != "unknown"
# The pre-commit hook (scripts/check_version_sync.sh) keeps plugin.yaml in
# sync with the repo-root VERSION, so the production fallback must
# advertise the same real value.
if root_value:
assert advertised == root_value
# A VERSION file one level up still wins when present.
(tmp_path / "plugins" / "VERSION").write_text("9.9.9\n")
assert v.plugin_version(base=prod_dir) == "9.9.9"
# Nothing at all -> "unknown".
empty = tmp_path / "empty"
empty.mkdir()
assert v.plugin_version(base=empty) == "unknown"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_disconnect_broadcasts_status_restarting(adapter): async def test_disconnect_broadcasts_status_restarting(adapter):
"""Teardown broadcasts ``status{state=restarting}`` before closing the """Teardown broadcasts ``status{state=restarting}`` before closing the
+133 -1
View File
@@ -29,11 +29,14 @@ import asyncio
import base64 import base64
import contextlib import contextlib
import importlib.util import importlib.util
import ipaddress
import json import json
import os import os
import socket
import ssl
import sys import sys
import time import time
from http.client import HTTPConnection from http.client import HTTPConnection, HTTPSConnection
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -835,6 +838,135 @@ async def test_media_pull_denied_path_404(gw):
assert body["payload"]["code"] == "not_found" assert body["payload"]["code"] == "not_found"
# ── TLS: a half-open connection must not wedge the accept loop ─────────────
#
# Regression (ARIA journal 2026-09-11 / 2026-09-23): the listening socket
# used to be wrapped in a server-side ssl.SSLSocket, so serve_forever's
# accept() ran the TLS handshake inline. A client that completed TCP but
# vanished mid-handshake (a phone losing its network/VPN while traveling)
# blocked do_handshake() forever: the gateway stopped accepting ANY new
# device connections (the app could not reconnect), and on the next restart
# httpd.shutdown() froze the whole event loop until the shutdown watchdog
# killed the process.
def _make_self_signed_cert(tmp_path: Path) -> tuple[Path, Path] | None:
"""Self-signed cert + key for the TLS tests; None when
``cryptography`` is unavailable (the tests then skip)."""
try:
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
except ImportError:
return None
import datetime
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "iris-test")])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName(
[
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
]
),
critical=False,
)
.sign(key, hashes.SHA256())
)
cert_path = tmp_path / "iris-test.crt"
key_path = tmp_path / "iris-test.key"
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(
key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
)
)
return cert_path, key_path
@pytest_asyncio.fixture
async def gw_tls(adapter, tmp_path, monkeypatch):
"""Connected adapter with the HTTP leg TLS-enabled; the handshake
timeout is shortened so the half-open connection cleans itself up
quickly."""
paths = _make_self_signed_cert(tmp_path)
if paths is None:
pytest.skip("cryptography not available; TLS wedge test skipped")
cert_path, key_path = paths
plugin = _load_plugin()
monkeypatch.setattr(
plugin.http_server._ThreadingHTTPD, "HANDSHAKE_TIMEOUT_S", 0.5, raising=False
)
adapter.http_cert = str(cert_path)
adapter.http_key = str(key_path)
await adapter.connect()
try:
yield adapter
finally:
await adapter.disconnect()
def _tls_health(port: int) -> int:
"""GET /v1/health over a fresh TLS connection; returns the status."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = HTTPSConnection("127.0.0.1", port, timeout=5.0, context=ctx)
conn.request("GET", "/v1/health")
resp = conn.getresponse()
status = resp.status
resp.read()
conn.close()
return status
@pytest.mark.asyncio
async def test_half_open_tls_connection_does_not_wedge_accept_loop(gw_tls):
"""A client that completes TCP but never finishes the TLS handshake
must not stop the server from accepting new connections (see section
comment for the incident)."""
port = http_port(gw_tls)
# 1) Half-open connection: TCP established, then silence — the
# phone-loses-its-VPN scenario (the ClientHello never arrives).
wedge = socket.create_connection(("127.0.0.1", port), timeout=5.0)
try:
# 2) While the half-open connection sits un-handshaked, a fresh,
# well-formed TLS connection must still be accepted promptly.
deadline = time.monotonic() + 10.0
status = None
while time.monotonic() < deadline:
try:
status = await asyncio.to_thread(_tls_health, port)
break
except OSError:
await asyncio.sleep(0.2)
assert status == 200, f"health over TLS failed (status={status})"
# 3) Teardown must stay bounded with the half-open connection still
# open: stop() used to block the event loop on httpd.shutdown()
# until the shutdown watchdog killed the process.
t0 = time.monotonic()
await gw_tls._http_server.stop()
assert time.monotonic() - t0 < 15.0
finally:
with contextlib.suppress(OSError):
wedge.close()
# ── Helpers ───────────────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────────────