Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1d444348 | ||
|
|
2c20b1c8a5 | ||
|
|
8657e6afc6 | ||
|
|
5b78e1566f | ||
|
|
0f5b5a16ab | ||
|
|
ea375fd88c | ||
|
|
b065d1783b |
No files matched your search
@@ -12,4 +12,10 @@ repos:
|
||||
entry: scripts/guard_hermes_agent.sh --staged
|
||||
language: system
|
||||
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
|
||||
@@ -3,6 +3,7 @@
|
||||
## 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.
|
||||
- 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).
|
||||
- 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`.
|
||||
- `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`.
|
||||
- `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
|
||||
|
||||
- `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`.
|
||||
- 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).
|
||||
- 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).
|
||||
@@ -34,7 +39,7 @@
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
@@ -7,9 +7,11 @@ Iris pairs with your running `hermes gateway` over a private, token-authenticate
|
||||
## Features
|
||||
|
||||
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
|
||||
- **Absolute Privacy!** — everything stays on your own infrastructure
|
||||
(push: ntfy by default; FCM is opt-in and routes push metadata via Google —
|
||||
see [Push notifications](#push-notifications))
|
||||
- **Absolute Privacy!** — chat stays on your own infrastructure
|
||||
(push: ntfy by default, **but the default ntfy server is the public
|
||||
`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
|
||||
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
|
||||
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
|
||||
outbox, so nothing is lost.
|
||||
|
||||
- **ntfy (default)** — push metadata stays on your own infrastructure
|
||||
(self-hosted ntfy recommended). This is the backend for truly private
|
||||
communication.
|
||||
- **ntfy (default)** — the backend for truly private communication.
|
||||
⚠️ **By default it uses the public `https://ntfy.sh` cloud service** — push
|
||||
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
|
||||
metadata (notification title, device token) is routed through **Google's
|
||||
servers**. If you want truly private communication, use ntfy instead.
|
||||
|
||||
@@ -95,6 +95,8 @@ import kotlinx.coroutines.launch
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
|
||||
@@ -188,13 +190,34 @@ class IrisController(
|
||||
_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
|
||||
* 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
|
||||
* bottom of the viewport). */
|
||||
private fun noteIncomingAssistantMessage(lane: String) {
|
||||
* bottom of the viewport). [messageId] dedupes redeliveries of the same
|
||||
* 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
|
||||
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
|
||||
@@ -332,6 +355,11 @@ class IrisController(
|
||||
/** Max simultaneous banners; persistent ones are exempt from the cap. */
|
||||
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_MAX = 1.5f
|
||||
|
||||
@@ -523,6 +551,13 @@ class IrisController(
|
||||
) {
|
||||
if (isAppForeground()) 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 chatName = channels.byId(id)?.name
|
||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||
@@ -647,7 +682,7 @@ class IrisController(
|
||||
// content — count it as unread unless the
|
||||
// user is reading this lane right now.
|
||||
frame.chatId?.let { cid ->
|
||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
|
||||
}
|
||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||
@@ -660,7 +695,7 @@ class IrisController(
|
||||
if (it.role == ROLE_ASSISTANT) {
|
||||
// M8: a finalized (non-streaming) reply.
|
||||
frame.chatId?.let { cid ->
|
||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
|
||||
}
|
||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||
@@ -787,6 +822,15 @@ class IrisController(
|
||||
// sync replay) must not re-show the banner.
|
||||
if (!alreadyConsumed) {
|
||||
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
|
||||
// in-app banner is invisible, so mirror to a system
|
||||
// notification (the push backend only fires when
|
||||
|
||||
+5
-2
@@ -3,8 +3,11 @@
|
||||
The gateway can't reach a sleeping phone directly. Push goes through a cloud
|
||||
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
|
||||
Privacy: FCM push metadata (notification title, device token) is routed
|
||||
through Google's servers — for truly private communication use ntfy
|
||||
(self-hosted), which keeps everything on your own infrastructure.
|
||||
through Google's servers. ntfy is the private option — **but note the default
|
||||
`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
|
||||
|
||||
|
||||
+6
-3
@@ -201,9 +201,12 @@ app is closed. Nothing is lost either way — on reconnect the app syncs its
|
||||
outbox.
|
||||
|
||||
- **ntfy (default)** — the phone generates its own topic automatically; the
|
||||
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy**
|
||||
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata
|
||||
stays on your own infrastructure — this is the private option.
|
||||
gateway publishes to it. ⚠️ **The default server is the public
|
||||
`https://ntfy.sh` cloud service** — push 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 —
|
||||
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
|
||||
metadata (notification title, device token) is routed through **Google's
|
||||
servers**. Needs a Firebase project + `google-services.json` in the app
|
||||
|
||||
@@ -16,8 +16,9 @@ threads, media, search — Telegram-quality, on your own infrastructure.
|
||||
**Absolute Privacy!** — everything stays on your own infrastructure:
|
||||
|
||||
- Your gateway, your machine, your data. No cloud middleman for chat.
|
||||
- **Push notifications:** ntfy by default — push metadata stays on your own
|
||||
(self-hosted) ntfy server.
|
||||
- **Push notifications:** ntfy by default. Note: out of the box it uses the
|
||||
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
|
||||
push metadata (notification title, device token) is routed through
|
||||
**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)
|
||||
|
||||
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
|
||||
that push metadata never leaves your infrastructure. If you explicitly enable
|
||||
FCM, push 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. By default, push notifications use ntfy — out of the box via the
|
||||
public ntfy.sh service (push metadata such as the topic and notification title
|
||||
passes through ntfy.sh's servers); self-host ntfy (one env var) so that push
|
||||
metadata never leaves your infrastructure. If you explicitly enable FCM, push
|
||||
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
|
||||
(self-hosted).
|
||||
with a self-hosted ntfy server.
|
||||
@@ -45,6 +45,7 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
@@ -199,7 +200,11 @@ class HttpServer:
|
||||
from gateway.status import acquire_scoped_lock
|
||||
|
||||
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(
|
||||
"iris: HTTP port %s:%s in use by another profile; server disabled",
|
||||
host,
|
||||
@@ -216,7 +221,14 @@ class HttpServer:
|
||||
if self._adapter.http_cert and self._adapter.http_key:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
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:
|
||||
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
|
||||
self._release_lock()
|
||||
@@ -241,17 +253,35 @@ class HttpServer:
|
||||
s.q.put_nowait(_STOP)
|
||||
httpd = self._httpd
|
||||
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
|
||||
self._thread = None
|
||||
if t is not None and t is not threading.current_thread():
|
||||
t.join(timeout=5.0)
|
||||
if httpd is not None or t is not None:
|
||||
# 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()
|
||||
|
||||
def _release_lock(self) -> None:
|
||||
@@ -779,14 +809,60 @@ class HttpServer:
|
||||
|
||||
class _ThreadingHTTPD(ThreadingHTTPServer):
|
||||
"""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
|
||||
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):
|
||||
super().__init__(addr, _Handler)
|
||||
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):
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
name: iris-platform
|
||||
label: Iris
|
||||
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: >
|
||||
Native Android / Desktop client gateway adapter for Hermes Agent.
|
||||
Runs an HTTP server (optional TLS) inside the gateway; the app connects
|
||||
|
||||
+55
-14
@@ -1,26 +1,67 @@
|
||||
"""Version discovery -- the repo-root ``VERSION`` file is the single source
|
||||
of truth for the release version ("everything from here on out is vX.Y.Z"
|
||||
= bump ``VERSION`` and commit).
|
||||
"""Version discovery.
|
||||
|
||||
The plugin lives at ``<repo>/gateway-plugin`` (installed into
|
||||
``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION``
|
||||
file is one directory up. The value is advertised to the app in
|
||||
``hello.ack`` (``server_caps.app_version``) so the app can show which
|
||||
gateway version it is talking to.
|
||||
The repo-root ``VERSION`` file is the single source of truth for the release
|
||||
version ("everything from here on out is vX.Y.Z" = bump ``VERSION`` and
|
||||
commit). It is advertised to the app in ``hello.ack``
|
||||
(``server_caps.app_version``) so the app can show which gateway version it is
|
||||
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
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FALLBACK = "unknown"
|
||||
|
||||
_VERSION_RE = re.compile(r"^version:\s*[\"']?([^\"'\s]+)")
|
||||
|
||||
def plugin_version() -> str:
|
||||
"""The release version from ``<repo>/VERSION``, or ``"unknown"``."""
|
||||
candidate = Path(__file__).resolve().parent.parent / "VERSION"
|
||||
|
||||
def _read_version_file(path: Path) -> str:
|
||||
try:
|
||||
version = candidate.read_text().strip()
|
||||
return path.read_text().strip()
|
||||
except OSError:
|
||||
return _FALLBACK
|
||||
return version or _FALLBACK
|
||||
return ""
|
||||
|
||||
|
||||
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
|
||||
Executable
+41
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
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
|
||||
async def test_disconnect_broadcasts_status_restarting(adapter):
|
||||
"""Teardown broadcasts ``status{state=restarting}`` before closing the
|
||||
|
||||
+133
-1
@@ -29,11 +29,14 @@ import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from http.client import HTTPConnection
|
||||
from http.client import HTTPConnection, HTTPSConnection
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -835,6 +838,135 @@ async def test_media_pull_denied_path_404(gw):
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user