Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1d444348 | ||
|
|
2c20b1c8a5 | ||
|
|
8657e6afc6 | ||
|
|
5b78e1566f | ||
|
|
0f5b5a16ab | ||
|
|
ea375fd88c | ||
|
|
b065d1783b | ||
|
|
fb980d12b4 | ||
|
|
f3f1b37221 | ||
|
|
6f339330c5 | ||
|
|
573291fc1e | ||
|
|
83a67f6fb1 | ||
|
|
c7a16d51e3 | ||
|
|
b1c9bac7d8 | ||
|
|
a61b47a947 | ||
|
|
597a28050f | ||
|
|
f90e40a3fc | ||
|
|
330e63e941 | ||
|
|
b8e756c3dd | ||
|
|
7faaf2aa1c | ||
|
|
746d809d48 |
No files matched your search
@@ -35,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run android gateway tests
|
- name: Run android gateway tests
|
||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
# The release version comes from the repo-root VERSION file (the single
|
||||||
description: "Release version (e.g. 0.2.0)"
|
# source of truth) — bump it in a commit, then dispatch this workflow.
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
changelog:
|
changelog:
|
||||||
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
||||||
required: false
|
required: false
|
||||||
@@ -38,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run android gateway tests
|
- name: Run android gateway tests
|
||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
@@ -133,7 +131,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build APK + AAB
|
- name: Build APK + AAB
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||||
|
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||||
cd app
|
cd app
|
||||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||||
# APK for direct sideloading, AAB for Play Store uploads.
|
# APK for direct sideloading, AAB for Play Store uploads.
|
||||||
@@ -155,7 +154,8 @@ jobs:
|
|||||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
||||||
- name: Build desktop app-image + deb
|
- name: Build desktop app-image + deb
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||||
|
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||||
cd app
|
cd app
|
||||||
# Self-contained app image (JRE bundled via jlink).
|
# Self-contained app image (JRE bundled via jlink).
|
||||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
||||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
||||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
||||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||||
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
||||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
||||||
TAG="v$VERSION"
|
TAG="v$VERSION"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"gateway-plugin/tests/test_android.py"
|
"tests/test_android.py"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"unchecked-throwing-call-python": {
|
"unchecked-throwing-call-python": {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,30 +3,35 @@
|
|||||||
## 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/android` → `<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).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `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).
|
||||||
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
|
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `tests/README.md`.
|
||||||
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
|
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python tests/e2e.py`.
|
||||||
|
|
||||||
## Environment / pairing quirks
|
## Environment / pairing quirks
|
||||||
|
|
||||||
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
|
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
|
||||||
- WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_HOST` to the gateway's LAN IP.
|
- HTTP default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
||||||
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
||||||
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
||||||
- **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
|
- **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
|
||||||
@@ -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.
|
||||||
|
|||||||
+53
-25
@@ -7,10 +7,10 @@ Everything needed for the Gitea workflows (CI + manual release). Items marked
|
|||||||
|
|
||||||
## 1. DONE — no action needed
|
## 1. DONE — no action needed
|
||||||
|
|
||||||
- `gateway-plugin/tests/test_android.py` — vendored byte-identical mirror of
|
- `tests/test_android.py` — vendored byte-identical mirror of
|
||||||
`hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout
|
`hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout
|
||||||
is the canonical copy; **keep the two in sync** when you change that test).
|
is the canonical copy; **keep the two in sync** when you change that test).
|
||||||
- `.pi-lens.json` — added `"ignore": ["gateway-plugin/tests/test_android.py"]`
|
- `.pi-lens.json` — added `"ignore": ["tests/test_android.py"]`
|
||||||
so the scanner doesn't flag the vendored mirror.
|
so the scanner doesn't flag the vendored mirror.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run android gateway tests
|
- name: Run android gateway tests
|
||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
@@ -100,11 +100,13 @@ jobs:
|
|||||||
|
|
||||||
## 3. NEW FILE: `.gitea/workflows/release.yml`
|
## 3. NEW FILE: `.gitea/workflows/release.yml`
|
||||||
|
|
||||||
Manual trigger: **repo → Actions → Release → Run workflow**, enter a
|
The release version is the repo-root **`VERSION` file** (single source of
|
||||||
`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI,
|
truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
|
||||||
builds a signed Android APK + AAB and the Linux desktop packages (jpackage,
|
commit). Manual trigger: **repo → Actions → Release → Run workflow**,
|
||||||
JRE bundled), then creates the Gitea release `v<version>` with all artifacts
|
optionally with a `changelog`. It runs the same tests as CI, builds a signed
|
||||||
as download attachments.
|
Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled),
|
||||||
|
then creates the Gitea release `v<VERSION>` with all artifacts as download
|
||||||
|
attachments.
|
||||||
|
|
||||||
Note: builds + release creation happen in ONE job because Gitea/act_runner
|
Note: builds + release creation happen in ONE job because Gitea/act_runner
|
||||||
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
|
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
|
||||||
@@ -116,12 +118,10 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
# The release version comes from the repo-root VERSION file (the single
|
||||||
description: "Release version (e.g. 0.2.0)"
|
# source of truth) — bump it in a commit, then dispatch this workflow.
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
changelog:
|
changelog:
|
||||||
description: "Release notes (markdown, shown on the release page)"
|
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run android gateway tests
|
- name: Run android gateway tests
|
||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
@@ -246,7 +246,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build APK + AAB
|
- name: Build APK + AAB
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||||
|
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||||
cd app
|
cd app
|
||||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||||
# APK for direct sideloading, AAB for Play Store uploads.
|
# APK for direct sideloading, AAB for Play Store uploads.
|
||||||
@@ -268,7 +269,8 @@ jobs:
|
|||||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
||||||
- name: Build desktop app-image + deb
|
- name: Build desktop app-image + deb
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||||
|
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||||
cd app
|
cd app
|
||||||
# Self-contained app image (JRE bundled via jlink).
|
# Self-contained app image (JRE bundled via jlink).
|
||||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||||
@@ -290,20 +292,39 @@ jobs:
|
|||||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
||||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
||||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
||||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH")
|
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
||||||
|
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
||||||
TAG="v$VERSION"
|
TAG="v$VERSION"
|
||||||
API="$SERVER/api/v1/repos/$REPO"
|
API="$SERVER/api/v1/repos/$REPO"
|
||||||
AUTH="Authorization: token $TOKEN"
|
AUTH="Authorization: token $TOKEN"
|
||||||
|
|
||||||
# Re-run safety: drop a previous release (and its tag) for this version.
|
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
|
||||||
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
# message) before failing — plain `curl -f` hides it (exit 22).
|
||||||
if [ -n "$OLD_ID" ]; then
|
api() {
|
||||||
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
local code body
|
||||||
|
body=$(mktemp)
|
||||||
|
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
||||||
|
if [ "${code:0:1}" != "2" ]; then
|
||||||
|
echo "API error $code: $(cat "$body")" >&2
|
||||||
|
rm -f "$body"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
cat "$body"
|
||||||
|
rm -f "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-run safety: drop a previous release AND its tag for this version.
|
||||||
|
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
||||||
|
# makes the POST below fail with 409.)
|
||||||
|
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
||||||
|
if [ -n "$OLD_ID" ]; then
|
||||||
|
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||||
|
fi
|
||||||
|
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
|
||||||
|
|
||||||
# Gitea creates the tag at the default branch HEAD automatically.
|
# Gitea creates the tag at the default branch HEAD automatically.
|
||||||
RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||||
"$API/releases" \
|
"$API/releases" \
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
||||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
'{tag_name:$tag, title:$title, body:$body}')" \
|
||||||
@@ -313,15 +334,22 @@ jobs:
|
|||||||
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
echo "Uploading $(basename "$f")"
|
echo "Uploading $(basename "$f")"
|
||||||
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \
|
# Forgejo-style API: release assets live under /assets, not /attachments.
|
||||||
"$API/releases/$RELEASE_ID/attachments" > /dev/null
|
api -X POST -H "$AUTH" -F "attachment=@$f" \
|
||||||
|
"$API/releases/$RELEASE_ID/assets" > /dev/null
|
||||||
done
|
done
|
||||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. EDITS to existing Gradle files
|
## 4. EDITS to existing Gradle files
|
||||||
|
|
||||||
|
> **Note (versioning):** the `versionName` / `appVersion` lines shown below
|
||||||
|
> have since been changed to read the repo-root **`VERSION` file** (single
|
||||||
|
> source of truth; `-PappVersion` still overrides in CI). See
|
||||||
|
> `.gitea/workflows/release.yml` and `gateway-plugin/version.py`.
|
||||||
|
|
||||||
### 4a. `app/androidApp/build.gradle.kts`
|
### 4a. `app/androidApp/build.gradle.kts`
|
||||||
|
|
||||||
**Change 1** — in `defaultConfig`, replace:
|
**Change 1** — in `defaultConfig`, replace:
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
|
|
||||||
A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
|
A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
|
||||||
|
|
||||||
Iris pairs with your running `hermes gateway` over a private WebSocket and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
|
Iris pairs with your running `hermes gateway` over a private, token-authenticated connection and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
|
||||||
|
|
||||||
## 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
|
||||||
- **No file limit**
|
(push: ntfy by default, **but the default ntfy server is the public
|
||||||
- **No character limit**
|
`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
|
||||||
|
- **No 4,096-character message limit like Telegram** — messages travel over
|
||||||
|
your own gateway (hard frame-body cap: 1 MiB)
|
||||||
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
|
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
|
||||||
- **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView
|
- **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView
|
||||||
- **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly.
|
- **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly.
|
||||||
@@ -24,11 +31,11 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (Android / Desktop)
|
hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android / Desktop)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
|
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
|
||||||
`hermes gateway` process and opens a WebSocket server the apps connect to.
|
`hermes gateway` process and opens an HTTP server the apps connect to.
|
||||||
Zero new Python dependencies, zero hermes-core changes.
|
Zero new Python dependencies, zero hermes-core changes.
|
||||||
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
|
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
|
||||||
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
|
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
|
||||||
@@ -36,7 +43,46 @@ hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (And
|
|||||||
- The app is a first-class hermes *messaging platform*, so everything the gateway
|
- The app is a first-class hermes *messaging platform*, so everything the gateway
|
||||||
already does just works: slash commands, cron delivery, `send_message` routing,
|
already does just works: slash commands, cron delivery, `send_message` routing,
|
||||||
coexistence with Telegram/Discord/etc.
|
coexistence with Telegram/Discord/etc.
|
||||||
- Push notifications: FCM (primary) or ntfy (fallback).
|
- Push notifications: ntfy (default) or FCM (opt-in).
|
||||||
|
|
||||||
|
## Push notifications
|
||||||
|
|
||||||
|
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
||||||
|
outbox, so nothing is lost.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
Setup: [`docs/install.md`](docs/install.md); details:
|
||||||
|
[`docs/08-push.md`](docs/08-push.md).
|
||||||
|
|
||||||
|
## Install the gateway
|
||||||
|
|
||||||
|
Three commands on the machine where hermes runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hermes-agent && uv sync # 1. hermes with its venv (separate project, not this repo)
|
||||||
|
|
||||||
|
# 2. install the Iris plugin — the #gateway-plugin suffix points the
|
||||||
|
# installer at the plugin subfolder of this monorepo
|
||||||
|
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
|
||||||
|
|
||||||
|
# 3. generate the pairing token + server URL, then run the gateway
|
||||||
|
hermes gateway setup
|
||||||
|
hermes gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
`hermes gateway setup` prints the **server URL** and **pairing token / QR**
|
||||||
|
the app needs on its Connect screen.
|
||||||
|
|
||||||
|
All options (LAN binding, TLS, push, device allowlist) and the full app
|
||||||
|
pairing walkthrough: [`docs/install.md`](docs/install.md).
|
||||||
|
|
||||||
## Build from source
|
## Build from source
|
||||||
|
|
||||||
@@ -52,19 +98,21 @@ No system Gradle needed — both apps use the project wrapper (`./gradlew`).
|
|||||||
|
|
||||||
### 1. Gateway (on the gateway host)
|
### 1. Gateway (on the gateway host)
|
||||||
|
|
||||||
|
If you're developing from a checkout, skip `hermes plugins install` and
|
||||||
|
symlink the plugin so it always tracks your working tree:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# hermes-agent is a separate project (not part of this repo)
|
|
||||||
cd hermes-agent && uv sync
|
cd hermes-agent && uv sync
|
||||||
|
|
||||||
# install the Iris plugin into the live hermes home
|
|
||||||
mkdir -p ~/.hermes/plugins
|
mkdir -p ~/.hermes/plugins
|
||||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
|
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||||
hermes gateway status # should list "android"
|
hermes gateway status # should list the Iris platform
|
||||||
|
|
||||||
hermes gateway setup # generates ANDROID_TOKEN, prints the server URL
|
hermes gateway setup # generates IRIS_TOKEN, prints server URL + pairing QR
|
||||||
hermes gateway # run the gateway
|
hermes gateway # run the gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(Otherwise see [Install the gateway](#install-the-gateway) above.)
|
||||||
|
|
||||||
### 2. Android app
|
### 2. Android app
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -86,21 +134,22 @@ cd app
|
|||||||
|
|
||||||
On the app's **Connect** screen:
|
On the app's **Connect** screen:
|
||||||
|
|
||||||
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`).
|
1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
|
||||||
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env`
|
2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
|
||||||
on the gateway host.
|
on the gateway host.
|
||||||
3. **Test & Connect.**
|
3. **Test & Connect.**
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The app has **no QR scanner** — pairing is manual URL + token entry.
|
- **Android** has a **Scan QR** button that reads the QR printed by
|
||||||
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone
|
`hermes gateway setup` and pre-fills URL + token; desktop uses manual entry.
|
||||||
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
- The default bind is `127.0.0.1` (desktop on the same machine only). For a
|
||||||
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
|
phone on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
||||||
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
|
- Remote access: Tailscale/WireGuard, or a reverse proxy/tunnel with TLS
|
||||||
|
(`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`).
|
||||||
|
|
||||||
Full walkthrough, push setup (FCM/ntfy), and troubleshooting:
|
Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
|
||||||
[`docs/setup.md`](docs/setup.md).
|
[`docs/install.md`](docs/install.md).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -123,8 +172,8 @@ Contributions are welcome! Before you start:
|
|||||||
- Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
|
- Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
|
||||||
(never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`).
|
(never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`).
|
||||||
- Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest`
|
- Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest`
|
||||||
- Live check (gateway must be running): `gateway-plugin/tests/ws_probe.py` and
|
- Live check (gateway must be running): `tests/ws_probe.py` and
|
||||||
`gateway-plugin/tests/e2e.py` — see [`gateway-plugin/tests/README.md`](gateway-plugin/tests/README.md).
|
`tests/e2e.py` — see [`tests/README.md`](tests/README.md).
|
||||||
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
|
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
|
||||||
`app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json`
|
`app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json`
|
||||||
must always agree.
|
must always agree.
|
||||||
|
|||||||
@@ -16,9 +16,16 @@ android {
|
|||||||
// Play Store requires an incrementing versionCode per upload; CI can
|
// Play Store requires an incrementing versionCode per upload; CI can
|
||||||
// pass -PappVersionCode=<n>. Local builds keep the default.
|
// pass -PappVersionCode=<n>. Local builds keep the default.
|
||||||
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
|
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
|
||||||
// CI passes -PappVersion=<version> (release workflow); local builds
|
// The repo-root VERSION file is the single source of truth (bump it
|
||||||
// keep the default.
|
// to cut a release); CI can still override with -PappVersion.
|
||||||
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
versionName =
|
||||||
|
(project.findProperty("appVersion") as? String)
|
||||||
|
?: project
|
||||||
|
.file("../../VERSION")
|
||||||
|
.takeIf { it.exists() }
|
||||||
|
?.readText()
|
||||||
|
?.trim()
|
||||||
|
?: "0.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ plugins {
|
|||||||
val composeVersion = "1.11.1"
|
val composeVersion = "1.11.1"
|
||||||
val os = OperatingSystem.current()
|
val os = OperatingSystem.current()
|
||||||
val arch = System.getProperty("os.arch") ?: "amd64"
|
val arch = System.getProperty("os.arch") ?: "amd64"
|
||||||
// CI passes -PappVersion=<version> (release workflow); local builds keep the
|
// The repo-root VERSION file is the single source of truth (bump it to cut
|
||||||
// default. jpackage requires a plain semver (no leading "v").
|
// a release); CI can still override with -PappVersion. jpackage requires a
|
||||||
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
// plain semver (no leading "v").
|
||||||
|
val appVersion =
|
||||||
|
(project.findProperty("appVersion") as? String)
|
||||||
|
?: project
|
||||||
|
.file("../../VERSION")
|
||||||
|
.takeIf { it.exists() }
|
||||||
|
?.readText()
|
||||||
|
?.trim()
|
||||||
|
?: "0.1.0"
|
||||||
val desktopTarget =
|
val desktopTarget =
|
||||||
when {
|
when {
|
||||||
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
|
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import iris.IrisApp
|
|||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.DesktopBridge
|
import iris.platform.DesktopBridge
|
||||||
import iris.platform.DesktopSecureStore
|
import iris.platform.DesktopSecureStore
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import java.awt.Color
|
import java.awt.Color
|
||||||
import java.awt.Graphics2D
|
import java.awt.Graphics2D
|
||||||
import javax.imageio.ImageIO
|
|
||||||
import java.awt.RenderingHints
|
import java.awt.RenderingHints
|
||||||
import java.awt.SystemTray
|
import java.awt.SystemTray
|
||||||
import java.awt.event.WindowEvent
|
import java.awt.event.WindowEvent
|
||||||
@@ -29,10 +32,7 @@ import java.awt.event.WindowFocusListener
|
|||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import javax.imageio.ImageIO
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M6: desktop shell (docs/11 §11.2/§11.3).
|
* M6: desktop shell (docs/11 §11.2/§11.3).
|
||||||
@@ -48,6 +48,7 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso
|
|||||||
|
|
||||||
// Window/taskbar icon (src/main/resources/icon.png).
|
// Window/taskbar icon (src/main/resources/icon.png).
|
||||||
private class IrisDesktop
|
private class IrisDesktop
|
||||||
|
|
||||||
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
||||||
|
|
||||||
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
||||||
@@ -87,14 +88,36 @@ fun main() {
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(2_000)
|
delay(2_000)
|
||||||
val state = DesktopBridge.controller?.client?.state?.value
|
val state =
|
||||||
val (color, tooltip) = when (state) {
|
DesktopBridge.controller
|
||||||
|
?.client
|
||||||
|
?.state
|
||||||
|
?.value
|
||||||
|
val (color, tooltip) =
|
||||||
|
when (state) {
|
||||||
null,
|
null,
|
||||||
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline"
|
is GatewayClient.State.Disconnected,
|
||||||
|
-> {
|
||||||
|
TRAY_OFFLINE to "Iris — offline"
|
||||||
|
}
|
||||||
|
|
||||||
is GatewayClient.State.Connecting,
|
is GatewayClient.State.Connecting,
|
||||||
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…"
|
is GatewayClient.State.Reconnecting,
|
||||||
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected"
|
-> {
|
||||||
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed"
|
TRAY_CONNECTING to "Iris — connecting…"
|
||||||
|
}
|
||||||
|
|
||||||
|
is GatewayClient.State.Connected -> {
|
||||||
|
TRAY_CONNECTED to "Iris — connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
is GatewayClient.State.AuthFailed -> {
|
||||||
|
TRAY_AUTH_FAILED to "Iris — auth failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
is GatewayClient.State.TlsConfirmRequired -> {
|
||||||
|
TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
trayColor = color
|
trayColor = color
|
||||||
trayTooltip = tooltip
|
trayTooltip = tooltip
|
||||||
@@ -126,7 +149,8 @@ fun main() {
|
|||||||
state = loadWindowState(),
|
state = loadWindowState(),
|
||||||
) {
|
) {
|
||||||
composeWindow = window
|
composeWindow = window
|
||||||
window.addWindowFocusListener(object : WindowFocusListener {
|
window.addWindowFocusListener(
|
||||||
|
object : WindowFocusListener {
|
||||||
override fun windowGainedFocus(e: WindowEvent) {
|
override fun windowGainedFocus(e: WindowEvent) {
|
||||||
DesktopBridge.foreground = true
|
DesktopBridge.foreground = true
|
||||||
}
|
}
|
||||||
@@ -134,7 +158,8 @@ fun main() {
|
|||||||
override fun windowLostFocus(e: WindowEvent) {
|
override fun windowLostFocus(e: WindowEvent) {
|
||||||
DesktopBridge.foreground = false
|
DesktopBridge.foreground = false
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
IrisApp(store)
|
IrisApp(store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,48 @@ val sqldelightVersion = "2.3.2"
|
|||||||
val cameraxVersion = "1.5.1"
|
val cameraxVersion = "1.5.1"
|
||||||
val mlKitVersion = "16.1.1"
|
val mlKitVersion = "16.1.1"
|
||||||
|
|
||||||
|
// ── App version (generated) ─────────────────────────────────────────────
|
||||||
|
// The repo-root VERSION file is the single source of truth (bump it to cut
|
||||||
|
// a release; CI can still override with -PappVersion). Generate AppVersion.kt
|
||||||
|
// into commonMain so both targets can display it in Settings and send it to
|
||||||
|
// the gateway (X-Iris-App-Version header).
|
||||||
|
//
|
||||||
|
// A real task with the VERSION file as a DECLARED input: on a
|
||||||
|
// configuration-cache hit the script body does not re-run, so only the task
|
||||||
|
// (keyed on the file's content) can regenerate AppVersion.kt after a bump.
|
||||||
|
// The doLast reads everything from the task's own inputs/outputs (which are
|
||||||
|
// configuration-cache serializable) — it must not reference script-scope
|
||||||
|
// vals, because a .kts script lambda captures the script object and the
|
||||||
|
// configuration cache rejects that.
|
||||||
|
val generatedVersionDir = layout.buildDirectory.dir("generated/app-version")
|
||||||
|
val generateAppVersion by tasks.registering {
|
||||||
|
inputs.file(project.file("../../VERSION"))
|
||||||
|
inputs.property("appVersionOverride", (project.findProperty("appVersion") as? String).orEmpty())
|
||||||
|
val outFile = generatedVersionDir.map { it.file("AppVersion.kt") }
|
||||||
|
outputs.file(outFile)
|
||||||
|
doLast {
|
||||||
|
val override = inputs.properties["appVersionOverride"] as? String ?: ""
|
||||||
|
val versionFile = inputs.files.singleFile
|
||||||
|
val version =
|
||||||
|
override.ifBlank {
|
||||||
|
versionFile.takeIf { it.exists() }?.readText()?.trim() ?: "0.1.0"
|
||||||
|
}
|
||||||
|
val f = outFile.get().asFile
|
||||||
|
f.parentFile?.mkdirs()
|
||||||
|
f.writeText(
|
||||||
|
"""
|
||||||
|
|package iris
|
||||||
|
|
|
||||||
|
|/** Generated from the repo-root VERSION file - do not edit. */
|
||||||
|
|object AppVersion {
|
||||||
|
| const val VERSION = "$version"
|
||||||
|
|}
|
||||||
|
|
|
||||||
|
""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
android {
|
android {
|
||||||
namespace = "iris.shared"
|
namespace = "iris.shared"
|
||||||
@@ -53,6 +95,11 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
// AppVersion.kt is generated from the repo-root VERSION file (see
|
||||||
|
// generateAppVersion above) into commonMain so both targets can read it.
|
||||||
|
commonMain {
|
||||||
|
kotlin.srcDir(generatedVersionDir)
|
||||||
|
}
|
||||||
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
|
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
|
||||||
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
|
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
|
||||||
val jvmMain by creating { dependsOn(commonMain.get()) }
|
val jvmMain by creating { dependsOn(commonMain.get()) }
|
||||||
@@ -138,3 +185,8 @@ sqldelight {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every Kotlin compile depends on the generated AppVersion.kt being current.
|
||||||
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
dependsOn(generateAppVersion)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import iris.data.SecureStore
|
|||||||
import iris.ui.theme.Backdrop
|
import iris.ui.theme.Backdrop
|
||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +77,14 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
|
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
|
||||||
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
|
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
|
||||||
|
|
||||||
|
override var deviceToken: String
|
||||||
|
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
|
||||||
|
|
||||||
|
override var pinnedCertFingerprint: String
|
||||||
|
get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply()
|
||||||
|
|
||||||
override val deviceId: String
|
override val deviceId: String
|
||||||
get() {
|
get() {
|
||||||
var id = prefs.getString(KEY_DEVICE_ID, null)
|
var id = prefs.getString(KEY_DEVICE_ID, null)
|
||||||
@@ -126,6 +135,10 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
|
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
|
||||||
|
|
||||||
|
override var streamSmoothness: Float
|
||||||
|
get() = prefs.getFloat(KEY_STREAM_SMOOTHNESS, STREAM_SMOOTHNESS_DEFAULT)
|
||||||
|
set(value) = prefs.edit().putFloat(KEY_STREAM_SMOOTHNESS, value).apply()
|
||||||
|
|
||||||
override var reasoningAutoCollapse: Boolean
|
override var reasoningAutoCollapse: Boolean
|
||||||
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
|
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
|
||||||
@@ -176,6 +189,9 @@ class AndroidSecureStore(
|
|||||||
) {
|
) {
|
||||||
serverUrl = url
|
serverUrl = url
|
||||||
this.token = token
|
this.token = token
|
||||||
|
// A (re-)pair may target a different gateway: the old per-device
|
||||||
|
// token is dead there. The next hello.ack re-mints/returns it.
|
||||||
|
deviceToken = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clear() {
|
override fun clear() {
|
||||||
@@ -187,12 +203,14 @@ class AndroidSecureStore(
|
|||||||
.edit()
|
.edit()
|
||||||
.remove(KEY_URL)
|
.remove(KEY_URL)
|
||||||
.remove(KEY_TOKEN)
|
.remove(KEY_TOKEN)
|
||||||
|
.remove(KEY_DEVICE_TOKEN)
|
||||||
.remove(KEY_DEVICE_ID)
|
.remove(KEY_DEVICE_ID)
|
||||||
.remove(KEY_SYNC_CURSOR)
|
.remove(KEY_SYNC_CURSOR)
|
||||||
.remove(KEY_FCM_TOKEN)
|
.remove(KEY_FCM_TOKEN)
|
||||||
.remove(KEY_NTFY_TOPIC)
|
.remove(KEY_NTFY_TOPIC)
|
||||||
.remove(KEY_NTFY_SERVER)
|
.remove(KEY_NTFY_SERVER)
|
||||||
.remove(KEY_PUSH_BACKEND)
|
.remove(KEY_PUSH_BACKEND)
|
||||||
|
.remove(KEY_PINNED_CERT)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,15 +219,18 @@ class AndroidSecureStore(
|
|||||||
const val SECURE_PREFS_NAME = "iris_secure"
|
const val SECURE_PREFS_NAME = "iris_secure"
|
||||||
const val KEY_URL = "server_url"
|
const val KEY_URL = "server_url"
|
||||||
const val KEY_TOKEN = "token"
|
const val KEY_TOKEN = "token"
|
||||||
|
const val KEY_DEVICE_TOKEN = "device_token"
|
||||||
const val KEY_DEVICE_ID = "device_id"
|
const val KEY_DEVICE_ID = "device_id"
|
||||||
const val KEY_SYNC_CURSOR = "sync_cursor"
|
const val KEY_SYNC_CURSOR = "sync_cursor"
|
||||||
const val KEY_FCM_TOKEN = "fcm_token"
|
const val KEY_FCM_TOKEN = "fcm_token"
|
||||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||||
const val KEY_PUSH_BACKEND = "push_backend"
|
const val KEY_PUSH_BACKEND = "push_backend"
|
||||||
|
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
|
||||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
||||||
|
const val KEY_STREAM_SMOOTHNESS = "stream_smoothness"
|
||||||
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
|
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
|
||||||
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
|
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
|
||||||
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
|
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
|
||||||
|
|||||||
@@ -121,6 +121,21 @@ fun IrisApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLS: the gateway's certificate is untrusted and not
|
||||||
|
// the pinned one (docs/09 §9.4) — ask the user to
|
||||||
|
// confirm its fingerprint (SSH host-key style).
|
||||||
|
is GatewayClient.State.TlsConfirmRequired -> {
|
||||||
|
key(deepLinkPair) {
|
||||||
|
ConnectScreen(
|
||||||
|
controller,
|
||||||
|
prefillUrl = pairUrl,
|
||||||
|
prefillToken = pairToken,
|
||||||
|
initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.",
|
||||||
|
tlsFingerprint = s.fingerprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||||
// status bubble + connection banner show the link state
|
// status bubble + connection banner show the link state
|
||||||
// without blocking the view (M7's full-screen spinner is gone).
|
// without blocking the view (M7's full-screen spinner is gone).
|
||||||
|
|||||||
@@ -9,9 +9,19 @@ interface SecureStore {
|
|||||||
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
||||||
var serverUrl: String
|
var serverUrl: String
|
||||||
|
|
||||||
/** IRIS_TOKEN presented in the auth header. */
|
/** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */
|
||||||
var token: String
|
var token: String
|
||||||
|
|
||||||
|
/** Per-device token minted at pairing (hello.ack ``device_token``,
|
||||||
|
* docs/09 §9.3). Presented INSTEAD of [token] when non-empty; the
|
||||||
|
* gateway can revoke it per device. Empty until the first hello.ack. */
|
||||||
|
var deviceToken: String
|
||||||
|
|
||||||
|
/** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS
|
||||||
|
* certificate, confirmed by the user on first pair (docs/09 §9.4).
|
||||||
|
* Empty = nothing pinned (CA-signed certs need no pin). */
|
||||||
|
var pinnedCertFingerprint: String
|
||||||
|
|
||||||
/** Stable app-generated device id (persisted). */
|
/** Stable app-generated device id (persisted). */
|
||||||
val deviceId: String
|
val deviceId: String
|
||||||
|
|
||||||
@@ -44,6 +54,10 @@ interface SecureStore {
|
|||||||
/** UI setting: stream assistant replies live (token by token). */
|
/** UI setting: stream assistant replies live (token by token). */
|
||||||
var streamingEnabled: Boolean
|
var streamingEnabled: Boolean
|
||||||
|
|
||||||
|
/** UI setting: streaming smoothness — seconds between visible text
|
||||||
|
* updates while streaming (0.2–0.8; lower = faster/smoother). */
|
||||||
|
var streamSmoothness: Float
|
||||||
|
|
||||||
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
|
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
|
||||||
var reasoningAutoCollapse: Boolean
|
var reasoningAutoCollapse: Boolean
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ class GatewayClient(
|
|||||||
data class AuthFailed(
|
data class AuthFailed(
|
||||||
val message: String,
|
val message: String,
|
||||||
) : State
|
) : State
|
||||||
|
|
||||||
|
/** The gateway's TLS certificate is untrusted and doesn't match the
|
||||||
|
* pinned fingerprint (docs/09 §9.4). Terminal: the connect loop
|
||||||
|
* stops and the UI asks the user to confirm the fingerprint. */
|
||||||
|
data class TlsConfirmRequired(
|
||||||
|
val fingerprint: String,
|
||||||
|
) : State
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _state = MutableStateFlow<State>(State.Disconnected)
|
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||||
@@ -79,10 +86,18 @@ class GatewayClient(
|
|||||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||||
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
// TLS: the pinning trust manager wraps the platform default (CA-signed
|
||||||
|
// certs behave as before); a self-signed gateway cert is accepted only
|
||||||
|
// after the user confirms its fingerprint (docs/09 §9.4). The pin is
|
||||||
|
// read live from the store so a fresh confirm takes effect without
|
||||||
|
// rebuilding the client.
|
||||||
|
private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint }
|
||||||
|
|
||||||
private val client: OkHttpClient =
|
private val client: OkHttpClient =
|
||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
.pingInterval(20, TimeUnit.SECONDS)
|
.pingInterval(20, TimeUnit.SECONDS)
|
||||||
|
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private var connectJob: Job? = null
|
private var connectJob: Job? = null
|
||||||
@@ -195,7 +210,9 @@ class GatewayClient(
|
|||||||
private suspend fun connectLoop() {
|
private suspend fun connectLoop() {
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
val url = store.serverUrl.trim()
|
val url = store.serverUrl.trim()
|
||||||
val token = store.token
|
// Per-device token when the gateway minted one (docs/09 §9.3),
|
||||||
|
// else the shared IRIS_TOKEN (bootstrap).
|
||||||
|
val token = store.deviceToken.ifBlank { store.token }
|
||||||
if (url.isBlank() || token.isBlank()) {
|
if (url.isBlank() || token.isBlank()) {
|
||||||
_state.value = State.Disconnected
|
_state.value = State.Disconnected
|
||||||
return
|
return
|
||||||
@@ -208,6 +225,7 @@ class GatewayClient(
|
|||||||
try {
|
try {
|
||||||
gw.health()
|
gw.health()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) return
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
if (!healthOk) {
|
if (!healthOk) {
|
||||||
@@ -245,6 +263,10 @@ class GatewayClient(
|
|||||||
try {
|
try {
|
||||||
gw.health()
|
gw.health()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) {
|
||||||
|
receiveJob.cancel()
|
||||||
|
break
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
probeFailures = if (ok) 0 else probeFailures + 1
|
probeFailures = if (ok) 0 else probeFailures + 1
|
||||||
@@ -265,8 +287,9 @@ class GatewayClient(
|
|||||||
}
|
}
|
||||||
receiveJob.join()
|
receiveJob.join()
|
||||||
}
|
}
|
||||||
// Terminal auth failure: don't redial with the same bad token.
|
// Terminal failures: don't redial with the same bad token / the
|
||||||
if (_state.value is State.AuthFailed) return
|
// same untrusted certificate (the UI asks the user to act).
|
||||||
|
if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,13 +301,15 @@ class GatewayClient(
|
|||||||
private fun httpGateway(): HttpGateway? =
|
private fun httpGateway(): HttpGateway? =
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val url = store.serverUrl.trim()
|
val url = store.serverUrl.trim()
|
||||||
val token = store.token
|
val token = store.deviceToken.ifBlank { store.token }
|
||||||
if (url.isBlank() || token.isBlank()) return@synchronized null
|
if (url.isBlank() || token.isBlank()) return@synchronized null
|
||||||
http
|
http
|
||||||
?: HttpGateway(
|
?: HttpGateway(
|
||||||
client,
|
client,
|
||||||
HttpGateway.deriveHttpUrl(url),
|
HttpGateway.deriveHttpUrl(url),
|
||||||
token,
|
// Live provider: a device token minted by the next
|
||||||
|
// hello.ack is picked up without rebuilding the client.
|
||||||
|
token = { store.deviceToken.ifBlank { store.token } },
|
||||||
store.deviceId,
|
store.deviceId,
|
||||||
deviceName = store.deviceName,
|
deviceName = store.deviceName,
|
||||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||||
@@ -312,6 +337,7 @@ class GatewayClient(
|
|||||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
return
|
return
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) return
|
||||||
markStreamLost()
|
markStreamLost()
|
||||||
IrisLog.w("http poll failed: ${e.message}")
|
IrisLog.w("http poll failed: ${e.message}")
|
||||||
delay(backoff)
|
delay(backoff)
|
||||||
@@ -338,6 +364,7 @@ class GatewayClient(
|
|||||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
return
|
return
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (failTls(e)) return
|
||||||
sseFailures++
|
sseFailures++
|
||||||
markStreamLost()
|
markStreamLost()
|
||||||
if (sseFailures >= 2) {
|
if (sseFailures >= 2) {
|
||||||
@@ -356,6 +383,13 @@ class GatewayClient(
|
|||||||
/** The SSE `event: hello` (the HTTP hello.ack). */
|
/** The SSE `event: hello` (the HTTP hello.ack). */
|
||||||
private fun onHttpHello(ack: HelloAckPayload) {
|
private fun onHttpHello(ack: HelloAckPayload) {
|
||||||
lastAck = ack
|
lastAck = ack
|
||||||
|
// Per-device token (docs/09 §9.3): minted at pairing, stable across
|
||||||
|
// (re)connects. Store it — from the next request on the app presents
|
||||||
|
// it instead of the shared IRIS_TOKEN, so the gateway can revoke
|
||||||
|
// THIS device without touching the others.
|
||||||
|
if (ack.deviceToken.isNotBlank() && ack.deviceToken != store.deviceToken) {
|
||||||
|
store.deviceToken = ack.deviceToken
|
||||||
|
}
|
||||||
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||||
_state.value = connected
|
_state.value = connected
|
||||||
// M5: reconnect catch-up — replay frames parked while offline.
|
// M5: reconnect catch-up — replay frames parked while offline.
|
||||||
@@ -411,6 +445,17 @@ class GatewayClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Terminal TLS failure: the presented certificate is untrusted and not
|
||||||
|
* the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect
|
||||||
|
* loop and let the UI ask the user to confirm the fingerprint. True when
|
||||||
|
* the state was set. */
|
||||||
|
private fun failTls(e: Exception): Boolean {
|
||||||
|
val tls = tlsFingerprintRequired(e) ?: return false
|
||||||
|
IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})")
|
||||||
|
_state.value = State.TlsConfirmRequired(tls.fingerprint)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ── Outbound ──────────────────────────────────────────────────────────
|
// ── Outbound ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||||
@@ -522,7 +567,10 @@ class GatewayClient(
|
|||||||
HttpGateway(
|
HttpGateway(
|
||||||
client,
|
client,
|
||||||
HttpGateway.deriveHttpUrl(url),
|
HttpGateway.deriveHttpUrl(url),
|
||||||
token,
|
// An already-paired device presents its per-device token
|
||||||
|
// (docs/09 §9.3); a fresh pairing falls back to the entered
|
||||||
|
// shared token (bootstrap).
|
||||||
|
token = { store.deviceToken.ifBlank { token } },
|
||||||
store.deviceId,
|
store.deviceId,
|
||||||
deviceName = store.deviceName,
|
deviceName = store.deviceName,
|
||||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||||
@@ -560,13 +608,15 @@ class GatewayClient(
|
|||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(IllegalStateException("connection failed: ${e.message}"))
|
// Unwrap the pinning manager's signal so the Connect
|
||||||
|
// screen can offer the fingerprint confirm dialog.
|
||||||
|
Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}"))
|
||||||
} finally {
|
} finally {
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(tlsFingerprintRequired(e) ?: e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package iris.net
|
package iris.net
|
||||||
|
|
||||||
|
import iris.AppVersion
|
||||||
import iris.media.Sha256
|
import iris.media.Sha256
|
||||||
import iris.media.isValidMediaId
|
import iris.media.isValidMediaId
|
||||||
import iris.protocol.ErrorPayload
|
import iris.protocol.ErrorPayload
|
||||||
@@ -38,7 +39,11 @@ import java.util.concurrent.TimeUnit
|
|||||||
class HttpGateway(
|
class HttpGateway(
|
||||||
private val client: OkHttpClient,
|
private val client: OkHttpClient,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
private val token: String,
|
/** Live auth-token provider, read per request: the per-device token
|
||||||
|
* (docs/09 §9.3) when the gateway minted one, else the shared
|
||||||
|
* IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is
|
||||||
|
* picked up without rebuilding the client. */
|
||||||
|
private val token: () -> String,
|
||||||
private val deviceId: String,
|
private val deviceId: String,
|
||||||
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
|
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
|
||||||
* upserts it into the device registry on every SSE open — the HTTP
|
* upserts it into the device registry on every SSE open — the HTTP
|
||||||
@@ -123,7 +128,7 @@ class HttpGateway(
|
|||||||
val b =
|
val b =
|
||||||
Headers
|
Headers
|
||||||
.Builder()
|
.Builder()
|
||||||
.add("Authorization", "Bearer $token")
|
.add("Authorization", "Bearer ${token()}")
|
||||||
.add("X-Iris-Device", deviceId)
|
.add("X-Iris-Device", deviceId)
|
||||||
// Device registration (docs/19): the gateway upserts name + push
|
// Device registration (docs/19): the gateway upserts name + push
|
||||||
// tokens from these headers on every SSE open (COALESCE — absent
|
// tokens from these headers on every SSE open (COALESCE — absent
|
||||||
@@ -131,6 +136,9 @@ class HttpGateway(
|
|||||||
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
|
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
|
||||||
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
|
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
|
||||||
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
|
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
|
||||||
|
// Release version (repo-root VERSION baked in at build time); the
|
||||||
|
// gateway stores it in the device registry (docs/04 hello.ack note).
|
||||||
|
b.add("X-Iris-App-Version", AppVersion.VERSION)
|
||||||
return b.build()
|
return b.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import iris.protocol.IrisJson
|
||||||
|
import iris.util.IrisLog
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest-release check (docs/04 hello.ack note): the repo is public on Gitea,
|
||||||
|
* so the app can ask for the newest release tag without any token. Settings
|
||||||
|
* shows "vX.Y.Z available" when the running build is older.
|
||||||
|
*
|
||||||
|
* Best-effort by design: any failure (offline, DNS, rate limit) just yields
|
||||||
|
* `null` — the check must never surface an error in the UI.
|
||||||
|
*/
|
||||||
|
object ReleaseCheck {
|
||||||
|
const val LATEST_RELEASE_URL =
|
||||||
|
"https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared client for the process lifetime: an OkHttpClient owns a
|
||||||
|
* thread pool and connection pool, so it must not be rebuilt per screen
|
||||||
|
* open (and never needs explicit shutdown — the pools idle out). Short
|
||||||
|
* timeouts so a black-holed network can't linger the LaunchedEffect.
|
||||||
|
*/
|
||||||
|
private val client: OkHttpClient =
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest release version (tag without the leading "v"), or `null`
|
||||||
|
* when the check could not be performed.
|
||||||
|
*/
|
||||||
|
suspend fun latestVersion(): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(LATEST_RELEASE_URL)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(request).execute().use { response: Response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
IrisLog.d("ReleaseCheck: HTTP ${response.code}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val body = response.body?.string() ?: return@withContext null
|
||||||
|
val tag =
|
||||||
|
IrisJson
|
||||||
|
.instance
|
||||||
|
.parseToJsonElement(body)
|
||||||
|
.jsonObject
|
||||||
|
.get("tag_name")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content
|
||||||
|
.orEmpty()
|
||||||
|
tag.removePrefix("v").ifBlank { null }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
IrisLog.d("ReleaseCheck: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [latest] is a newer release than [running]. Numeric
|
||||||
|
* component-wise comparison (so "0.1.10" > "0.1.9"); anything that does
|
||||||
|
* not parse as dotted numbers is treated as "not newer" — the hint is
|
||||||
|
* best-effort and must never fire for dev builds ahead of the latest
|
||||||
|
* release.
|
||||||
|
*/
|
||||||
|
fun isNewer(
|
||||||
|
latest: String,
|
||||||
|
running: String,
|
||||||
|
): Boolean {
|
||||||
|
val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
|
||||||
|
val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
|
||||||
|
if (a.isEmpty() || b.isEmpty()) return false
|
||||||
|
val n = maxOf(a.size, b.size)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val x = a.getOrElse(i) { 0 }
|
||||||
|
val y = b.getOrElse(i) { 0 }
|
||||||
|
if (x != y) return x > y
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
|
||||||
|
*
|
||||||
|
* A gateway behind `IRIS_HTTP_CERT` may present a
|
||||||
|
* self-signed certificate the platform doesn't trust. Instead of forcing the
|
||||||
|
* user to install it into the system trust store, the app shows the
|
||||||
|
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
|
||||||
|
* the user confirms it, the fingerprint is pinned in secure storage and
|
||||||
|
* [PinningTrustManager] accepts exactly that certificate from then on.
|
||||||
|
*
|
||||||
|
* Hostname verification is NOT bypassed: OkHttp runs its own hostname check
|
||||||
|
* on top of the trust manager, so a pinned cert still has to match the URL's
|
||||||
|
* host. A *changed* certificate (different fingerprint) fails again with
|
||||||
|
* [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH
|
||||||
|
* host key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gateway presented a certificate the platform doesn't trust and that
|
||||||
|
* doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the
|
||||||
|
* user must confirm. Thrown by [PinningTrustManager] during the handshake;
|
||||||
|
* the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired].
|
||||||
|
*/
|
||||||
|
class TlsFingerprintRequired(
|
||||||
|
val fingerprint: String,
|
||||||
|
) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 of the certificate's DER encoding, colon-separated byte pairs
|
||||||
|
* (SSH host-key style) — the string the user verifies on the gateway host.
|
||||||
|
*/
|
||||||
|
fun certFingerprint(cert: X509Certificate): String =
|
||||||
|
MessageDigest
|
||||||
|
.getInstance("SHA-256")
|
||||||
|
.digest(cert.encoded)
|
||||||
|
.joinToString(":") { String.format("%02X", it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the platform default trust manager:
|
||||||
|
* - CA-signed certs behave exactly as before (the default manager decides).
|
||||||
|
* - A cert the default manager REJECTS is accepted only when its fingerprint
|
||||||
|
* equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh
|
||||||
|
* pin is picked up without rebuilding the client).
|
||||||
|
* - Anything else fails with [TlsFingerprintRequired] carrying the
|
||||||
|
* presented fingerprint, so the UI can offer the confirm dialog.
|
||||||
|
*/
|
||||||
|
class PinningTrustManager(
|
||||||
|
private val pinnedFingerprint: () -> String,
|
||||||
|
) : X509TrustManager {
|
||||||
|
private val default: X509TrustManager = defaultTrustManager()
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) {
|
||||||
|
default.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
default.checkServerTrusted(chain, authType)
|
||||||
|
} catch (e: CertificateException) {
|
||||||
|
val fp = certFingerprint(chain.first())
|
||||||
|
if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return
|
||||||
|
throw TlsFingerprintRequired(fp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The platform default server trust manager (the JDK's CA store). */
|
||||||
|
fun defaultTrustManager(): X509TrustManager {
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
factory.init(null as KeyStore?)
|
||||||
|
return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager)
|
||||||
|
?: error("no X509TrustManager in the default trust store")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */
|
||||||
|
fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory {
|
||||||
|
val ctx = SSLContext.getInstance("TLS")
|
||||||
|
ctx.init(null, arrayOf<TrustManager>(tm), SecureRandom())
|
||||||
|
return ctx.socketFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport
|
||||||
|
* exception: the trust manager throws it during the handshake and the
|
||||||
|
* JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when
|
||||||
|
* the failure has nothing to do with an untrusted gateway certificate.
|
||||||
|
*/
|
||||||
|
fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? {
|
||||||
|
var t: Throwable? = e
|
||||||
|
var depth = 0
|
||||||
|
while (t != null && depth < 10) {
|
||||||
|
if (t is TlsFingerprintRequired) return t
|
||||||
|
t = t.cause
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -140,6 +140,8 @@ data class ServerCaps(
|
|||||||
val push: String = "fcm",
|
val push: String = "fcm",
|
||||||
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
||||||
val pickers: Boolean = false,
|
val pickers: Boolean = false,
|
||||||
|
/** Release version of the gateway plugin (repo-root VERSION file). */
|
||||||
|
@SerialName("app_version") val appVersion: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -150,6 +152,9 @@ data class ChannelInfo(
|
|||||||
@SerialName("is_default") val isDefault: Boolean = false,
|
@SerialName("is_default") val isDefault: Boolean = false,
|
||||||
@SerialName("parent_chat_id") val parentChatId: String? = null,
|
@SerialName("parent_chat_id") val parentChatId: String? = null,
|
||||||
val archived: Boolean = false,
|
val archived: Boolean = false,
|
||||||
|
/** Unix timestamp (seconds) when the channel/thread was created; 0.0 if
|
||||||
|
* the gateway predates the field. Used to order threads newest-first. */
|
||||||
|
val created: Double = 0.0,
|
||||||
/** Gateway minted this thread for an incoming message (auto-threading);
|
/** Gateway minted this thread for an incoming message (auto-threading);
|
||||||
* the name is a derived title, upgraded by the LLM via channel.renamed. */
|
* the name is a derived title, upgraded by the LLM via channel.renamed. */
|
||||||
val auto: Boolean = false,
|
val auto: Boolean = false,
|
||||||
@@ -176,6 +181,11 @@ data class HelloAckPayload(
|
|||||||
* push backend (0 = never). Sync-replayed frames at/below it must not
|
* push backend (0 = never). Sync-replayed frames at/below it must not
|
||||||
* re-post system notifications (dedupe, docs/08 §8.7). */
|
* re-post system notifications (dedupe, docs/08 §8.7). */
|
||||||
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
|
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
|
||||||
|
/** Per-device token minted at pairing (docs/09 §9.3). The app stores it
|
||||||
|
* and presents it INSTEAD of the shared IRIS_TOKEN from then on; the
|
||||||
|
* gateway can revoke it per device. Empty when the gateway didn't
|
||||||
|
* issue one (legacy). */
|
||||||
|
@SerialName("device_token") val deviceToken: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── message (server -> app) ─────────────────────────────────────────────
|
// ── message (server -> app) ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ import iris.ui.theme.Backdrop
|
|||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
import iris.util.IrisLog
|
import iris.util.IrisLog
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MAX
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MIN
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -93,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,
|
||||||
@@ -156,6 +160,15 @@ class IrisController(
|
|||||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||||
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release version of the connected gateway (hello.ack `server_caps.app_version`,
|
||||||
|
* the repo-root VERSION file). Empty when not connected or the gateway is
|
||||||
|
* old enough not to report it. Settings shows it next to the app version
|
||||||
|
* and hints when the two differ.
|
||||||
|
*/
|
||||||
|
private val _gatewayVersion = MutableStateFlow("")
|
||||||
|
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
|
||||||
|
|
||||||
// ── M8: unread indicator ──────────────────────────────────────────────
|
// ── M8: unread indicator ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** True while the current lane's newest content sits at the bottom of the
|
/** True while the current lane's newest content sits at the bottom of the
|
||||||
@@ -177,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
|
||||||
@@ -267,6 +301,21 @@ class IrisController(
|
|||||||
chat.streamingEnabled = _streamingEnabled.value
|
chat.streamingEnabled = _streamingEnabled.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streaming smoothness (Settings → "Streaming speed"): seconds between
|
||||||
|
// visible text updates while streaming (lower = faster/smoother).
|
||||||
|
// Per-device display preference; applied on the fly by the reveal loop
|
||||||
|
// in MarkdownText (docs/05 §5.1).
|
||||||
|
private val _streamSmoothness =
|
||||||
|
MutableStateFlow(store.streamSmoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX))
|
||||||
|
val streamSmoothness: StateFlow<Float> = _streamSmoothness.asStateFlow()
|
||||||
|
|
||||||
|
fun setStreamSmoothness(value: Float) {
|
||||||
|
val clamped = value.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
|
||||||
|
if (clamped == _streamSmoothness.value) return
|
||||||
|
_streamSmoothness.value = clamped
|
||||||
|
store.streamSmoothness = clamped
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
|
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
|
||||||
// Persisted. When on, long reasoning blocks start collapsed (short ones
|
// Persisted. When on, long reasoning blocks start collapsed (short ones
|
||||||
// stay expanded); when off, all reasoning blocks start expanded.
|
// stay expanded); when off, all reasoning blocks start expanded.
|
||||||
@@ -306,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
|
||||||
|
|
||||||
@@ -497,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)
|
||||||
@@ -621,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)
|
||||||
@@ -634,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)
|
||||||
@@ -761,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
|
||||||
@@ -872,6 +942,11 @@ class IrisController(
|
|||||||
// just close the in-flight turn's dangling tool cards /
|
// just close the in-flight turn's dangling tool cards /
|
||||||
// streaming bubble (nothing spins forever).
|
// streaming bubble (nothing spins forever).
|
||||||
chat.finalizeInterrupted()
|
chat.finalizeInterrupted()
|
||||||
|
// The gateway is gone — clear the advertised version so
|
||||||
|
// Settings doesn't keep showing it (and the mismatch
|
||||||
|
// hint) while disconnected (matches the KDoc: empty when
|
||||||
|
// not connected).
|
||||||
|
_gatewayVersion.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -910,6 +985,7 @@ class IrisController(
|
|||||||
* refreshes (skipped on a plain reconnect via historyLoaded).
|
* refreshes (skipped on a plain reconnect via historyLoaded).
|
||||||
*/
|
*/
|
||||||
private fun onConnectedLane(connected: GatewayClient.State.Connected) {
|
private fun onConnectedLane(connected: GatewayClient.State.Connected) {
|
||||||
|
_gatewayVersion.value = connected.caps.appVersion
|
||||||
// Never wipe the directory with an empty list: the long-poll restore
|
// Never wipe the directory with an empty list: the long-poll restore
|
||||||
// path carries no channels when there was no prior SSE hello (lastAck
|
// path carries no channels when there was no prior SSE hello (lastAck
|
||||||
// null), and the cached directory is still valid then.
|
// null), and the cached directory is still valid then.
|
||||||
@@ -1307,6 +1383,14 @@ class IrisController(
|
|||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TLS fingerprint confirm (docs/09 §9.4): pin the gateway's presented
|
||||||
|
* certificate so its self-signed cert is accepted from now on. The
|
||||||
|
* caller re-runs [connect] (or the connect loop picks the pin up on its
|
||||||
|
* next attempt — the trust manager reads the store live). */
|
||||||
|
fun confirmTlsFingerprint(fingerprint: String) {
|
||||||
|
store.pinnedCertFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
fun forget() {
|
fun forget() {
|
||||||
store.clear()
|
store.clear()
|
||||||
// A different gateway means a different chat universe — wipe the cache.
|
// A different gateway means a different chat universe — wipe the cache.
|
||||||
|
|||||||
@@ -9,17 +9,21 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.LinkInteractionListener
|
import androidx.compose.ui.text.LinkInteractionListener
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -37,15 +41,28 @@ import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
|
|||||||
import com.mikepenz.markdown.m3.Markdown
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
import com.mikepenz.markdown.m3.markdownColor
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
import com.mikepenz.markdown.m3.markdownTypography
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
|
import com.mikepenz.markdown.model.StreamingMarkdownState
|
||||||
import com.mikepenz.markdown.model.markdownAnnotator
|
import com.mikepenz.markdown.model.markdownAnnotator
|
||||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||||
|
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
|
||||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||||
import dev.snipme.highlights.Highlights
|
import dev.snipme.highlights.Highlights
|
||||||
import dev.snipme.highlights.model.SyntaxThemes
|
import dev.snipme.highlights.model.SyntaxThemes
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||||
|
import iris.util.appendChunk
|
||||||
|
import iris.util.prepareForMarkdown
|
||||||
|
import iris.util.preserveNewlinesAsHardBreaks
|
||||||
|
import iris.util.preserveNewlinesAsHardBreaksStreaming
|
||||||
|
import iris.util.revealStep
|
||||||
|
import iris.util.streamCharsPerSecond
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
|
import org.intellij.markdown.MarkdownTokenTypes
|
||||||
|
|
||||||
|
/** Reveal tick for the streaming typewriter effect (docs/05 §5.1). */
|
||||||
|
private const val STREAM_REVEAL_TICK_MS = 50L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
|
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
|
||||||
@@ -53,6 +70,12 @@ import org.intellij.markdown.MarkdownElementTypes
|
|||||||
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
||||||
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
||||||
* rendered text blends in.
|
* rendered text blends in.
|
||||||
|
*
|
||||||
|
* While [isStreaming], the text is revealed at a steady rate (controlled by
|
||||||
|
* [streamSmoothness], Settings → Streaming) and parsed INCREMENTALLY: settled
|
||||||
|
* blocks are parsed once and never re-laid out, only the tail re-renders per
|
||||||
|
* tick — so the bubble stays readable instead of reflowing (and flashing raw
|
||||||
|
* markdown) on every gateway update.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownText(
|
fun MarkdownText(
|
||||||
@@ -64,11 +87,30 @@ fun MarkdownText(
|
|||||||
// plain highlighted code — the artifact card (and its runnable preview)
|
// plain highlighted code — the artifact card (and its runnable preview)
|
||||||
// only appears once the message is complete.
|
// only appears once the message is complete.
|
||||||
isStreaming: Boolean = false,
|
isStreaming: Boolean = false,
|
||||||
|
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||||
) {
|
) {
|
||||||
val state = rememberMarkdownState(text)
|
// Static path: transform once per text change (leading blanks stripped,
|
||||||
|
// single newlines → hard breaks).
|
||||||
|
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
|
||||||
|
// Whether this bubble has ever been streamed. Static history messages
|
||||||
|
// skip the reveal pipeline entirely.
|
||||||
|
val hasStreamed = remember { mutableStateOf(isStreaming) }
|
||||||
|
if (isStreaming) hasStreamed.value = true
|
||||||
|
val pipeline =
|
||||||
|
rememberStreamingMarkdownPipeline(
|
||||||
|
text = text,
|
||||||
|
smoothness = streamSmoothness,
|
||||||
|
active = hasStreamed.value,
|
||||||
|
streaming = isStreaming,
|
||||||
|
)
|
||||||
|
// Keep the streaming renderer until the reveal catches up, even after
|
||||||
|
// message.stop — a fast model that dumps the whole text at once still
|
||||||
|
// plays out the typewriter instead of jumping to the full message.
|
||||||
|
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
|
||||||
// The app is always dark-themed, so force the dark highlight palette
|
// The app is always dark-themed, so force the dark highlight palette
|
||||||
// (isSystemInDarkTheme() is unreliable on desktop).
|
// (isSystemInDarkTheme() is unreliable on desktop).
|
||||||
val highlights = remember {
|
val highlights =
|
||||||
|
remember {
|
||||||
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
||||||
}
|
}
|
||||||
val base = TextStyle(color = color, fontSize = fontSize)
|
val base = TextStyle(color = color, fontSize = fontSize)
|
||||||
@@ -80,7 +122,8 @@ fun MarkdownText(
|
|||||||
// Brief green flash shown on the chip right after a copy, so the tap is
|
// Brief green flash shown on the chip right after a copy, so the tap is
|
||||||
// visible feedback (the clipboard write itself is silent).
|
// visible feedback (the clipboard write itself is silent).
|
||||||
val copiedCodeSpanStyle =
|
val copiedCodeSpanStyle =
|
||||||
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
base
|
||||||
|
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
||||||
.toSpanStyle()
|
.toSpanStyle()
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -92,9 +135,11 @@ fun MarkdownText(
|
|||||||
// Re-render it without the padding, and wrap it in a link so tapping the
|
// Re-render it without the padding, and wrap it in a link so tapping the
|
||||||
// inline code copies it to the clipboard (Telegram-style). The
|
// inline code copies it to the clipboard (Telegram-style). The
|
||||||
// LinkAnnotation carries the click listener; the URL is never opened.
|
// LinkAnnotation carries the click listener; the URL is never opened.
|
||||||
val annotator = markdownAnnotator(
|
val annotator =
|
||||||
|
markdownAnnotator(
|
||||||
annotate = { content, child ->
|
annotate = { content, child ->
|
||||||
if (child.type == MarkdownElementTypes.CODE_SPAN) {
|
when {
|
||||||
|
child.type == MarkdownElementTypes.CODE_SPAN -> {
|
||||||
val children = child.children
|
val children = child.children
|
||||||
// Drop the surrounding backtick tokens (present as first/last child).
|
// Drop the surrounding backtick tokens (present as first/last child).
|
||||||
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
||||||
@@ -106,13 +151,15 @@ fun MarkdownText(
|
|||||||
url = "iris:copy-code",
|
url = "iris:copy-code",
|
||||||
// Keep every interaction state identical to the chip so
|
// Keep every interaction state identical to the chip so
|
||||||
// hover/press never restyles the inline code.
|
// hover/press never restyles the inline code.
|
||||||
styles = TextLinkStyles(
|
styles =
|
||||||
|
TextLinkStyles(
|
||||||
style = spanStyle,
|
style = spanStyle,
|
||||||
focusedStyle = spanStyle,
|
focusedStyle = spanStyle,
|
||||||
hoveredStyle = spanStyle,
|
hoveredStyle = spanStyle,
|
||||||
pressedStyle = spanStyle,
|
pressedStyle = spanStyle,
|
||||||
),
|
),
|
||||||
linkInteractionListener = LinkInteractionListener {
|
linkInteractionListener =
|
||||||
|
LinkInteractionListener {
|
||||||
clipboard.setText(AnnotatedString(code))
|
clipboard.setText(AnnotatedString(code))
|
||||||
copiedCode.value = code
|
copiedCode.value = code
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -126,23 +173,36 @@ fun MarkdownText(
|
|||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
true
|
true
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// Streaming cursor: append ▉ to the last text leaf of the
|
||||||
|
// document so the cursor sits at the end of the visible text.
|
||||||
|
// The tail is re-annotated on every reveal tick, so the cursor
|
||||||
|
// follows the text (and vanishes once the message is final).
|
||||||
|
useStreaming &&
|
||||||
|
child.type == MarkdownTokenTypes.TEXT &&
|
||||||
|
content.length - child.endOffset <= 1 -> {
|
||||||
|
append(child.getUnescapedTextInNode(content))
|
||||||
|
append(" ▉")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Markdown(
|
val colors =
|
||||||
markdownState = state,
|
markdownColor(
|
||||||
modifier = modifier,
|
|
||||||
annotator = annotator,
|
|
||||||
colors = markdownColor(
|
|
||||||
text = color,
|
text = color,
|
||||||
codeBackground = Color.Black.copy(alpha = 0.8f),
|
codeBackground = Color.Black.copy(alpha = 0.8f),
|
||||||
inlineCodeBackground = inlineCodeBackground,
|
inlineCodeBackground = inlineCodeBackground,
|
||||||
dividerColor = IrisColors.divider,
|
dividerColor = IrisColors.divider,
|
||||||
tableBackground = Color.White.copy(alpha = 0.03f),
|
tableBackground = Color.White.copy(alpha = 0.03f),
|
||||||
),
|
)
|
||||||
typography = markdownTypography(
|
val typography =
|
||||||
|
markdownTypography(
|
||||||
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
||||||
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
||||||
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
||||||
@@ -157,15 +217,17 @@ fun MarkdownText(
|
|||||||
ordered = base,
|
ordered = base,
|
||||||
bullet = base,
|
bullet = base,
|
||||||
list = base,
|
list = base,
|
||||||
textLink = TextLinkStyles(
|
textLink =
|
||||||
|
TextLinkStyles(
|
||||||
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
||||||
),
|
),
|
||||||
table = base.copy(fontSize = fontSize * 0.95f),
|
table = base.copy(fontSize = fontSize * 0.95f),
|
||||||
),
|
)
|
||||||
components = markdownComponents(
|
val components =
|
||||||
|
markdownComponents(
|
||||||
codeFence = { model ->
|
codeFence = { model ->
|
||||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||||
if (!isStreaming && isHtmlArtifact(language, code)) {
|
if (!useStreaming && isHtmlArtifact(language, code)) {
|
||||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
} else {
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
@@ -174,7 +236,7 @@ fun MarkdownText(
|
|||||||
},
|
},
|
||||||
codeBlock = { model ->
|
codeBlock = { model ->
|
||||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
||||||
if (!isStreaming && isHtmlArtifact(language, code)) {
|
if (!useStreaming && isHtmlArtifact(language, code)) {
|
||||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
} else {
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
@@ -184,16 +246,128 @@ fun MarkdownText(
|
|||||||
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
||||||
// Material 3 checkbox instead.
|
// Material 3 checkbox instead.
|
||||||
checkbox = {
|
checkbox = {
|
||||||
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
com.mikepenz.markdown.m3.elements
|
||||||
|
.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
|
if (useStreaming) {
|
||||||
|
if (pipeline.displayed.isEmpty()) {
|
||||||
|
// No text revealed yet (message.start, first tick pending): show
|
||||||
|
// just the cursor so the bubble is visible immediately.
|
||||||
|
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
|
||||||
|
} else {
|
||||||
|
Markdown(
|
||||||
|
streamingMarkdownState = pipeline.state,
|
||||||
|
modifier = modifier,
|
||||||
|
annotator = annotator,
|
||||||
|
colors = colors,
|
||||||
|
typography = typography,
|
||||||
|
components = components,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// retainState: keep the last formatted output visible while the new
|
||||||
|
// text re-parses (async) — no raw-markdown flash between updates.
|
||||||
|
val state = rememberMarkdownState(displayText, retainState = true)
|
||||||
|
Markdown(
|
||||||
|
markdownState = state,
|
||||||
|
modifier = modifier,
|
||||||
|
annotator = annotator,
|
||||||
|
colors = colors,
|
||||||
|
typography = typography,
|
||||||
|
components = components,
|
||||||
loading = { m ->
|
loading = { m ->
|
||||||
// While (re)parsing — e.g. on each streaming update — show the raw
|
// First parse of a (long) message: show the text so the bubble
|
||||||
// text so the bubble never goes blank between updates.
|
// never goes blank.
|
||||||
Text(text, modifier = m, color = color, fontSize = fontSize)
|
Text(displayText, modifier = m, color = color, fontSize = fontSize)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
|
||||||
|
* snapshot from the gateway; it is revealed at a steady rate controlled by
|
||||||
|
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
|
||||||
|
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
|
||||||
|
* keep their AST identity, so Compose never re-lays them out — only the
|
||||||
|
* unstable tail re-renders per tick.
|
||||||
|
*
|
||||||
|
* [active] is false for history messages (never streamed): the reveal is
|
||||||
|
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
|
||||||
|
* indicates the message is still receiving updates; once the reveal catches
|
||||||
|
* up and the message is final, the reveal loop stops.
|
||||||
|
*
|
||||||
|
* Returns the parser state, the currently revealed text (read inside the
|
||||||
|
* composition so reveal ticks recompose the caller), and whether the reveal
|
||||||
|
* has caught up with the target.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberStreamingMarkdownPipeline(
|
||||||
|
text: String,
|
||||||
|
smoothness: Float,
|
||||||
|
active: Boolean,
|
||||||
|
streaming: Boolean,
|
||||||
|
): StreamingPipeline {
|
||||||
|
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
|
||||||
|
// a prefix of the revealed text always maps to a prefix of the target, so
|
||||||
|
// the diff between consecutive reveals is a pure append.
|
||||||
|
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
|
||||||
|
val displayed = remember { mutableStateOf("") }
|
||||||
|
val latestTarget = rememberUpdatedState(target)
|
||||||
|
val latestSmoothness = rememberUpdatedState(smoothness)
|
||||||
|
val latestStreaming = rememberUpdatedState(streaming)
|
||||||
|
// Bumped when the target is rewritten (not extended): the parser state is
|
||||||
|
// recreated via key() below and re-seeded with the full text.
|
||||||
|
val generation = remember { mutableIntStateOf(0) }
|
||||||
|
val state = key(generation.value) { rememberStreamingMarkdownState() }
|
||||||
|
LaunchedEffect(state, active) {
|
||||||
|
if (!active) {
|
||||||
|
// Never streamed (history message): reveal everything at once so
|
||||||
|
// the caller falls back to the static renderer immediately.
|
||||||
|
displayed.value = latestTarget.value
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
var last = ""
|
||||||
|
while (true) {
|
||||||
|
delay(STREAM_REVEAL_TICK_MS)
|
||||||
|
val t = latestTarget.value
|
||||||
|
val cur = displayed.value
|
||||||
|
if (cur == t) {
|
||||||
|
// Reveal complete: keep ticking only while the message is
|
||||||
|
// still streaming (more text may arrive); otherwise stop so
|
||||||
|
// finished bubbles don't burn battery.
|
||||||
|
if (!latestStreaming.value) return@LaunchedEffect
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val step =
|
||||||
|
revealStep(
|
||||||
|
remaining = t.length - cur.length,
|
||||||
|
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
|
||||||
|
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
|
||||||
|
)
|
||||||
|
val next = t.take(cur.length + step)
|
||||||
|
displayed.value = next
|
||||||
|
val chunk = appendChunk(last, next)
|
||||||
|
if (chunk == null) {
|
||||||
|
// Rewritten, not extended: recreate the parser state. The
|
||||||
|
// effect restarts (keyed on [state]) and re-seeds it.
|
||||||
|
generation.value++
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (chunk.isNotEmpty()) state.append(chunk)
|
||||||
|
last = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StreamingPipeline(state, displayed.value, displayed.value == target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of the streaming reveal pipeline. */
|
||||||
|
private data class StreamingPipeline(
|
||||||
|
val state: StreamingMarkdownState,
|
||||||
|
val displayed: String,
|
||||||
|
val done: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a highlighted code block with a copy button in the top-right corner
|
* Renders a highlighted code block with a copy button in the top-right corner
|
||||||
@@ -213,7 +387,8 @@ internal fun CodeBlockWithCopy(
|
|||||||
if (code.isNotBlank()) {
|
if (code.isNotBlank()) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { clipboard.setText(AnnotatedString(code)) },
|
onClick = { clipboard.setText(AnnotatedString(code)) },
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
// MarkdownHighlightedCode insets its background by 8dp top;
|
// MarkdownHighlightedCode insets its background by 8dp top;
|
||||||
// match that so the button sits inside the block, not above it.
|
// match that so the button sits inside the block, not above it.
|
||||||
|
|||||||
@@ -143,12 +143,11 @@ import iris.ui.theme.IrisColors
|
|||||||
import iris.ui.theme.LocalUserTheme
|
import iris.ui.theme.LocalUserTheme
|
||||||
import iris.ui.theme.avatarColor
|
import iris.ui.theme.avatarColor
|
||||||
import iris.ui.theme.contrastText
|
import iris.ui.theme.contrastText
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||||
import iris.util.formatDayLabel
|
import iris.util.formatDayLabel
|
||||||
import iris.util.formatTime
|
import iris.util.formatTime
|
||||||
import iris.util.fuzzyScore
|
import iris.util.fuzzyScore
|
||||||
import iris.util.localDayKey
|
import iris.util.localDayKey
|
||||||
import iris.util.prepareForMarkdown
|
|
||||||
import iris.util.preserveNewlinesAsHardBreaks
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -171,11 +170,17 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||||
|
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
||||||
|
|
||||||
val channels by controller.channels.channels.collectAsState()
|
val channels by controller.channels.channels.collectAsState()
|
||||||
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
|
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
|
||||||
val currentChannel = channels.firstOrNull { it.chatId == currentChatId }
|
val currentChannel = channels.firstOrNull { it.chatId == currentChatId }
|
||||||
val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId }
|
// Threads newest-first (right after "General") so the user swipes from
|
||||||
|
// new to old; ties (e.g. created==0 from an old cache) fall back to name.
|
||||||
|
val threads =
|
||||||
|
channels
|
||||||
|
.filter { it.kind == "thread" && it.parentChatId == currentChatId }
|
||||||
|
.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
|
||||||
|
|
||||||
// M8: unread counts. Per-lane from the store; per-channel aggregated
|
// M8: unread counts. Per-lane from the store; per-channel aggregated
|
||||||
// (flat lane + all threads) for the drawer/rail badges.
|
// (flat lane + all threads) for the drawer/rail badges.
|
||||||
@@ -712,6 +717,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
},
|
},
|
||||||
runtimeFooterEnabled = runtimeFooterEnabled,
|
runtimeFooterEnabled = runtimeFooterEnabled,
|
||||||
runtimeFooterFields = runtimeFooterFields,
|
runtimeFooterFields = runtimeFooterFields,
|
||||||
|
streamSmoothness = streamSmoothness,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2100,6 +2106,7 @@ private fun statusLabel(state: GatewayClient.State): String =
|
|||||||
GatewayClient.State.Reconnecting -> "reconnecting…"
|
GatewayClient.State.Reconnecting -> "reconnecting…"
|
||||||
is GatewayClient.State.Connected -> "connected"
|
is GatewayClient.State.Connected -> "connected"
|
||||||
is GatewayClient.State.AuthFailed -> "auth failed"
|
is GatewayClient.State.AuthFailed -> "auth failed"
|
||||||
|
is GatewayClient.State.TlsConfirmRequired -> "cert confirm needed"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */
|
/** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */
|
||||||
@@ -2385,6 +2392,7 @@ private fun statusToastText(state: GatewayClient.State): String =
|
|||||||
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
|
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
|
||||||
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
||||||
is GatewayClient.State.AuthFailed -> "Unpaired from Hermes"
|
is GatewayClient.State.AuthFailed -> "Unpaired from Hermes"
|
||||||
|
is GatewayClient.State.TlsConfirmRequired -> "Gateway certificate needs confirmation"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header status bubble: green = connected, yellow pulsing = (re)connecting,
|
/** Header status bubble: green = connected, yellow pulsing = (re)connecting,
|
||||||
@@ -2405,6 +2413,7 @@ private fun StatusBubble(
|
|||||||
|
|
||||||
GatewayClient.State.Disconnected,
|
GatewayClient.State.Disconnected,
|
||||||
is GatewayClient.State.AuthFailed,
|
is GatewayClient.State.AuthFailed,
|
||||||
|
is GatewayClient.State.TlsConfirmRequired,
|
||||||
-> IrisColors.statusRed to false
|
-> IrisColors.statusRed to false
|
||||||
}
|
}
|
||||||
val alpha = remember { Animatable(1f) }
|
val alpha = remember { Animatable(1f) }
|
||||||
@@ -2518,6 +2527,7 @@ private fun MessageBubble(
|
|||||||
onSelect: (() -> Unit)? = null,
|
onSelect: (() -> Unit)? = null,
|
||||||
runtimeFooterEnabled: Boolean = false,
|
runtimeFooterEnabled: Boolean = false,
|
||||||
runtimeFooterFields: List<String> = emptyList(),
|
runtimeFooterFields: List<String> = emptyList(),
|
||||||
|
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||||
) {
|
) {
|
||||||
val isUser = msg.role == ROLE_USER
|
val isUser = msg.role == ROLE_USER
|
||||||
val isCommentary = msg.isCommentary
|
val isCommentary = msg.isCommentary
|
||||||
@@ -2591,9 +2601,7 @@ private fun MessageBubble(
|
|||||||
// in replies.
|
// in replies.
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text =
|
text = msg.text,
|
||||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
|
||||||
if (msg.streaming) " ▉" else "",
|
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
isStreaming = msg.streaming,
|
isStreaming = msg.streaming,
|
||||||
@@ -2638,20 +2646,18 @@ private fun MessageBubble(
|
|||||||
} else {
|
} else {
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
// M8: render the agent's reply as markdown (bold / italic /
|
// M8: render the agent's reply as markdown (bold / italic /
|
||||||
// underscore, tables, highlighted code blocks). Leading
|
// underscore, tables, highlighted code blocks). The
|
||||||
// newlines are stripped so the text hugs the top of the
|
// transform (leading-newline strip, hard breaks) and the ▉
|
||||||
// bubble; single newlines become hard breaks (models write
|
// streaming cursor live inside MarkdownText; while
|
||||||
// status lines and wrapped text expecting a break per line,
|
// streaming the text is revealed at a steady rate and
|
||||||
// same as user input); the ▉ cursor is kept while streaming.
|
// parsed incrementally (docs/05 §5.1).
|
||||||
val displayText =
|
|
||||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
|
||||||
if (msg.streaming) " ▉" else ""
|
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text = displayText,
|
text = msg.text,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
fontSize = if (isCommentary) 13.sp else 15.sp,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
isStreaming = msg.streaming,
|
isStreaming = msg.streaming,
|
||||||
|
streamSmoothness = streamSmoothness,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -24,9 +26,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import iris.net.TlsFingerprintRequired
|
||||||
import iris.platform.QrScanButton
|
import iris.platform.QrScanButton
|
||||||
import iris.platform.isDesktop
|
import iris.platform.isDesktop
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
@@ -44,6 +48,9 @@ fun ConnectScreen(
|
|||||||
prefillUrl: String = "",
|
prefillUrl: String = "",
|
||||||
prefillToken: String = "",
|
prefillToken: String = "",
|
||||||
initialError: String? = null,
|
initialError: String? = null,
|
||||||
|
/** Gateway certificate fingerprint awaiting user confirmation (docs/09
|
||||||
|
* §9.4) — shown as a confirm dialog on entry. */
|
||||||
|
tlsFingerprint: String? = null,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
||||||
@@ -52,6 +59,31 @@ fun ConnectScreen(
|
|||||||
var token by remember { mutableStateOf(prefillToken) }
|
var token by remember { mutableStateOf(prefillToken) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf(initialError) }
|
var error by remember { mutableStateOf(initialError) }
|
||||||
|
// Fingerprint the user still has to confirm (self-signed gateway cert,
|
||||||
|
// docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a
|
||||||
|
// connect attempt fails with an untrusted certificate.
|
||||||
|
var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) }
|
||||||
|
|
||||||
|
// "Test & Connect" (also the dialog's confirm action): real hello test,
|
||||||
|
// then save + (re)connect. An untrusted gateway certificate surfaces as
|
||||||
|
// the fingerprint confirm dialog instead of a plain error.
|
||||||
|
fun doConnect() {
|
||||||
|
if (busy) return
|
||||||
|
busy = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
val result = controller.connect(url.trim(), token.trim())
|
||||||
|
busy = false
|
||||||
|
if (result.isFailure) {
|
||||||
|
val ex = result.exceptionOrNull()
|
||||||
|
if (ex is TlsFingerprintRequired) {
|
||||||
|
pendingFingerprint = ex.fingerprint
|
||||||
|
} else {
|
||||||
|
error = ex?.message ?: "connection failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -116,18 +148,7 @@ fun ConnectScreen(
|
|||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { doConnect() },
|
||||||
if (busy) return@Button
|
|
||||||
busy = true
|
|
||||||
error = null
|
|
||||||
scope.launch {
|
|
||||||
val result = controller.connect(url.trim(), token.trim())
|
|
||||||
busy = false
|
|
||||||
if (result.isFailure) {
|
|
||||||
error = result.exceptionOrNull()?.message ?: "connection failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
@@ -152,4 +173,43 @@ fun ConnectScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate
|
||||||
|
// this device doesn't trust (self-signed). The user verifies the
|
||||||
|
// fingerprint on the gateway host, then confirms — the pin is stored in
|
||||||
|
// secure storage and the connect is retried, like an SSH host key.
|
||||||
|
if (pendingFingerprint != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingFingerprint = null },
|
||||||
|
title = { Text("Confirm gateway certificate") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"The gateway presents a certificate this device doesn't trust. " +
|
||||||
|
"Verify the fingerprint on the gateway host, then confirm to pin it.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
pendingFingerprint!!,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val fp = pendingFingerprint!!
|
||||||
|
pendingFingerprint = null
|
||||||
|
controller.confirmTlsFingerprint(fp)
|
||||||
|
doConnect()
|
||||||
|
},
|
||||||
|
) { Text("Confirm & pin") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,8 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import iris.AppVersion
|
||||||
|
import iris.net.ReleaseCheck
|
||||||
import iris.platform.ImageFilePicker
|
import iris.platform.ImageFilePicker
|
||||||
import iris.platform.decodeImageBytes
|
import iris.platform.decodeImageBytes
|
||||||
import iris.platform.loadScaledImage
|
import iris.platform.loadScaledImage
|
||||||
@@ -54,6 +56,8 @@ import iris.ui.theme.Backdrop
|
|||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
import iris.ui.theme.LocalUserTheme
|
import iris.ui.theme.LocalUserTheme
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MAX
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MIN
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,15 +75,24 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||||
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
||||||
|
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
||||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||||
val toolDetail by controller.toolDetail.collectAsState()
|
val toolDetail by controller.toolDetail.collectAsState()
|
||||||
val fontSizeScale by controller.fontSizeScale.collectAsState()
|
val fontSizeScale by controller.fontSizeScale.collectAsState()
|
||||||
|
val gatewayVersion by controller.gatewayVersion.collectAsState()
|
||||||
val theme = LocalUserTheme.current
|
val theme = LocalUserTheme.current
|
||||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showForgetConfirm by remember { mutableStateOf(false) }
|
var showForgetConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Best-effort latest-release check (docs/04): one query per screen open,
|
||||||
|
// failures stay silent (ReleaseCheck returns null).
|
||||||
|
var latestVersion by remember { mutableStateOf<String?>(null) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
latestVersion = ReleaseCheck.latestVersion()
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -142,6 +155,27 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = { controller.toggleStreaming() },
|
onCheckedChange = { controller.toggleStreaming() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (streamingEnabled) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Streaming speed", fontSize = 12.sp, color = IrisColors.textDim)
|
||||||
|
Text(
|
||||||
|
"How fast new text appears while streaming — lower is faster",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = IrisColors.textDim,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Fast", fontSize = 12.sp, color = IrisColors.textDim)
|
||||||
|
Slider(
|
||||||
|
value = streamSmoothness,
|
||||||
|
onValueChange = { controller.setStreamSmoothness(it) },
|
||||||
|
valueRange = STREAM_SMOOTHNESS_MIN..STREAM_SMOOTHNESS_MAX,
|
||||||
|
steps = 5, // 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.7 / 0.8
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text("Slow", fontSize = 12.sp, color = IrisColors.textDim)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
Row(
|
Row(
|
||||||
@@ -410,6 +444,19 @@ fun SettingsScreen(
|
|||||||
Text("Forget pairing", fontSize = 12.sp)
|
Text("Forget pairing", fontSize = 12.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"About",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
SettingsCard {
|
||||||
|
VersionCard(
|
||||||
|
appVersion = AppVersion.VERSION,
|
||||||
|
gatewayVersion = gatewayVersion,
|
||||||
|
latestVersion = latestVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (pickerTarget) {
|
when (pickerTarget) {
|
||||||
@@ -510,6 +557,47 @@ private fun BackArrow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* About card (docs/04 hello.ack note): app version (repo-root VERSION baked in
|
||||||
|
* at build time), the connected gateway's version (hello.ack
|
||||||
|
* `server_caps.app_version`), a mismatch hint, and the latest Gitea release
|
||||||
|
* when the running build is older.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun VersionCard(
|
||||||
|
appVersion: String,
|
||||||
|
gatewayVersion: String,
|
||||||
|
latestVersion: String?,
|
||||||
|
) {
|
||||||
|
Text("📦 Version", fontSize = 14.sp)
|
||||||
|
Text(
|
||||||
|
"Iris app v$appVersion",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = IrisColors.textSecondary,
|
||||||
|
)
|
||||||
|
if (gatewayVersion.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
"Gateway v$gatewayVersion",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = IrisColors.textSecondary,
|
||||||
|
)
|
||||||
|
if (gatewayVersion != appVersion) {
|
||||||
|
Text(
|
||||||
|
"App and gateway versions differ — update the other side to match.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = IrisColors.statusAmber,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest ->
|
||||||
|
Text(
|
||||||
|
"New version v$latest available — see the Gitea releases page.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = IrisColors.statusAmber,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Settings row container (panel card). */
|
/** Settings row container (panel card). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsCard(content: @Composable () -> Unit) {
|
private fun SettingsCard(content: @Composable () -> Unit) {
|
||||||
|
|||||||
@@ -25,16 +25,107 @@ fun String.prepareForMarkdown(): String {
|
|||||||
fun String.preserveNewlinesAsHardBreaks(): String {
|
fun String.preserveNewlinesAsHardBreaks(): String {
|
||||||
val lines = split("\n")
|
val lines = split("\n")
|
||||||
var inCodeBlock = false
|
var inCodeBlock = false
|
||||||
val out = lines.map { line ->
|
val out =
|
||||||
|
lines.map { line ->
|
||||||
val trimmed = line.trimStart()
|
val trimmed = line.trimStart()
|
||||||
when {
|
when {
|
||||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||||
inCodeBlock = !inCodeBlock
|
inCodeBlock = !inCodeBlock
|
||||||
line
|
line
|
||||||
}
|
}
|
||||||
inCodeBlock || line.isBlank() -> line
|
|
||||||
else -> line + " "
|
inCodeBlock || line.isBlank() -> {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
line + " "
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.joinToString("\n")
|
return out.joinToString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
|
||||||
|
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
|
||||||
|
*
|
||||||
|
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
|
||||||
|
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
|
||||||
|
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
|
||||||
|
* renderer feed the diff between consecutive snapshots into an append-only
|
||||||
|
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
|
||||||
|
* invisible at the end of the document, so a finished message renders exactly
|
||||||
|
* like the static transform.
|
||||||
|
*/
|
||||||
|
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
|
||||||
|
val lines = split("\n")
|
||||||
|
var inCodeBlock = false
|
||||||
|
val out =
|
||||||
|
lines.mapIndexed { index, line ->
|
||||||
|
val trimmed = line.trimStart()
|
||||||
|
when {
|
||||||
|
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||||
|
inCodeBlock = !inCodeBlock
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
inCodeBlock || line.isBlank() -> {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
index == lines.lastIndex -> {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
// still being typed
|
||||||
|
else -> {
|
||||||
|
line + " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
|
||||||
|
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
|
||||||
|
* content was rewritten, not appended. The gateway sends full text snapshots
|
||||||
|
* on every `message.update`; this converts them into append chunks for the
|
||||||
|
* append-only streaming parser.
|
||||||
|
*/
|
||||||
|
fun appendChunk(
|
||||||
|
last: String,
|
||||||
|
next: String,
|
||||||
|
): String? = if (next.startsWith(last)) next.substring(last.length) else null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of characters to reveal on one streaming tick. The reveal rate comes
|
||||||
|
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
|
||||||
|
* (fast model, reconnect catch-up) everything is revealed at once so the
|
||||||
|
* display never lags far behind the received text.
|
||||||
|
*/
|
||||||
|
fun revealStep(
|
||||||
|
remaining: Int,
|
||||||
|
charsPerSecond: Double,
|
||||||
|
tickSeconds: Double,
|
||||||
|
): Int {
|
||||||
|
if (remaining <= 0) return 0
|
||||||
|
if (remaining > charsPerSecond * 2.0) return remaining
|
||||||
|
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range of the streaming-smoothness setting (seconds between visible text
|
||||||
|
* updates while streaming; lower = faster/smoother).
|
||||||
|
*/
|
||||||
|
const val STREAM_SMOOTHNESS_MIN = 0.2f
|
||||||
|
const val STREAM_SMOOTHNESS_MAX = 0.8f
|
||||||
|
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
|
||||||
|
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
|
||||||
|
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
|
||||||
|
*/
|
||||||
|
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpsConfigurator
|
||||||
|
import com.sun.net.httpserver.HttpsServer
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.net.ssl.KeyManagerFactory
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real
|
||||||
|
* TLS handshake: a local HTTPS server presents the embedded self-signed
|
||||||
|
* certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like
|
||||||
|
* [GatewayClient] (pinning socket factory + trust manager).
|
||||||
|
*
|
||||||
|
* 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the
|
||||||
|
* presented fingerprint from the nested exception.
|
||||||
|
* 2. After "confirming" (setting the pin): the SAME client connects — the
|
||||||
|
* pin is read live, no client rebuild.
|
||||||
|
*
|
||||||
|
* The embedded key is a throwaway test key, not a secret.
|
||||||
|
*/
|
||||||
|
class TlsPinningIntegrationTest {
|
||||||
|
private companion object {
|
||||||
|
// Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname
|
||||||
|
// verifier requires a SAN; CN-only certs are rejected even when pinned).
|
||||||
|
val CERT_PEM =
|
||||||
|
"""
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z
|
||||||
|
NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG
|
||||||
|
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF
|
||||||
|
/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C
|
||||||
|
Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT
|
||||||
|
qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs
|
||||||
|
PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a
|
||||||
|
GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7
|
||||||
|
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd
|
||||||
|
WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm
|
||||||
|
EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3
|
||||||
|
Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE
|
||||||
|
dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw
|
||||||
|
zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0
|
||||||
|
Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val KEY_PEM =
|
||||||
|
"""
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G
|
||||||
|
uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc
|
||||||
|
fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H
|
||||||
|
eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7
|
||||||
|
bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC
|
||||||
|
qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7
|
||||||
|
OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5
|
||||||
|
PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd
|
||||||
|
sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r
|
||||||
|
yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g
|
||||||
|
WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN
|
||||||
|
ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE
|
||||||
|
BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f
|
||||||
|
utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem
|
||||||
|
Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK
|
||||||
|
ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu
|
||||||
|
OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H
|
||||||
|
yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w
|
||||||
|
Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6
|
||||||
|
190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF
|
||||||
|
JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/
|
||||||
|
LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M
|
||||||
|
JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI
|
||||||
|
MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z
|
||||||
|
OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO
|
||||||
|
sZ1EltfJHT57a0ub22mRtVc=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// `openssl x509 -noout -fingerprint -sha256` over CERT_PEM.
|
||||||
|
const val EXPECTED_FINGERPRINT =
|
||||||
|
"9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44"
|
||||||
|
|
||||||
|
fun pemBody(pem: String): ByteArray {
|
||||||
|
val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "")
|
||||||
|
return Base64.getDecoder().decode(base64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selfSignedGatewayRequiresConfirmThenPins() {
|
||||||
|
val cert =
|
||||||
|
CertificateFactory
|
||||||
|
.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray()))
|
||||||
|
.let { it as java.security.cert.X509Certificate }
|
||||||
|
val key =
|
||||||
|
KeyFactory
|
||||||
|
.getInstance("RSA")
|
||||||
|
.generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM)))
|
||||||
|
|
||||||
|
// Local HTTPS server presenting the self-signed cert.
|
||||||
|
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||||
|
ks.load(null, null)
|
||||||
|
ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert))
|
||||||
|
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||||
|
kmf.init(ks, CharArray(0))
|
||||||
|
val serverCtx = SSLContext.getInstance("TLS")
|
||||||
|
serverCtx.init(kmf.keyManagers, null, null)
|
||||||
|
|
||||||
|
val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||||
|
server.httpsConfigurator = HttpsConfigurator(serverCtx)
|
||||||
|
server.createContext("/v1/health") { exchange ->
|
||||||
|
val body = "ok".toByteArray()
|
||||||
|
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(body) }
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
|
||||||
|
// The client is wired exactly like GatewayClient: pinning socket
|
||||||
|
// factory + trust manager, pin read live from a mutable holder.
|
||||||
|
var pin = ""
|
||||||
|
val tm = PinningTrustManager { pin }
|
||||||
|
val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build()
|
||||||
|
val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Unpinned: the handshake fails; the unwrap finds the
|
||||||
|
// presented fingerprint in the nested exception chain.
|
||||||
|
val e =
|
||||||
|
assertFailsWith<Exception> {
|
||||||
|
client.newCall(request).execute().use { it.body!!.string() }
|
||||||
|
}
|
||||||
|
val tls = tlsFingerprintRequired(e)
|
||||||
|
assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e")
|
||||||
|
assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint)
|
||||||
|
|
||||||
|
// 2. User "confirms" the fingerprint: the SAME client now
|
||||||
|
// connects (the pin is read live, no client rebuild).
|
||||||
|
pin = EXPECTED_FINGERPRINT
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
assertEquals(200, response.code)
|
||||||
|
assertEquals("ok", response.body!!.string())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
client.dispatcher.executorService.shutdown()
|
||||||
|
client.connectionPool.evictAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow.
|
||||||
|
*
|
||||||
|
* The embedded certificate is a self-signed cert (CN=iris-test-gateway) the
|
||||||
|
* platform trust store does NOT contain, so it exercises the exact path a
|
||||||
|
* self-signed gateway hits: default trust manager rejects → the pinning
|
||||||
|
* manager either offers the fingerprint for confirmation or accepts the
|
||||||
|
* user-confirmed pin.
|
||||||
|
*/
|
||||||
|
class TlsPinningTest {
|
||||||
|
private companion object {
|
||||||
|
// Self-signed, 10-year validity — generated with:
|
||||||
|
// openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway"
|
||||||
|
val SELF_SIGNED_PEM =
|
||||||
|
"""
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL
|
||||||
|
BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3
|
||||||
|
WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC
|
||||||
|
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp
|
||||||
|
9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V
|
||||||
|
LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs
|
||||||
|
BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc
|
||||||
|
HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66
|
||||||
|
UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC
|
||||||
|
AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY
|
||||||
|
MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
|
||||||
|
hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj
|
||||||
|
GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/
|
||||||
|
Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S
|
||||||
|
vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA
|
||||||
|
I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2
|
||||||
|
vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// `openssl x509 -noout -fingerprint -sha256` over the same cert.
|
||||||
|
const val EXPECTED_FINGERPRINT =
|
||||||
|
"C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA"
|
||||||
|
|
||||||
|
val CERT: X509Certificate by lazy {
|
||||||
|
CertificateFactory
|
||||||
|
.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray()))
|
||||||
|
.let { it as X509Certificate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun certFingerprintMatchesOpenSsl() {
|
||||||
|
assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() {
|
||||||
|
val tm = PinningTrustManager { "" }
|
||||||
|
val e =
|
||||||
|
assertFailsWith<TlsFingerprintRequired> {
|
||||||
|
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||||
|
}
|
||||||
|
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun confirmedPinAcceptsTheSelfSignedCert() {
|
||||||
|
val tm = PinningTrustManager { EXPECTED_FINGERPRINT }
|
||||||
|
// Must not throw: the user confirmed exactly this certificate.
|
||||||
|
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrongPinStillFailsWithThePresentedFingerprint() {
|
||||||
|
val tm = PinningTrustManager { "DE:AD:BE:EF" }
|
||||||
|
val e =
|
||||||
|
assertFailsWith<TlsFingerprintRequired> {
|
||||||
|
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||||
|
}
|
||||||
|
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pinIsReadLive() {
|
||||||
|
// The provider is a lambda: a pin saved AFTER the manager was built
|
||||||
|
// (the confirm dialog's action) takes effect without rebuilding it.
|
||||||
|
var pin = ""
|
||||||
|
val tm = PinningTrustManager { pin }
|
||||||
|
assertFailsWith<TlsFingerprintRequired> {
|
||||||
|
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||||
|
}
|
||||||
|
pin = EXPECTED_FINGERPRINT
|
||||||
|
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unwrapFindsNestedTlsFingerprintRequired() {
|
||||||
|
val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT)
|
||||||
|
// The JSSE/OkHttp layers wrap the trust manager's exception in an
|
||||||
|
// (SSL) handshake IOException — the unwrap must find it nested.
|
||||||
|
val wrapped = IOException("PKIX path building failed", inner)
|
||||||
|
val found = tlsFingerprintRequired(wrapped)
|
||||||
|
assertNotNull(found)
|
||||||
|
assertEquals(EXPECTED_FINGERPRINT, found.fingerprint)
|
||||||
|
// Unrelated failures must not be misread as a confirm request.
|
||||||
|
assertNull(tlsFingerprintRequired(IOException("remote host closed connection")))
|
||||||
|
assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pinningSocketFactoryCreatesSockets() {
|
||||||
|
val tm = PinningTrustManager { "" }
|
||||||
|
val factory = pinningSslSocketFactory(tm)
|
||||||
|
val socket = factory.createSocket()
|
||||||
|
assertTrue(socket.javaClass.name.contains("SSL"))
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package iris.protocol
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/** Wire tests for the channel directory `created` field (thread ordering). */
|
||||||
|
class ChannelCreatedWireTest {
|
||||||
|
@Test
|
||||||
|
fun channelInfoDeserializesCreated() {
|
||||||
|
val raw =
|
||||||
|
"""
|
||||||
|
{"v":1,"type":"channel.created","payload":{"chat_id":"t_9","name":"New topic",
|
||||||
|
"kind":"thread","parent_chat_id":"default","created":1787648374.37}}
|
||||||
|
""".trimIndent()
|
||||||
|
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||||
|
val info = frame.payloadAs<ChannelInfo>()
|
||||||
|
assertEquals("t_9", info?.chatId)
|
||||||
|
assertEquals(1787648374.37, info?.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelInfoDefaultsCreatedToZero() {
|
||||||
|
// Legacy gateways omit the field; the app falls back to name ordering.
|
||||||
|
val raw =
|
||||||
|
"""{"v":1,"type":"channel.created","payload":{"chat_id":"t_1","name":"Old","kind":"thread"}}"""
|
||||||
|
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||||
|
assertEquals(0.0, frame.payloadAs<ChannelInfo>()?.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun threadsSortNewestFirst() {
|
||||||
|
// Mirrors the topic-switcher ordering in ChatScreen: created desc,
|
||||||
|
// name asc as the tie-break (e.g. for legacy entries with created==0).
|
||||||
|
val threads =
|
||||||
|
listOf(
|
||||||
|
ChannelInfo(chatId = "t_2", name = "Capital of Romania", kind = "thread", parentChatId = "default", created = 1787232736.0),
|
||||||
|
ChannelInfo(chatId = "t_1", name = "Capital of France", kind = "thread", parentChatId = "default", created = 1787232221.0),
|
||||||
|
ChannelInfo(
|
||||||
|
chatId = "t_9",
|
||||||
|
name = "Test file operations",
|
||||||
|
kind = "thread",
|
||||||
|
parentChatId = "default",
|
||||||
|
created = 1787422924.0,
|
||||||
|
),
|
||||||
|
ChannelInfo(chatId = "t_0", name = "Legacy b", kind = "thread", parentChatId = "default"),
|
||||||
|
ChannelInfo(chatId = "t_0a", name = "Legacy a", kind = "thread", parentChatId = "default"),
|
||||||
|
)
|
||||||
|
val ordered =
|
||||||
|
threads.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
|
||||||
|
assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package iris.protocol
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/** Wire tests for the hello.ack payload (docs/04). */
|
||||||
|
class HelloAckWireTest {
|
||||||
|
@Test
|
||||||
|
fun helloAckDeserializesDeviceToken() {
|
||||||
|
val raw =
|
||||||
|
"""
|
||||||
|
{"v":1,"type":"hello.ack","payload":{"sync_cursor":5,
|
||||||
|
"last_pushed_cursor":3,"device_token":"9f2c64hex"}}
|
||||||
|
""".trimIndent()
|
||||||
|
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||||
|
assertEquals("hello.ack", frame.type)
|
||||||
|
val p = frame.payloadAs<HelloAckPayload>()
|
||||||
|
assertEquals("9f2c64hex", p?.deviceToken)
|
||||||
|
assertEquals(5L, p?.syncCursor)
|
||||||
|
assertEquals(3L, p?.lastPushedCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun helloAckDefaultsDeviceTokenToEmpty() {
|
||||||
|
// Legacy gateways (pre per-device tokens) omit the field entirely.
|
||||||
|
val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}"""
|
||||||
|
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||||
|
val p = frame.payloadAs<HelloAckPayload>()
|
||||||
|
assertEquals("", p?.deviceToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ package iris.util
|
|||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class MarkdownTest {
|
class MarkdownTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun stripsLeadingNewlines() {
|
fun stripsLeadingNewlines() {
|
||||||
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
||||||
@@ -61,4 +61,90 @@ class MarkdownTest {
|
|||||||
val md = "**Title:** x\n**Model:** y"
|
val md = "**Title:** x\n**Model:** y"
|
||||||
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
|
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streamingTransformSkipsLastLineOnly() {
|
||||||
|
// Identical to the static transform except the (still-incomplete)
|
||||||
|
// last line gets no trailing hard-break spaces.
|
||||||
|
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
|
||||||
|
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
|
||||||
|
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
|
||||||
|
val md = "```kotlin\nval a = 1\nval b = 2\n```"
|
||||||
|
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streamingTransformIsPrefixPreserving() {
|
||||||
|
// The core invariant the incremental renderer relies on: for every
|
||||||
|
// prefix p of s, transform(p) is a prefix of transform(s).
|
||||||
|
val docs =
|
||||||
|
listOf(
|
||||||
|
"a\nb",
|
||||||
|
"a\n\nb\nc",
|
||||||
|
"**bold** and `code`",
|
||||||
|
"```kotlin\nval a = 1\n```\nthen text",
|
||||||
|
"~~~\nx\n~~~\ny",
|
||||||
|
"| a | b |\n| - | - |\n| 1 | 2 |",
|
||||||
|
"- item\n- item2",
|
||||||
|
"line with trailing spaces \nnext",
|
||||||
|
"",
|
||||||
|
"\n\n \n",
|
||||||
|
)
|
||||||
|
for (s in docs) {
|
||||||
|
val whole = s.preserveNewlinesAsHardBreaksStreaming()
|
||||||
|
for (i in 0..s.length) {
|
||||||
|
val p = s.take(i)
|
||||||
|
val tp = p.preserveNewlinesAsHardBreaksStreaming()
|
||||||
|
assertTrue(
|
||||||
|
whole.startsWith(tp),
|
||||||
|
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appendChunkReturnsSuffixOnExtension() {
|
||||||
|
assertEquals(" world", appendChunk("Hello", "Hello world"))
|
||||||
|
assertEquals("", appendChunk("abc", "abc"))
|
||||||
|
assertEquals("abc", appendChunk("", "abc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appendChunkReturnsNullOnRewrite() {
|
||||||
|
assertEquals(null, appendChunk("Hello", "Hi"))
|
||||||
|
assertEquals(null, appendChunk("Hello world", "Hello"))
|
||||||
|
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun revealStepRevealsAtLeastOneChar() {
|
||||||
|
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
|
||||||
|
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
|
||||||
|
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun revealStepCatchesUpWhenBacklogIsLarge() {
|
||||||
|
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
|
||||||
|
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||||
|
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||||
|
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
|
||||||
|
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streamCharsPerSecondMapsSmoothnessRange() {
|
||||||
|
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
|
||||||
|
// values are clamped to the ends.
|
||||||
|
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
|
||||||
|
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
|
||||||
|
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
|
||||||
|
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
|
||||||
|
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import iris.protocol.IrisJson
|
|||||||
import iris.ui.theme.Backdrop
|
import iris.ui.theme.Backdrop
|
||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
@@ -27,7 +28,24 @@ class DesktopSecureStore : SecureStore {
|
|||||||
private val baseDir = File(System.getProperty("user.home"), ".iris")
|
private val baseDir = File(System.getProperty("user.home"), ".iris")
|
||||||
private val settingsFile = File(baseDir, "settings.json")
|
private val settingsFile = File(baseDir, "settings.json")
|
||||||
private val legacyFile = File(baseDir, "pairing.json")
|
private val legacyFile = File(baseDir, "pairing.json")
|
||||||
private val secret = SecretBackend(baseDir)
|
private val secret =
|
||||||
|
SecretBackend(
|
||||||
|
baseDir,
|
||||||
|
keyringService = "iris-gateway-token",
|
||||||
|
keyringLabel = "Iris gateway token",
|
||||||
|
keyringAttr = "iris",
|
||||||
|
encFileName = "pairing.enc",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-device token (docs/09 §9.3): a second secret slot, same backends.
|
||||||
|
private val deviceSecret =
|
||||||
|
SecretBackend(
|
||||||
|
baseDir,
|
||||||
|
keyringService = "iris-device-token",
|
||||||
|
keyringLabel = "Iris device token",
|
||||||
|
keyringAttr = "iris-device",
|
||||||
|
encFileName = "device_token.enc",
|
||||||
|
)
|
||||||
|
|
||||||
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
|
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
|
||||||
// connect attempt) don't re-read + re-parse the file on every access.
|
// connect attempt) don't re-read + re-parse the file on every access.
|
||||||
@@ -47,6 +65,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
val threadsEnabled: Boolean = false,
|
val threadsEnabled: Boolean = false,
|
||||||
val toolDetail: String = "truncated",
|
val toolDetail: String = "truncated",
|
||||||
val streamingEnabled: Boolean = true,
|
val streamingEnabled: Boolean = true,
|
||||||
|
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||||
val reasoningAutoCollapse: Boolean = true,
|
val reasoningAutoCollapse: Boolean = true,
|
||||||
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
||||||
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
||||||
@@ -56,6 +75,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
val fontSizeScale: Float = 1.0f,
|
val fontSizeScale: Float = 1.0f,
|
||||||
val runtimeFooterEnabled: Boolean = false,
|
val runtimeFooterEnabled: Boolean = false,
|
||||||
val runtimeFooterFields: String = "",
|
val runtimeFooterFields: String = "",
|
||||||
|
val pinnedCertFingerprint: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -124,6 +144,12 @@ class DesktopSecureStore : SecureStore {
|
|||||||
if (value.isBlank()) secret.clear() else secret.write(value.trim())
|
if (value.isBlank()) secret.clear() else secret.write(value.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var deviceToken: String
|
||||||
|
get() = deviceSecret.read().orEmpty()
|
||||||
|
set(value) {
|
||||||
|
if (value.isBlank()) deviceSecret.clear() else deviceSecret.write(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
override val deviceId: String
|
override val deviceId: String
|
||||||
get() {
|
get() {
|
||||||
val d = load()
|
val d = load()
|
||||||
@@ -198,6 +224,13 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(streamingEnabled = value))
|
save(d.copy(streamingEnabled = value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var streamSmoothness: Float
|
||||||
|
get() = load().streamSmoothness
|
||||||
|
set(value) {
|
||||||
|
val d = load()
|
||||||
|
save(d.copy(streamSmoothness = value))
|
||||||
|
}
|
||||||
|
|
||||||
override var reasoningAutoCollapse: Boolean
|
override var reasoningAutoCollapse: Boolean
|
||||||
get() = load().reasoningAutoCollapse
|
get() = load().reasoningAutoCollapse
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -261,6 +294,13 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(runtimeFooterFields = value))
|
save(d.copy(runtimeFooterFields = value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var pinnedCertFingerprint: String
|
||||||
|
get() = load().pinnedCertFingerprint
|
||||||
|
set(value) {
|
||||||
|
val d = load()
|
||||||
|
save(d.copy(pinnedCertFingerprint = value.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
override fun savePairing(
|
override fun savePairing(
|
||||||
url: String,
|
url: String,
|
||||||
token: String,
|
token: String,
|
||||||
@@ -268,6 +308,9 @@ class DesktopSecureStore : SecureStore {
|
|||||||
val d = load()
|
val d = load()
|
||||||
save(d.copy(serverUrl = url.trim()))
|
save(d.copy(serverUrl = url.trim()))
|
||||||
if (token.isBlank()) secret.clear() else secret.write(token.trim())
|
if (token.isBlank()) secret.clear() else secret.write(token.trim())
|
||||||
|
// A (re-)pair may target a different gateway: the old per-device
|
||||||
|
// token is dead there. The next hello.ack re-mints/returns it.
|
||||||
|
deviceSecret.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clear() {
|
override fun clear() {
|
||||||
@@ -285,9 +328,11 @@ class DesktopSecureStore : SecureStore {
|
|||||||
ntfyTopic = "",
|
ntfyTopic = "",
|
||||||
ntfyServer = "",
|
ntfyServer = "",
|
||||||
pushBackend = "",
|
pushBackend = "",
|
||||||
|
pinnedCertFingerprint = "",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
secret.clear()
|
secret.clear()
|
||||||
|
deviceSecret.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,13 +351,21 @@ private data class PairingData(
|
|||||||
/**
|
/**
|
||||||
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
|
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
|
||||||
* All backend failures degrade to the encrypted file (never plaintext).
|
* All backend failures degrade to the encrypted file (never plaintext).
|
||||||
|
*
|
||||||
|
* Parameterized so the shared gateway token and the per-device token
|
||||||
|
* (docs/09 §9.3) each get their own keyring entry / encrypted file.
|
||||||
*/
|
*/
|
||||||
private class SecretBackend(
|
private class SecretBackend(
|
||||||
private val baseDir: File,
|
private val baseDir: File,
|
||||||
|
private val keyringService: String,
|
||||||
|
private val keyringLabel: String,
|
||||||
|
private val keyringAttr: String,
|
||||||
|
encFileName: String,
|
||||||
) {
|
) {
|
||||||
private val encFile = File(baseDir, "pairing.enc")
|
private val encFile = File(baseDir, encFileName)
|
||||||
private val keyFile = File(baseDir, ".key")
|
private val keyFile = File(baseDir, ".key")
|
||||||
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
|
private val keyring: KeyringBackend? =
|
||||||
|
KeyringBackend(keyringService, keyringLabel, keyringAttr).takeIf { it.available }
|
||||||
|
|
||||||
fun read(): String? = keyring?.read() ?: readEncrypted()
|
fun read(): String? = keyring?.read() ?: readEncrypted()
|
||||||
|
|
||||||
@@ -388,7 +441,11 @@ private class SecretBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** OS keyring via the platform CLI (best effort). */
|
/** OS keyring via the platform CLI (best effort). */
|
||||||
private class KeyringBackend {
|
private class KeyringBackend(
|
||||||
|
private val service: String,
|
||||||
|
private val label: String,
|
||||||
|
private val attr: String,
|
||||||
|
) {
|
||||||
private val os = System.getProperty("os.name").lowercase()
|
private val os = System.getProperty("os.name").lowercase()
|
||||||
private val isMac = os.contains("mac")
|
private val isMac = os.contains("mac")
|
||||||
private val isLinux = os.contains("linux")
|
private val isLinux = os.contains("linux")
|
||||||
@@ -407,9 +464,9 @@ private class KeyringBackend {
|
|||||||
fun read(): String? =
|
fun read(): String? =
|
||||||
try {
|
try {
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
out(listOf("security", "find-generic-password", "-a", "iris", "-s", service, "-w"))
|
||||||
} else {
|
} else {
|
||||||
out(listOf("secret-tool", "lookup", "app", "iris"))
|
out(listOf("secret-tool", "lookup", "app", attr))
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
@@ -430,11 +487,11 @@ private class KeyringBackend {
|
|||||||
"-a",
|
"-a",
|
||||||
"iris",
|
"iris",
|
||||||
"-s",
|
"-s",
|
||||||
"iris-gateway-token",
|
service,
|
||||||
"-w",
|
"-w",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris")
|
listOf("secret-tool", "store", "--label=$label", "app", attr)
|
||||||
}
|
}
|
||||||
ProcessBuilder(cmd).start().apply {
|
ProcessBuilder(cmd).start().apply {
|
||||||
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
|
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
|
||||||
@@ -453,14 +510,14 @@ private class KeyringBackend {
|
|||||||
"-a",
|
"-a",
|
||||||
"iris",
|
"iris",
|
||||||
"-s",
|
"-s",
|
||||||
"iris-gateway-token",
|
service,
|
||||||
).inheritIO().start().waitFor()
|
).inheritIO().start().waitFor()
|
||||||
} else {
|
} else {
|
||||||
ProcessBuilder(
|
ProcessBuilder(
|
||||||
"secret-tool",
|
"secret-tool",
|
||||||
"clear",
|
"clear",
|
||||||
"app",
|
"app",
|
||||||
"iris",
|
attr,
|
||||||
).inheritIO().start().waitFor()
|
).inheritIO().start().waitFor()
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
+3
-3
@@ -11,8 +11,8 @@ create.
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- **Native feel.** Real Android app (Kotlin/Compose), not a WebView. Desktop
|
- **Native feel.** Real native app (Iris on Android, Kotlin/Compose), not a
|
||||||
app that is the same app, resized for a big screen.
|
WebView. Desktop app that is the same app, resized for a big screen.
|
||||||
- **First-class gateway citizen.** The app is a hermes *messaging platform*, so
|
- **First-class gateway citizen.** The app is a hermes *messaging platform*, so
|
||||||
everything the gateway already does "just works": slash commands, cron
|
everything the gateway already does "just works": slash commands, cron
|
||||||
delivery, `send_message` routing, coexistence with Telegram/Discord/etc.
|
delivery, `send_message` routing, coexistence with Telegram/Discord/etc.
|
||||||
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
|
|||||||
| Decision | Choice |
|
| Decision | Choice |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
|
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
|
||||||
| Push backend | **Both** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) |
|
| Push backend | **Both** — ntfy default, FCM optional (`IRIS_PUSH_BACKEND`) |
|
||||||
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
|
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
|
||||||
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
|
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
|
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
|
||||||
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
|
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
|
||||||
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
|
│ │ │ IRIS PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
|
||||||
│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
|
│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
|
||||||
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
|
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
|
||||||
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
|
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
│ │ Google FCM cloud │
|
│ │ Google FCM cloud │
|
||||||
▼ │ │ │
|
▼ │ │ │
|
||||||
┌────────────────────────┐ │ ▼ │
|
┌────────────────────────┐ │ ▼ │
|
||||||
│ ANDROID APP │◄──┴── (wake) ┌──────────┐
|
│ IRIS APP (ANDROID) │◄──┴── (wake) ┌──────────┐
|
||||||
│ (Kotlin / Compose) │ WSS │ PHONE │
|
│ (Kotlin / Compose) │ WSS │ PHONE │
|
||||||
│ • WS client (OkHttp) │◄───────────►│ MIX 2S │
|
│ • WS client (OkHttp) │◄───────────►│ MIX 2S │
|
||||||
│ • ExoPlayer │ │ (API 29) │
|
│ • ExoPlayer │ │ (API 29) │
|
||||||
@@ -80,7 +80,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
|
|||||||
| **Reasoning split in the adapter** | The gateway prepends reasoning to the final text when `show_reasoning` is on; the adapter splits the stable prefix into a `reasoning` field so the app renders a clean collapsible block. |
|
| **Reasoning split in the adapter** | The gateway prepends reasoning to the final text when `show_reasoning` is on; the adapter splits the stable prefix into a `reasoning` field so the app renders a clean collapsible block. |
|
||||||
| **Channels/threads = `chat_id`/`thread_id`** | The gateway's `SessionSource` already models this; cron delivery already targets `platform:chat_id:thread_id`. We map app concepts onto existing primitives. |
|
| **Channels/threads = `chat_id`/`thread_id`** | The gateway's `SessionSource` already models this; cron delivery already targets `platform:chat_id:thread_id`. We map app concepts onto existing primitives. |
|
||||||
| **SQLite outbox + sync cursor** | Offline delivery + reconnect catch-up without re-reading full history. |
|
| **SQLite outbox + sync cursor** | Offline delivery + reconnect catch-up without re-reading full history. |
|
||||||
| **Compose Multiplatform** | Desktop is "the Android app, tweaked" → share protocol/state/UI; only platform services + layout differ. |
|
| **Compose Multiplatform** | Desktop is "the Iris app, tweaked" → share protocol/state/UI; only platform services + layout differ. |
|
||||||
|
|
||||||
## Data flow (one turn)
|
## Data flow (one turn)
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -50,6 +50,7 @@ iris_x_hermes/
|
|||||||
## Module responsibilities
|
## Module responsibilities
|
||||||
|
|
||||||
### `gateway-plugin/` (Python)
|
### `gateway-plugin/` (Python)
|
||||||
|
|
||||||
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
|
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
|
||||||
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
|
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
|
||||||
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
|
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
|
||||||
@@ -61,13 +62,14 @@ iris_x_hermes/
|
|||||||
- **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound
|
- **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound
|
||||||
`media.offer`/`media.pull` chunked streaming.
|
`media.offer`/`media.pull` chunked streaming.
|
||||||
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
|
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
|
||||||
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
|
- **`push.py`** — `PushBackend` interface; `NtfyBackend` (default) and
|
||||||
`NtfyBackend` (reuses hermes ntfy publish). Selected by `IRIS_PUSH_BACKEND`.
|
`FcmBackend` (httpx, FCM HTTP v1). Selected by `IRIS_PUSH_BACKEND`.
|
||||||
- **`pairing.py`** — token generation/verification (constant-time), device
|
- **`pairing.py`** — token generation/verification (constant-time), device
|
||||||
registry (SQLite), QR payload.
|
registry (SQLite), QR payload.
|
||||||
- **`search.py`** — FTS5 query bridge over the hermes session store.
|
- **`search.py`** — FTS5 query bridge over the hermes session store.
|
||||||
|
|
||||||
### `app/shared` (Kotlin KMP)
|
### `app/shared` (Kotlin KMP)
|
||||||
|
|
||||||
- **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient`
|
- **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient`
|
||||||
(OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI
|
(OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI
|
||||||
(design system, screens). ~80% of app code.
|
(design system, screens). ~80% of app code.
|
||||||
@@ -77,6 +79,7 @@ iris_x_hermes/
|
|||||||
window management, `MediaPlayer` actual.
|
window management, `MediaPlayer` actual.
|
||||||
|
|
||||||
### `app/androidApp` / `app/desktopApp`
|
### `app/androidApp` / `app/desktopApp`
|
||||||
|
|
||||||
Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
|
Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
|
||||||
(Desktop). They compose the `shared` UI and inject platform services.
|
(Desktop). They compose the `shared` UI and inject platform services.
|
||||||
|
|
||||||
|
|||||||
+41
-41
@@ -12,7 +12,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: iris-platform
|
name: iris-platform
|
||||||
label: Android
|
label: Iris
|
||||||
kind: platform
|
kind: platform
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
description: >
|
description: >
|
||||||
@@ -24,18 +24,18 @@ author: <you>
|
|||||||
requires_env:
|
requires_env:
|
||||||
- name: IRIS_TOKEN
|
- name: IRIS_TOKEN
|
||||||
description: "Shared pairing token the app presents on connect"
|
description: "Shared pairing token the app presents on connect"
|
||||||
prompt: "Android pairing token"
|
prompt: "Iris pairing token"
|
||||||
password: true
|
password: true
|
||||||
optional_env:
|
optional_env:
|
||||||
- name: IRIS_WS_HOST
|
- name: IRIS_HTTP_HOST
|
||||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||||
prompt: "WS host"
|
prompt: "HTTP host"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_PORT
|
- name: IRIS_HTTP_PORT
|
||||||
description: "WS port (default 8790)"
|
description: "HTTP port (default 8791)"
|
||||||
prompt: "WS port"
|
prompt: "HTTP port"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_HOME_CHANNEL
|
- name: IRIS_HOME_CHANNEL
|
||||||
description: "Default chat id for cron/notification delivery (default default)"
|
description: "Default chat id for cron/notification delivery (default default)"
|
||||||
prompt: "Home channel"
|
prompt: "Home channel"
|
||||||
password: false
|
password: false
|
||||||
@@ -48,7 +48,7 @@ optional_env:
|
|||||||
prompt: "Allow all devices? (true/false)"
|
prompt: "Allow all devices? (true/false)"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_PUSH_BACKEND
|
- name: IRIS_PUSH_BACKEND
|
||||||
description: "Push backend: fcm (default) or ntfy"
|
description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
|
||||||
prompt: "Push backend"
|
prompt: "Push backend"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_FCM_SERVICE_ACCOUNT
|
- name: IRIS_FCM_SERVICE_ACCOUNT
|
||||||
@@ -67,13 +67,13 @@ optional_env:
|
|||||||
description: "ntfy server URL (default https://ntfy.sh)"
|
description: "ntfy server URL (default https://ntfy.sh)"
|
||||||
prompt: "ntfy server URL"
|
prompt: "ntfy server URL"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_CERT
|
- name: IRIS_HTTP_CERT
|
||||||
description: "TLS cert path for WSS (optional)"
|
description: "TLS cert path for HTTPS (optional)"
|
||||||
prompt: "WSS cert"
|
prompt: "HTTPS cert"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_KEY
|
- name: IRIS_HTTP_KEY
|
||||||
description: "TLS key path for WSS (optional)"
|
description: "TLS key path for HTTPS (optional)"
|
||||||
prompt: "WSS key"
|
prompt: "HTTPS key"
|
||||||
password: false
|
password: false
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ def register(ctx):
|
|||||||
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
||||||
setup_fn=interactive_setup, # hermes gateway setup flow
|
setup_fn=interactive_setup, # hermes gateway setup flow
|
||||||
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
|
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
|
||||||
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
cron_deliver_env_var="IRIS_HOME_CHANNEL",
|
||||||
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
|
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
|
||||||
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
|
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
|
||||||
allowed_users_env="IRIS_ALLOWED_USERS",
|
allowed_users_env="IRIS_ALLOWED_USERS",
|
||||||
@@ -215,27 +215,26 @@ The adapter tracks per-chat **turn state** (in-turn, current streaming
|
|||||||
`message` vs `tool.*` vs `commentary`. The exact classification markers are
|
`message` vs `tool.*` vs `commentary`. The exact classification markers are
|
||||||
verified empirically in M2 (see `13-testing.md`).
|
verified empirically in M2 (see `13-testing.md`).
|
||||||
|
|
||||||
## 3.4 WebSocket server (`ws_server.py`)
|
## 3.4 HTTP server (`http_server.py`)
|
||||||
|
|
||||||
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host,
|
- Library: **stdlib `http.server`** (`ThreadingHTTPServer` +
|
||||||
port, ssl=ctx)`.
|
`BaseHTTPRequestHandler`) in a daemon thread; bridges into the gateway's
|
||||||
- **Handler** per connection:
|
asyncio loop via `asyncio.run_coroutine_threadsafe`. Optional TLS via
|
||||||
1. Await first frame; must be `hello {token, device_id, device_name, caps,
|
`ssl.SSLContext` (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`). Full design:
|
||||||
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
|
`19-http-fallback-transport.md`.
|
||||||
`error {code:"auth"}` and close.
|
- **Auth:** `Authorization: Bearer <token>` (constant-time `verify_token`)
|
||||||
2. On success: register in connection registry
|
- device allowlist via `X-Iris-Device`; `401` on failure.
|
||||||
(`device_id → {ws, caps, fcm_token}`), send
|
- **Endpoints:** `GET /v1/health` (unauthenticated liveness),
|
||||||
`hello.ack {server_caps, sync_cursor, channels[]}`.
|
`POST /v1/frame` (any JSON frame the protocol accepts),
|
||||||
3. Loop: decode frames, dispatch to adapter inbound handlers.
|
`GET /v1/events?cursor=N` (SSE: outbox catch-up + live frames),
|
||||||
4. On close: deregister; if no devices remain, ensure pending outbox
|
`GET /v1/poll?cursor=N` (long-poll fallback), `POST /v1/media` +
|
||||||
frames have push fired.
|
`GET /v1/media/{id}` (media upload/pull).
|
||||||
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
||||||
devices (no per-chat subscribe; single-user model). Global frames
|
devices (no per-chat subscribe; single-user model). Global frames
|
||||||
(`channel.*`, `status`) also broadcast to all.
|
(`channel.*`, `status`) also broadcast to all.
|
||||||
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
|
- **Limits:** 64 KiB request body cap, per-device token-bucket rate limit
|
||||||
- **Backpressure:** per-connection send queue with a bounded buffer; drop
|
(20/s, burst 40) → `429`; media uploads bounded by the per-upload total
|
||||||
`message.update` (coalesce to latest) under pressure, never drop
|
cap. No CORS (app clients only).
|
||||||
`message`/`tool.end`/`notification`.
|
|
||||||
|
|
||||||
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
|
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
|
||||||
|
|
||||||
@@ -253,18 +252,19 @@ verified empirically in M2 (see `13-testing.md`).
|
|||||||
## 3.6 Config resolution
|
## 3.6 Config resolution
|
||||||
|
|
||||||
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
|
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
|
||||||
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
`IRIS_FCM_SERVER_KEY`, `IRIS_HTTP_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
||||||
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
|
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
|
||||||
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
`http_port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
||||||
`max_upload_bytes`, `tls`.
|
`max_upload_bytes`, `http_cert`/`http_key`.
|
||||||
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
||||||
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
||||||
so multiplexed profiles don't leak each other's tokens.
|
so multiplexed profiles don't leak each other's tokens.
|
||||||
|
|
||||||
## 3.7 Failure & lifecycle safety
|
## 3.7 Failure & lifecycle safety
|
||||||
|
|
||||||
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
|
- HTTP server bind failure → non-fatal: log a warning, disable the HTTP leg,
|
||||||
- All outbound sends are best-effort; a dead socket latches and the frame falls
|
show it in the inspector (the plugin keeps working for other platforms).
|
||||||
to the outbox.
|
- All outbound sends are best-effort; a dead stream latches and the frame
|
||||||
- `disconnect()` cancels the server task and closes sockets cleanly.
|
falls to the outbox.
|
||||||
|
- `disconnect()` stops the HTTP server and closes streams cleanly.
|
||||||
- Token/PII redaction in all logs (hermes PII policy).
|
- Token/PII redaction in all logs (hermes PII policy).
|
||||||
@@ -40,18 +40,34 @@ Pairing succeeded.
|
|||||||
```json
|
```json
|
||||||
{"type":"hello.ack","payload":{
|
{"type":"hello.ack","payload":{
|
||||||
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
|
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
|
||||||
"search":true,"push":"fcm","pickers":true},
|
"search":true,"push":"fcm","pickers":true,
|
||||||
|
"app_version":"0.1.2"},
|
||||||
"sync_cursor":1042,
|
"sync_cursor":1042,
|
||||||
"last_pushed_cursor":1040,
|
"last_pushed_cursor":1040,
|
||||||
|
"device_token":"9f2c…(64 hex)",
|
||||||
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
|
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`server_caps.app_version` is the gateway plugin's release version (the
|
||||||
|
repo-root `VERSION` file, `gateway-plugin/version.py`). The app shows it in
|
||||||
|
Settings → About (next to its own version) and hints when the app and
|
||||||
|
gateway versions differ.
|
||||||
|
The app reports its own version on the SSE open via the
|
||||||
|
`X-Iris-App-Version` header (stored in the device registry's `caps` JSON,
|
||||||
|
visible in `~/.hermes/.../devices.db`).
|
||||||
|
|
||||||
`last_pushed_cursor` is the highest outbox cursor already delivered to THIS
|
`last_pushed_cursor` is the highest outbox cursor already delivered to THIS
|
||||||
device via the push backend (0 = never). The app skips system notifications
|
device via the push backend (0 = never). The app skips system notifications
|
||||||
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
|
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
|
||||||
woke the device via push (dedupe, `08-push.md` §8.7).
|
woke the device via push (dedupe, `08-push.md` §8.7).
|
||||||
|
|
||||||
|
`device_token` is the per-device token minted at pairing (docs/09 §9.3):
|
||||||
|
the app stores it and presents it in the `Authorization` header INSTEAD of
|
||||||
|
the shared `IRIS_TOKEN` from then on, so the gateway can revoke one device
|
||||||
|
without affecting the others. Empty when the gateway didn't issue one
|
||||||
|
(legacy).
|
||||||
|
|
||||||
### `message`
|
### `message`
|
||||||
|
|
||||||
A final / standalone message.
|
A final / standalone message.
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ produced by the gateway and rendered by the app.
|
|||||||
**Gateway side.** The agent's `stream_delta_callback` feeds a
|
**Gateway side.** The agent's `stream_delta_callback` feeds a
|
||||||
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
|
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
|
||||||
accumulates text and, at intervals / thresholds, calls:
|
accumulates text and, at intervals / thresholds, calls:
|
||||||
|
|
||||||
- `adapter.send(chat_id, text)` — first time a bubble is created.
|
- `adapter.send(chat_id, text)` — first time a bubble is created.
|
||||||
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
|
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
|
||||||
(each carries the **full** accumulated text).
|
(each carries the **full** accumulated text).
|
||||||
|
|
||||||
**Adapter → frames.**
|
**Adapter → frames.**
|
||||||
|
|
||||||
- First `send()` of a turn segment → `message.start {message_id, role}`.
|
- First `send()` of a turn segment → `message.start {message_id, role}`.
|
||||||
- Each `edit_message()` → `message.update {message_id, text}` (full text).
|
- Each `edit_message()` → `message.update {message_id, text}` (full text).
|
||||||
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?,
|
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?,
|
||||||
@@ -27,7 +29,44 @@ replace the bubble text (cheap: it's a full snapshot). On `message.stop`,
|
|||||||
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
|
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
|
||||||
while the user is at the bottom.
|
while the user is at the bottom.
|
||||||
|
|
||||||
|
**Smooth streaming (app-side rendering).** Re-parsing + re-laying-out the
|
||||||
|
whole bubble on every update made streamed text unreadable (raw-markdown
|
||||||
|
flashes, constant reflow). `MarkdownText` therefore renders streaming bubbles
|
||||||
|
incrementally:
|
||||||
|
|
||||||
|
- **Reveal (typewriter):** the latest full snapshot is revealed at a steady
|
||||||
|
rate instead of jumping per gateway update. The rate is user-adjustable:
|
||||||
|
Settings → "Streaming speed" (`streamSmoothness`, 0.2–0.8 s between visible
|
||||||
|
updates, lower = faster; `streamCharsPerSecond` maps it to chars/s:
|
||||||
|
`60 / smoothness`, so 0.2 → 300 chars/s, 0.8 → 75 chars/s). Applied on the
|
||||||
|
fly; a backlog exceeding ~2 s of reveal time is revealed at once (fast
|
||||||
|
model / reconnect catch-up).
|
||||||
|
- **Wait for the reveal:** the streaming renderer stays active until the
|
||||||
|
reveal catches up, even after `message.stop` — a fast model that dumps the
|
||||||
|
whole text in one or two frames still plays out the typewriter instead of
|
||||||
|
jumping to the full message. Only then does the bubble switch to the static
|
||||||
|
renderer (which also enables the HTML artifact card).
|
||||||
|
- **Incremental parse:** the revealed prefix is fed as append chunks
|
||||||
|
(`appendChunk`) into the library's `StreamingMarkdownState`
|
||||||
|
(`rememberStreamingMarkdownState`, mikepenz 0.44.0) — an append-only parser
|
||||||
|
that re-parses only the unstable tail. Settled blocks keep AST identity, so
|
||||||
|
Compose never re-lays them out; only the tail re-renders per tick. A
|
||||||
|
non-extension snapshot (rewrite) recreates the parser state and re-seeds it.
|
||||||
|
- **Prefix-preserving transform:** `preserveNewlinesAsHardBreaksStreaming()`
|
||||||
|
is like `preserveNewlinesAsHardBreaks()` but skips the still-incomplete last
|
||||||
|
line, so the transform of a prefix is always a prefix of the transform of
|
||||||
|
the whole (required for pure-append diffs). The static path keeps the
|
||||||
|
original transform plus `retainState = true` (last formatted output stays
|
||||||
|
visible during re-parses — no raw flash).
|
||||||
|
- **Cursor:** the ▉ is appended to the last text leaf by the annotator (not to
|
||||||
|
the parse input, which would break the append diff).
|
||||||
|
|
||||||
|
The gateway cadence (`edit_interval` / `buffer_threshold`, default 0.8 s /
|
||||||
|
24 chars) is unchanged — smoothing happens entirely client-side, so it works
|
||||||
|
with any gateway and per device.
|
||||||
|
|
||||||
**Streaming on/off.** Two levels:
|
**Streaming on/off.** Two levels:
|
||||||
|
|
||||||
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
|
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
|
||||||
follows global). When off, the app just gets one final `message` frame.
|
follows global). When off, the app just gets one final `message` frame.
|
||||||
- **App side (per device):** Settings → "Streaming" toggle (default on). When
|
- **App side (per device):** Settings → "Streaming" toggle (default on). When
|
||||||
@@ -45,11 +84,13 @@ reference screenshot's "Reasoning:" panel with a copy button).
|
|||||||
**Gateway side.** hermes prepends reasoning to the final response when
|
**Gateway side.** hermes prepends reasoning to the final response when
|
||||||
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
|
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
|
||||||
chosen by `reasoning_style` (`gateway/display_config.py:37`):
|
chosen by `reasoning_style` (`gateway/display_config.py:37`):
|
||||||
|
|
||||||
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
|
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
|
||||||
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
||||||
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
||||||
|
|
||||||
**Plugin config.** Set for the `iris` platform:
|
**Plugin config.** Set for the `iris` platform:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
display:
|
display:
|
||||||
platforms:
|
platforms:
|
||||||
@@ -59,12 +100,14 @@ display:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Adapter split.** In `send()`, detect the `code`-style prefix and split:
|
**Adapter split.** In `send()`, detect the `code`-style prefix and split:
|
||||||
|
|
||||||
```
|
```
|
||||||
prefix = "💭 **Reasoning:**\n```\n"
|
prefix = "💭 **Reasoning:**\n```\n"
|
||||||
# find the closing "\n```\n\n" after the prefix
|
# find the closing "\n```\n\n" after the prefix
|
||||||
reasoning = text[len(prefix):close_idx]
|
reasoning = text[len(prefix):close_idx]
|
||||||
body = text[close_idx + len("\n```\n\n"):]
|
body = text[close_idx + len("\n```\n\n"):]
|
||||||
```
|
```
|
||||||
|
|
||||||
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
|
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
|
||||||
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`.
|
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`.
|
||||||
|
|
||||||
@@ -89,6 +132,7 @@ gateway progress queue → `send_progress_messages` (`gateway/run.py:4603`) →
|
|||||||
|
|
||||||
**Adapter → frames.** The adapter classifies tool activity (via turn-state +
|
**Adapter → frames.** The adapter classifies tool activity (via turn-state +
|
||||||
line format) and emits **structured** frames — not pre-formatted strings:
|
line format) and emits **structured** frames — not pre-formatted strings:
|
||||||
|
|
||||||
- `tool.start {index, name, preview, args}` — a tool call began.
|
- `tool.start {index, name, preview, args}` — a tool call began.
|
||||||
- `tool.progress {index, name, note}` — in-progress update (optional).
|
- `tool.progress {index, name, note}` — in-progress update (optional).
|
||||||
- `tool.end {index, name, ok, duration, output_preview}` — completed.
|
- `tool.end {index, name, ok, duration, output_preview}` — completed.
|
||||||
@@ -99,6 +143,7 @@ short tail; full tool output is **not** streamed (it lives in agent history and
|
|||||||
is reachable via search).
|
is reachable via search).
|
||||||
|
|
||||||
**App side — the verbosity setting** (Settings → "Tool detail"):
|
**App side — the verbosity setting** (Settings → "Tool detail"):
|
||||||
|
|
||||||
- **Everything** — show tool name, full args (collapsible), and output preview.
|
- **Everything** — show tool name, full args (collapsible), and output preview.
|
||||||
- **Truncated** (default) — show `emoji name: "short preview"` one-liner,
|
- **Truncated** (default) — show `emoji name: "short preview"` one-liner,
|
||||||
collapsible to expand.
|
collapsible to expand.
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ gateway identity concepts**.
|
|||||||
## 6.2 Default chat
|
## 6.2 Default chat
|
||||||
|
|
||||||
- On first connect, the plugin ensures a **default channel** exists:
|
- On first connect, the plugin ensures a **default channel** exists:
|
||||||
`chat_id = ANDROID_HOME_CHANNEL` (default `default`), `kind=default`,
|
`chat_id = IRIS_HOME_CHANNEL` (default `default`), `kind=default`,
|
||||||
`is_default=true`, name "Default".
|
`is_default=true`, name "Default".
|
||||||
- It is also the **cron home channel** (`cron_deliver_env_var=
|
- It is also the **cron home channel** (`cron_deliver_env_var=
|
||||||
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here.
|
IRIS_HOME_CHANNEL`), so `deliver=iris` (bare) routes here.
|
||||||
- The app opens the default chat on launch.
|
- The app opens the default chat on launch.
|
||||||
|
|
||||||
## 6.3 Threads (toggle for overview)
|
## 6.3 Threads (toggle for overview)
|
||||||
|
|||||||
+10
-4
@@ -1,7 +1,13 @@
|
|||||||
# 08 — Push Notifications, Outbox & Sync
|
# 08 — Push Notifications, Outbox & Sync
|
||||||
|
|
||||||
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: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`).
|
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
|
||||||
|
Privacy: FCM push metadata (notification title, device token) is routed
|
||||||
|
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
|
## 8.1 When push fires
|
||||||
|
|
||||||
@@ -54,9 +60,9 @@ class PushBackend(Protocol):
|
|||||||
def configured(self) -> bool: ...
|
def configured(self) -> bool: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
|
Selected at adapter init by `IRIS_PUSH_BACKEND` (`ntfy` default, `fcm`).
|
||||||
|
|
||||||
### 8.2.1 `FcmBackend` (primary)
|
### 8.2.1 `FcmBackend` (optional; metadata via Google)
|
||||||
|
|
||||||
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
|
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
|
||||||
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
|
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
|
||||||
@@ -74,7 +80,7 @@ Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
|
|||||||
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
|
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
|
||||||
devices).
|
devices).
|
||||||
|
|
||||||
### 8.2.2 `NtfyBackend` (fallback, self-host friendly)
|
### 8.2.2 `NtfyBackend` (default, self-host friendly)
|
||||||
|
|
||||||
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
|
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
|
||||||
- Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
|
- Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
|
||||||
|
|||||||
+76
-29
@@ -27,8 +27,10 @@
|
|||||||
4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
|
4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
|
||||||
(`hmac.compare_digest`). Optionally check `device_id` against
|
(`hmac.compare_digest`). Optionally check `device_id` against
|
||||||
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
|
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
|
||||||
5. **On success:** register the device in `devices.db`, send `hello.ack`.
|
5. **On success:** register the device in `devices.db`, **mint its
|
||||||
**On failure:** send `error {code:"auth"}` and close.
|
per-device token** (if it has none yet) and return it in
|
||||||
|
`hello.ack.device_token`. **On failure:** send `error {code:"auth"}`
|
||||||
|
and close.
|
||||||
|
|
||||||
`device_id` is a stable, app-generated UUID (persisted in the app's
|
`device_id` is a stable, app-generated UUID (persisted in the app's
|
||||||
secure storage). It identifies the device for routing + push, **not** as a
|
secure storage). It identifies the device for routing + push, **not** as a
|
||||||
@@ -36,37 +38,81 @@ security principal (the token is).
|
|||||||
|
|
||||||
## 9.3 Auth model
|
## 9.3 Auth model
|
||||||
|
|
||||||
- **Token = the security principal.** Any connection presenting the valid
|
- **Two tokens, one principal per device.**
|
||||||
`IRIS_TOKEN` is authorized (it's the user's own token).
|
- **Shared `IRIS_TOKEN` (bootstrap):** the setup token from
|
||||||
|
`hermes gateway setup`. It authorizes *pairing* — a NEW device (no row
|
||||||
|
in `devices.db` yet) presents it to connect, and the gateway mints a
|
||||||
|
per-device token for it (returned in `hello.ack.device_token`). It
|
||||||
|
keeps working for devices that never received a per-device token
|
||||||
|
(legacy apps), so an upgrade never bricks a pairing.
|
||||||
|
- **Per-device token (revocable):** minted once at pairing
|
||||||
|
(`DeviceRegistry.issue_token`, 64 hex chars, stored in the `devices`
|
||||||
|
table of `devices.db`). The app stores it in secure storage and
|
||||||
|
presents it INSTEAD of the shared token from the next request on
|
||||||
|
(`Authorization: Bearer <device-token>`). Both tokens are compared in
|
||||||
|
constant time (`verify_token`); a revoked device is rejected before
|
||||||
|
either comparison runs.
|
||||||
|
- **Per-device revocation.** Two control surfaces (run on the gateway host):
|
||||||
|
- **Setup flow** — `hermes gateway setup` → *Iris*: on an existing setup
|
||||||
|
(devices already paired) it asks **"Remove a paired device?"** (default
|
||||||
|
No). If yes: a numbered select menu (name, device id, last seen) whose
|
||||||
|
LAST option is *Exit* (leaves the removal loop, continues the setup);
|
||||||
|
picking a device asks for confirmation, then returns to the menu so
|
||||||
|
several devices can be removed in a row.
|
||||||
|
- **CLI** — `gateway-plugin/tools/iris_devices.py`:
|
||||||
|
- `list` — paired devices (id, name, token minted?, last seen) + revoked ids.
|
||||||
|
- `revoke <device_id>` — drops the device's row (token, push tokens,
|
||||||
|
cursor) AND adds its id to the `revoked` denylist: the device can no
|
||||||
|
longer connect with its device token **or** the shared token, while
|
||||||
|
every other device is unaffected. This is the isolation primitive a
|
||||||
|
shared token alone can't provide (a compromised device can't be cut
|
||||||
|
off without rotating the token for everyone).
|
||||||
|
- `unrevoke <device_id>` — removes it from the denylist so it can pair
|
||||||
|
again (a fresh token is minted at the next pairing).
|
||||||
|
- `reissue <device_id>` — rotates the device's token (the old one stops
|
||||||
|
working; the app picks up the new one on its next (re)connect via
|
||||||
|
`hello.ack`).
|
||||||
|
Re-pairing a revoked device also works by giving the app a fresh
|
||||||
|
`device_id` (e.g. `adb shell pm clear dev.iris.app`), which bootstraps
|
||||||
|
with the shared token like any new device.
|
||||||
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
|
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
|
||||||
`device_id`s) restricts which *devices* may connect even with the token —
|
`device_id`s) restricts which *devices* may connect even with a valid
|
||||||
useful if the token is shared. `IRIS_ALLOW_ALL_USERS=true` disables the
|
token — useful if the shared token is exposed. `IRIS_ALLOW_ALL_USERS=true`
|
||||||
allowlist (dev only).
|
disables the allowlist (dev only).
|
||||||
- **Per-device tokens (stretch):** mint a unique token per device at pairing
|
- **Re-pairing / rotation.** Rotating `IRIS_TOKEN` no longer invalidates
|
||||||
(revocable) instead of one shared token. v1 uses the shared token + optional
|
paired devices: they authenticate with their per-device tokens, which
|
||||||
device allowlist.
|
survive the rotation. Only bootstrap of NEW devices needs the new shared
|
||||||
- **Re-pairing:** rotating `IRIS_TOKEN` invalidates all devices; they must
|
token. (Legacy devices without a per-device token still re-pair, as
|
||||||
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
|
before.)
|
||||||
|
|
||||||
## 9.4 Transport security
|
## 9.4 Transport security
|
||||||
|
|
||||||
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
|
- **Default (LAN/dev):** plain `http://` on the trusted LAN. Fine for a home
|
||||||
network.
|
network.
|
||||||
- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY`
|
- **HTTPS (recommended for remote):** set `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`
|
||||||
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user
|
(self-signed or CA-signed). CA-signed certs work out of the box.
|
||||||
confirms fingerprint on first pair, like a SSH host key).
|
For a **self-signed** cert the app shows its SHA-256 fingerprint on first
|
||||||
|
pair (like a SSH host key); once the user confirms it, the fingerprint is
|
||||||
|
pinned in secure storage (`SecureStore.pinnedCertFingerprint`) and the
|
||||||
|
app's `PinningTrustManager` accepts exactly that certificate from then on
|
||||||
|
(hostname verification still applies). A *changed* certificate fails
|
||||||
|
again with a fresh confirm request — the user must re-confirm, like a
|
||||||
|
changed SSH host key. No system trust-store install needed. Note: the cert
|
||||||
|
must carry a **SAN** for the URL host (OkHttp's hostname verifier rejects
|
||||||
|
CN-only certs even when pinned) — e.g. `openssl req -x509 ... -addext
|
||||||
|
"subjectAltName=DNS:myhost,IP:192.168.1.10"`.
|
||||||
- **Remote reachability options** (documented, user's choice):
|
- **Remote reachability options** (documented, user's choice):
|
||||||
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
|
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
|
||||||
app connects over the private mesh. No public exposure.
|
app connects over the private mesh. No public exposure.
|
||||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
||||||
at the edge, forward WS to `127.0.0.1:8790`.
|
at the edge, forward to `127.0.0.1:8791`.
|
||||||
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
- **Public bind** (`0.0.0.0`) + HTTPS + strong token — last resort.
|
||||||
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
|
- **HTTP transport (docs/19):** the gateway serves the same frames over plain
|
||||||
over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's
|
HTTP (`IRIS_HTTP_PORT`, default 8791) — the only device-facing transport.
|
||||||
fallback transport. It is a *second door with the same lock*: the same
|
It shares the same lock as everything else: the same Bearer token
|
||||||
Bearer token (constant-time `verify_token`) + the same device allowlist
|
(constant-time `verify_token`) + the same device allowlist
|
||||||
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
|
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit.
|
||||||
the WS. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
||||||
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
||||||
not reflect tokens, device ids, or versions).
|
not reflect tokens, device ids, or versions).
|
||||||
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
||||||
@@ -113,14 +159,15 @@ M7 research pass. "verified" = implemented and covered by
|
|||||||
| # | Item | Status | Evidence / mitigation |
|
| # | Item | Status | Evidence / mitigation |
|
||||||
| --- | ------ | -------- | ----------------------- |
|
| --- | ------ | -------- | ----------------------- |
|
||||||
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
|
| 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) |
|
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`http_server.py`, `broadcast`/`send_to`). Inbound: per-device token bucket on JSON frames (20/s, burst 40) → `429` on exceed (`http_server.py`, `_rate_limited`); media uploads bounded by the per-request body cap + per-upload total cap (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` |
|
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | Per-request body cap in `http_server.py`; 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` |
|
| 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 |
|
| 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 (`IRIS_WS_CERT`/`IRIS_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 |
|
| 6 | HTTPS + cert pinning for remote | implemented | TLS supported server-side (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`, `http_server.py`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `http://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
|
||||||
| 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` |
|
| 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 `IRIS_TOKEN`/`IRIS_WS_CERT`/`IRIS_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_HTTP_CERT`/`IRIS_HTTP_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 |
|
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: per-device token bucket in `http_server.py` (`_rate_limited`). Media uploads are bounded by the per-request body cap + 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`) |
|
| 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` |
|
| 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 | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
|
| 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
|
||||||
|
| 13 | Gap: per-device tokens (revocation) | implemented | `DeviceRegistry.issue_token` mints a 64-hex per-device token at pairing (stored in `devices.db`, returned in `hello.ack.device_token`); the app stores it in secure storage and presents it instead of the shared `IRIS_TOKEN` (bootstrap path unchanged). `tools/iris_devices.py revoke <device_id>` drops the device + denylists its id (rejected even with the shared token); `unrevoke`/`reissue` for re-pairing/rotation. `docs/09` §9.3 |
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 10 — Android App (Kotlin + Jetpack Compose)
|
# 10 — Iris App (Android; Kotlin + Jetpack Compose)
|
||||||
|
|
||||||
Native client. Lives in the Compose Multiplatform project at `app/`; the bulk of
|
Native client. Lives in the Compose Multiplatform project at `app/`; the bulk of
|
||||||
the code is in `app/shared` (commonMain) so the Desktop app reuses it.
|
the code is in `app/shared` (commonMain) so the Desktop app reuses it.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 11 — Desktop App (Kotlin + Compose Multiplatform)
|
# 11 — Desktop App (Kotlin + Compose Multiplatform)
|
||||||
|
|
||||||
The desktop app is **the Android app, tweaked for a big screen**. It reuses the
|
The desktop app is **the Iris app, tweaked for a big screen**. It reuses the
|
||||||
entire `app/shared` module (protocol, network, repositories, state, most UI) and
|
entire `app/shared` module (protocol, network, repositories, state, most UI) and
|
||||||
only adds desktop platform services + a wider default layout.
|
only adds desktop platform services + a wider default layout.
|
||||||
|
|
||||||
@@ -62,9 +62,9 @@ only adds desktop platform services + a wider default layout.
|
|||||||
- The desktop app connects to the **same** gateway WS server as the phone (the
|
- The desktop app connects to the **same** gateway WS server as the phone (the
|
||||||
user's home server / Tailscale). It does **not** spawn its own backend (unlike
|
user's home server / Tailscale). It does **not** spawn its own backend (unlike
|
||||||
hermes's existing Electron desktop, which spawns `hermes serve`) — our
|
hermes's existing Electron desktop, which spawns `hermes serve`) — our
|
||||||
desktop is a pure client of the messaging gateway, matching the Android app.
|
desktop is a pure client of the messaging gateway, matching the Iris app.
|
||||||
|
|
||||||
## 11.5 Parity checklist (same functionality as Android)
|
## 11.5 Parity checklist (same functionality as the Iris app)
|
||||||
|
|
||||||
- [ ] Streaming, reasoning block, tool cards (3-level verbosity), commentary.
|
- [ ] Streaming, reasoning block, tool cards (3-level verbosity), commentary.
|
||||||
- [ ] Channels + threads + new channel + cron target.
|
- [ ] Channels + threads + new channel + cron target.
|
||||||
|
|||||||
+31
-19
@@ -9,6 +9,7 @@ First-time setup on a machine (verified baseline: CachyOS/Arch, `pacman`,
|
|||||||
pacman -S jdk17-openjdk
|
pacman -S jdk17-openjdk
|
||||||
java -version # expect 17.x
|
java -version # expect 17.x
|
||||||
```
|
```
|
||||||
|
|
||||||
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the
|
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the
|
||||||
Android toolchain; 21 also works but 17 is the safe floor.)
|
Android toolchain; 21 also works but 17 is the safe floor.)
|
||||||
|
|
||||||
@@ -28,10 +29,12 @@ export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-
|
|||||||
sdkmanager --licenses
|
sdkmanager --licenses
|
||||||
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
|
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide;
|
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide;
|
||||||
`platform-tools` from the SDK is fine too (whichever is first on `PATH`).
|
`platform-tools` from the SDK is fine too (whichever is first on `PATH`).
|
||||||
|
|
||||||
Create `app/local.properties`:
|
Create `app/local.properties`:
|
||||||
|
|
||||||
```
|
```
|
||||||
sdk.dir=/home/<you>/android-sdk
|
sdk.dir=/home/<you>/android-sdk
|
||||||
```
|
```
|
||||||
@@ -39,10 +42,12 @@ sdk.dir=/home/<you>/android-sdk
|
|||||||
## 12.3 Gradle
|
## 12.3 Gradle
|
||||||
|
|
||||||
No system install — use the project wrapper:
|
No system install — use the project wrapper:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app
|
cd app
|
||||||
./gradlew tasks # first run downloads the wrapper distribution
|
./gradlew tasks # first run downloads the wrapper distribution
|
||||||
```
|
```
|
||||||
|
|
||||||
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
|
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
|
||||||
|
|
||||||
## 12.4 hermes environment (for the plugin + running the gateway)
|
## 12.4 hermes environment (for the plugin + running the gateway)
|
||||||
@@ -53,7 +58,9 @@ uv sync # creates .venv with all core deps (websockets, httpx,
|
|||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
hermes --version # sanity
|
hermes --version # sanity
|
||||||
```
|
```
|
||||||
|
|
||||||
- Run the gateway with the plugin:
|
- Run the gateway with the plugin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# install the plugin (dev: symlink)
|
# install the plugin (dev: symlink)
|
||||||
mkdir -p ~/.hermes/plugins
|
mkdir -p ~/.hermes/plugins
|
||||||
@@ -61,7 +68,9 @@ hermes --version # sanity
|
|||||||
hermes gateway status # should list "iris"
|
hermes gateway status # should list "iris"
|
||||||
hermes gateway # run
|
hermes gateway # run
|
||||||
```
|
```
|
||||||
|
|
||||||
- Tests use hermes's hermetic runner (never bare `pytest`):
|
- Tests use hermes's hermetic runner (never bare `pytest`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
```
|
```
|
||||||
@@ -77,24 +86,27 @@ hermes --version # sanity
|
|||||||
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
|
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
|
||||||
registers it via `hello` / `fcm.register`.
|
registers it via `hello` / `fcm.register`.
|
||||||
|
|
||||||
> Skip Firebase → set `IRIS_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
|
> Skip Firebase → the default is already ntfy: leave `IRIS_PUSH_BACKEND` unset
|
||||||
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
|
> (or set it to `ntfy`) and configure `NTFY_TOPIC` / `NTFY_SERVER_URL`
|
||||||
|
> (self-host ntfy or use ntfy.sh). See `08-push.md`.
|
||||||
|
|
||||||
## 12.6 Environment variables (summary)
|
## 12.6 Environment variables (summary)
|
||||||
|
|
||||||
**Secrets (`~/.hermes/.env`):**
|
**Secrets (`~/.hermes/.env`):**
|
||||||
|
|
||||||
```
|
```
|
||||||
IRIS_TOKEN=<64-hex>
|
IRIS_TOKEN=<64-hex>
|
||||||
IRIS_PUSH_BACKEND=fcm # or ntfy
|
IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google)
|
||||||
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
||||||
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
||||||
# NTFY_TOPIC=iris-push # when ntfy
|
# NTFY_TOPIC=iris-push # when ntfy
|
||||||
# NTFY_SERVER_URL=https://ntfy.sh
|
# NTFY_SERVER_URL=https://ntfy.sh
|
||||||
# IRIS_WS_CERT=/path/cert.pem # WSS
|
# IRIS_HTTP_CERT=/path/cert.pem # HTTPS
|
||||||
# IRIS_WS_KEY=/path/key.pem
|
# IRIS_HTTP_KEY=/path/key.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
**Behavioral (`~/.hermes/config.yaml`):**
|
**Behavioral (`~/.hermes/config.yaml`):**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
gateway:
|
gateway:
|
||||||
platforms:
|
platforms:
|
||||||
@@ -102,7 +114,7 @@ gateway:
|
|||||||
enabled: true
|
enabled: true
|
||||||
extra:
|
extra:
|
||||||
host: 127.0.0.1 # 0.0.0.0 for LAN
|
host: 127.0.0.1 # 0.0.0.0 for LAN
|
||||||
port: 8790
|
http_port: 8791
|
||||||
home_channel: default
|
home_channel: default
|
||||||
push_backend: fcm
|
push_backend: fcm
|
||||||
outbox_retention_hours: 72
|
outbox_retention_hours: 72
|
||||||
@@ -120,19 +132,19 @@ display:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. gateway up with plugin
|
# 1. gateway up with plugin
|
||||||
hermes gateway status | grep -i android
|
hermes gateway status | grep -i iris
|
||||||
|
|
||||||
# 2. a raw WS client can pair + echo
|
# 2. the HTTP server answers (unauthenticated liveness)
|
||||||
python - <<'PY'
|
curl -s http://127.0.0.1:8791/v1/health
|
||||||
import asyncio, json, websockets
|
# -> {"ok": true}
|
||||||
async def main():
|
|
||||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
# 3. a frame round-trip with the pairing token
|
||||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
curl -s -X POST http://127.0.0.1:8791/v1/frame \
|
||||||
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
|
-H "Authorization: Bearer <IRIS_TOKEN>" -H "X-Iris-Device: probe" \
|
||||||
"caps":{"min_protocol":1}}}))
|
-H "Content-Type: application/json" \
|
||||||
print("recv:", await ws.recv())
|
-d '{"v":1,"type":"commands.catalog","id":1,"payload":{}}'
|
||||||
asyncio.run(main())
|
|
||||||
PY
|
|
||||||
```
|
```
|
||||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
|
||||||
|
Expect `{"ok": true}` from the health probe and a `commands.catalog` reply
|
||||||
|
frame from the POST. If you get `401`, the token/host/port is
|
||||||
wrong.
|
wrong.
|
||||||
+9
-5
@@ -6,20 +6,22 @@ without the app (critical for verifying frame shapes early).
|
|||||||
|
|
||||||
## 13.1 Python plugin tests
|
## 13.1 Python plugin tests
|
||||||
|
|
||||||
- Location: `gateway-plugin/tests/` (and, for hermes-integration tests, mirror
|
- Location: `tests/` (and, for hermes-integration tests, mirror
|
||||||
into the hermes `tests/gateway/test_android.py` pattern when running under
|
into the hermes `tests/gateway/test_android.py` pattern when running under
|
||||||
hermes's suite).
|
hermes's suite).
|
||||||
- **Run with hermes's hermetic runner** (never bare `pytest`):
|
- **Run with hermes's hermetic runner** (never bare `pytest`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
scripts/run_tests.sh # full suite (CI parity)
|
scripts/run_tests.sh # full suite (CI parity)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Coverage to write (behavioral, not change-detector — per hermes test policy):
|
- Coverage to write (behavioral, not change-detector — per hermes test policy):
|
||||||
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
|
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
|
||||||
parse_target_ref).
|
parse_target_ref).
|
||||||
- `check_requirements` / `validate_config` / `is_connected` truth table.
|
- `check_requirements` / `validate_config` / `is_connected` truth table.
|
||||||
- `_parse_target_ref`: `iris:<chat>`, `iris:<chat>:<thread>`, non-android
|
- `_parse_target_ref`: `iris:<chat>`, `iris:<chat>:<thread>`, non-iris
|
||||||
→ None.
|
→ None.
|
||||||
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
|
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
|
||||||
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
|
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
|
||||||
@@ -46,19 +48,20 @@ without the app (critical for verifying frame shapes early).
|
|||||||
|
|
||||||
## 13.2 WS test-client harness (do this FIRST, in M1/M2)
|
## 13.2 WS test-client harness (do this FIRST, in M1/M2)
|
||||||
|
|
||||||
A small Python script (`gateway-plugin/tests/ws_probe.py`) that connects to the
|
A small Python script (`tests/ws_probe.py`) that connects to the
|
||||||
**real running gateway** and drives a turn, printing every frame. This is how we
|
**real running gateway** and drives a turn, printing every frame. This is how we
|
||||||
**empirically confirm** the exact frame shapes (especially tool-progress vs
|
**empirically confirm** the exact frame shapes (especially tool-progress vs
|
||||||
commentary classification and the reasoning prefix) before/while building the
|
commentary classification and the reasoning prefix) before/while building the
|
||||||
Kotlin client.
|
Kotlin client.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hermes gateway & # with the android plugin
|
hermes gateway & # with the iris plugin
|
||||||
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
|
python tests/ws_probe.py --token <IRIS_TOKEN> \
|
||||||
--send "list the files and summarize"
|
--send "list the files and summarize"
|
||||||
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
|
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
|
||||||
# commentary, message.stop {reasoning,…}, …
|
# commentary, message.stop {reasoning,…}, …
|
||||||
```
|
```
|
||||||
|
|
||||||
Use it to lock `04-wire-protocol.md` against reality and to debug the adapter
|
Use it to lock `04-wire-protocol.md` against reality and to debug the adapter
|
||||||
without waiting for the app.
|
without waiting for the app.
|
||||||
|
|
||||||
@@ -102,6 +105,7 @@ adb logcat -d > /tmp/logcat.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
**E2E scenarios (script where possible):**
|
**E2E scenarios (script where possible):**
|
||||||
|
|
||||||
1. **Pair:** connect screen → enter URL+token → `hello.ack` → main. (Verify auth
|
1. **Pair:** connect screen → enter URL+token → `hello.ack` → main. (Verify auth
|
||||||
leg, not just TCP.)
|
leg, not just TCP.)
|
||||||
2. **Text round-trip:** send "hello" → streamed reply appears (message.start →
|
2. **Text round-trip:** send "hello" → streamed reply appears (message.start →
|
||||||
|
|||||||
@@ -161,11 +161,11 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
|
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
|
||||||
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
|
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
|
||||||
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
|
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
|
||||||
- [ ] Parity pass vs Android feature checklist (`11-desktop-app.md`).
|
- [ ] Parity pass vs Iris app feature checklist (`11-desktop-app.md`).
|
||||||
- [x] jpackage builds (Linux first; macOS/Windows as available).
|
- [x] jpackage builds (Linux first; macOS/Windows as available).
|
||||||
- **Demo:** desktop app pairs to the same gateway; full feature parity; tray
|
- **Demo:** desktop app pairs to the same gateway; full feature parity; tray
|
||||||
notifications; media plays.
|
notifications; media plays.
|
||||||
- **Accept:** all Android features work on desktop; tray + shortcuts work;
|
- **Accept:** all Iris app features work on desktop; tray + shortcuts work;
|
||||||
native binary launches.
|
native binary launches.
|
||||||
- **Status (complete, 2026-08-19):** All `desktopMain` actuals implemented and
|
- **Status (complete, 2026-08-19):** All `desktopMain` actuals implemented and
|
||||||
verified on Linux (X11). `DesktopSecureStore`: non-secrets in
|
verified on Linux (X11). `DesktopSecureStore`: non-secrets in
|
||||||
@@ -274,7 +274,7 @@ in-app scanner and `iris://pair` deep link (closes gap #12, `docs/20`).
|
|||||||
build it in M1.
|
build it in M1.
|
||||||
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
|
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
|
||||||
extend for push in M5.
|
extend for push in M5.
|
||||||
- **M6 (desktop) reuses M1–M5 shared code** — do it after the Android features
|
- **M6 (desktop) reuses M1–M5 shared code** — do it after the Iris app features
|
||||||
are stable so the shared module is settled.
|
are stable so the shared module is settled.
|
||||||
- Parallelizable: plugin (Python) and app (Kotlin) can be worked on
|
- Parallelizable: plugin (Python) and app (Kotlin) can be worked on
|
||||||
concurrently once the protocol (`04-wire-protocol.md`) is agreed; the probe
|
concurrently once the protocol (`04-wire-protocol.md`) is agreed; the probe
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
| # | Decision | Choice | Rationale |
|
| # | Decision | Choice | Rationale |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. |
|
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Iris app, tweaked"; share protocol/state/UI. |
|
||||||
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `IRIS_PUSH_BACKEND`. |
|
| 2 | Push backend | **Both — ntfy default, FCM optional** (issue #10: FCM metadata — title, device token — is routed via Google's servers, contradicting the privacy claim; ntfy keeps it on your own infrastructure) | `IRIS_PUSH_BACKEND`. |
|
||||||
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
|
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
|
||||||
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
|
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ will proceed with unless you say otherwise.
|
|||||||
live gateway. If a clean metadata marker exists, prefer it.
|
live gateway. If a clean metadata marker exists, prefer it.
|
||||||
2. **Reasoning prefix format stability (M2).** We split on the `code`-style
|
2. **Reasoning prefix format stability (M2).** We split on the `code`-style
|
||||||
`💭 **Reasoning:**\n```\n…\n```\n\n` prefix. *Default:* set
|
`💭 **Reasoning:**\n```\n…\n```\n\n` prefix. *Default:* set
|
||||||
`reasoning_style: code` for android and split on that; fallback = no
|
`reasoning_style: code` for iris and split on that; fallback = no
|
||||||
reasoning field (full text) if the prefix isn't found. Verify in M2.
|
reasoning field (full text) if the prefix isn't found. Verify in M2.
|
||||||
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
|
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
|
||||||
`IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist.
|
`IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 18 — Code Review & Lint/LSP Cleanup (alpha → stable)
|
# 18 — Code Review & Lint/LSP Cleanup (alpha → stable)
|
||||||
|
|
||||||
Comprehensive review of all three components — **gateway plugin**, **Android
|
Comprehensive review of all three components — **gateway plugin**, **Iris app
|
||||||
app**, and **Desktop app** — performed to take the project from alpha to a
|
(Android)**, and **Desktop app** — performed to take the project from alpha to a
|
||||||
clean, stable baseline. Each section records what was found, what was fixed,
|
clean, stable baseline. Each section records what was found, what was fixed,
|
||||||
how it was verified, and what was deliberately left (with rationale).
|
how it was verified, and what was deliberately left (with rationale).
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ findings. Categories:
|
|||||||
`hermes_cli.config`, but those live in `hermes_cli.cli_output`; it also imported
|
`hermes_cli.config`, but those live in `hermes_cli.cli_output`; it also imported
|
||||||
a `print_code` that does not exist in hermes at all. The whole import block
|
a `print_code` that does not exist in hermes at all. The whole import block
|
||||||
raised `ImportError`, which the surrounding `try/except` swallowed, so
|
raised `ImportError`, which the surrounding `try/except` swallowed, so
|
||||||
`hermes gateway setup` for the android platform **always bailed out early** with
|
`hermes gateway setup` for the iris platform **always bailed out early** with
|
||||||
"setup helpers unavailable" and never generated a token or prompted for
|
"setup helpers unavailable" and never generated a token or prompted for
|
||||||
host/port. Fixed by importing the print helpers from `hermes_cli.cli_output`,
|
host/port. Fixed by importing the print helpers from `hermes_cli.cli_output`,
|
||||||
the env helpers from `hermes_cli.config`, and dropping the non-existent
|
the env helpers from `hermes_cli.config`, and dropping the non-existent
|
||||||
@@ -127,9 +127,9 @@ rule set (`E W F I UP B SIM PL RET C4`) and `line-length = 100`. Changes:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 18.2 Android app (`app/androidApp` + `app/shared`)
|
## 18.2 Iris app — Android (`app/androidApp` + `app/shared`)
|
||||||
|
|
||||||
The Android and Desktop apps share the `:shared` KMP module (`commonMain` +
|
The Iris Android and Desktop apps share the `:shared` KMP module (`commonMain` +
|
||||||
`jvmMain`), so most Kotlin code is covered here and in 18.3.
|
`jvmMain`), so most Kotlin code is covered here and in 18.3.
|
||||||
|
|
||||||
### 18.2.1 Findings (before)
|
### 18.2.1 Findings (before)
|
||||||
|
|||||||
@@ -113,13 +113,13 @@ to the WS server.
|
|||||||
scale). The handler thread never touches adapter state directly; it bridges
|
scale). The handler thread never touches adapter state directly; it bridges
|
||||||
into the gateway's asyncio loop with
|
into the gateway's asyncio loop with
|
||||||
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
||||||
start, same loop the WS server runs on).
|
start).
|
||||||
- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
|
- **Config:** `IRIS_HTTP_PORT` (default **8791**), bind host `IRIS_HTTP_HOST`.
|
||||||
(`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
||||||
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
|
(`ssl.SSLContext` on the server): plaintext on a trusted LAN by default,
|
||||||
trusted LAN by default, TLS for remote/Tailscale setups.
|
TLS for remote/Tailscale setups.
|
||||||
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
|
- **Bind failure is NON-fatal:** log a warning, disable the HTTP leg, show it
|
||||||
HTTP leg, show it in the inspector. The plugin must keep working WS-only.
|
in the inspector.
|
||||||
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
|
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
@@ -305,7 +305,7 @@ New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
|
|||||||
auth → 401, magic-byte reclassification; `GET /v1/media/{id}` happy path
|
auth → 401, magic-byte reclassification; `GET /v1/media/{id}` happy path
|
||||||
(bytes + content-type), unknown id → 404, denied path → 404.
|
(bytes + content-type), unknown id → 404, denied path → 404.
|
||||||
- **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with
|
- **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with
|
||||||
assertion flags, per `gateway-plugin/tests/README.md`) + `--http-media FILE`
|
assertion flags, per `tests/README.md`) + `--http-media FILE`
|
||||||
(v2: upload round-trip via `POST /v1/media`, exit 23 on rejection).
|
(v2: upload round-trip via `POST /v1/media`, exit 23 on rejection).
|
||||||
- **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments,
|
- **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments,
|
||||||
`Last-Event-ID` bookkeeping); transport state machine transitions (fake
|
`Last-Event-ID` bookkeeping); transport state machine transitions (fake
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ interleaved (different languages, no shared surface).
|
|||||||
|
|
||||||
- **QR display in the app** (showing a QR for other devices to scan) —
|
- **QR display in the app** (showing a QR for other devices to scan) —
|
||||||
single-device pairing today; revisit if multi-device lands.
|
single-device pairing today; revisit if multi-device lands.
|
||||||
- **`hermes android pair` stretch CLI** (re-issue token + new QR,
|
- **`hermes iris pair` stretch CLI** (re-issue token + new QR,
|
||||||
`09-pairing-security.md` §9.2 line 48) — separate backlog item.
|
`09-pairing-security.md` §9.2 line 48) — separate backlog item.
|
||||||
- **WSS cert pinning** (gap #6) — orthogonal; QR carries `secure=1`
|
- **WSS cert pinning** (gap #6) — orthogonal; QR carries `secure=1`
|
||||||
already, pinning is app-side.
|
already, pinning is app-side.
|
||||||
|
|||||||
+10
-4
@@ -18,6 +18,11 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## User-facing guides
|
||||||
|
|
||||||
|
- [`install.md`](install.md) — **install the gateway + connect the app** (non-technical walkthrough, all options, TLS, push).
|
||||||
|
- [`setup.md`](setup.md) — moved; pointer to `install.md`.
|
||||||
|
|
||||||
## Reading order
|
## Reading order
|
||||||
|
|
||||||
| # | File | When to read |
|
| # | File | When to read |
|
||||||
@@ -30,9 +35,9 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
|||||||
| 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. |
|
| 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. |
|
||||||
| 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. |
|
| 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. |
|
||||||
| 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. |
|
| 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. |
|
||||||
| 8 | [`08-push.md`](08-push.md) | Push (FCM + ntfy), outbox, sync. |
|
| 8 | [`08-push.md`](08-push.md) | Push (ntfy default + FCM optional), outbox, sync. |
|
||||||
| 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. |
|
| 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. |
|
||||||
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Android app. |
|
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Iris app (Android). |
|
||||||
| 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. |
|
| 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. |
|
||||||
| 12 | [`12-toolchain.md`](12-toolchain.md) | First time on a machine (JDK/SDK/uv/Firebase). |
|
| 12 | [`12-toolchain.md`](12-toolchain.md) | First time on a machine (JDK/SDK/uv/Firebase). |
|
||||||
| 13 | [`13-testing.md`](13-testing.md) | Writing tests + on-device ADB workflow. |
|
| 13 | [`13-testing.md`](13-testing.md) | Writing tests + on-device ADB workflow. |
|
||||||
@@ -47,6 +52,7 @@ Machine-readable / diagrams:
|
|||||||
|
|
||||||
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
|
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
|
||||||
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
|
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
|
||||||
|
- [`playstore-listing.md`](playstore-listing.md) — Play Store listing text (incl. the FCM/ntfy privacy note).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,9 +64,9 @@ Machine-readable / diagrams:
|
|||||||
Python dependencies, zero hermes-core changes.**
|
Python dependencies, zero hermes-core changes.**
|
||||||
2. **`app/androidApp`** — native Kotlin + Jetpack Compose client.
|
2. **`app/androidApp`** — native Kotlin + Jetpack Compose client.
|
||||||
3. **`app/desktopApp`** — Kotlin + Compose Multiplatform client that *shares*
|
3. **`app/desktopApp`** — Kotlin + Compose Multiplatform client that *shares*
|
||||||
the Android app's code and is "tweaked" for a big screen.
|
the Iris app's code and is "tweaked" for a big screen.
|
||||||
|
|
||||||
The Android and Desktop clients live in **one Compose Multiplatform Gradle
|
The Iris Android and Desktop clients live in **one Compose Multiplatform Gradle
|
||||||
project** (`app/`) with a shared KMP module (`app/shared`).
|
project** (`app/`) with a shared KMP module (`app/shared`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ flowchart TB
|
|||||||
AGENT["Agent core<br/>(run_agent.py)"]
|
AGENT["Agent core<br/>(run_agent.py)"]
|
||||||
SESS["Sessions<br/>(SQLite + FTS5)"]
|
SESS["Sessions<br/>(SQLite + FTS5)"]
|
||||||
CRON["Cron scheduler"]
|
CRON["Cron scheduler"]
|
||||||
subgraph PLUGIN["android PLATFORM PLUGIN"]
|
subgraph PLUGIN["IRIS PLATFORM PLUGIN"]
|
||||||
ADAPTER["AndroidAdapter<br/>(BasePlatformAdapter)"]
|
ADAPTER["IrisAdapter<br/>(BasePlatformAdapter)"]
|
||||||
OUTBOX["Outbox (SQLite)<br/>+ sync cursor"]
|
OUTBOX["Outbox (SQLite)<br/>+ sync cursor"]
|
||||||
PUSH["push.py<br/>FcmBackend / NtfyBackend"]
|
PUSH["push.py<br/>FcmBackend / NtfyBackend"]
|
||||||
MEDIA["media.py<br/>cache + chunk stream"]
|
MEDIA["media.py<br/>cache + chunk stream"]
|
||||||
@@ -33,7 +33,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph DEVICES["Clients"]
|
subgraph DEVICES["Clients"]
|
||||||
PHONE["ANDROID APP<br/>(Kotlin / Compose)<br/>WS client + ExoPlayer + FCM"]
|
PHONE["IRIS APP (ANDROID)<br/>(Kotlin / Compose)<br/>WS client + ExoPlayer + FCM"]
|
||||||
DESKTOP["DESKTOP APP<br/>(Compose Multiplatform)<br/>WS client + tray + desktop player"]
|
DESKTOP["DESKTOP APP<br/>(Compose Multiplatform)<br/>WS client + tray + desktop player"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
# Install — Gateway & App
|
||||||
|
|
||||||
|
A step-by-step guide for getting **Iris** (the Android / Desktop app) talking
|
||||||
|
to your **hermes gateway**. Written for people who just want to *use* it, not
|
||||||
|
build it. If you only want the short version, the [README](../README.md) has
|
||||||
|
the three commands that matter.
|
||||||
|
|
||||||
|
The whole setup has two halves:
|
||||||
|
|
||||||
|
1. **The gateway** — a small plugin that runs *inside* your existing hermes
|
||||||
|
install and opens a door for the app to connect through.
|
||||||
|
2. **The app** — on your phone or desktop, where you enter the gateway's
|
||||||
|
address and a pairing token.
|
||||||
|
|
||||||
|
> **Old guides?** Earlier versions of Iris used a WebSocket on port `8790`
|
||||||
|
> (`ws://…/ws`). The transport is now plain HTTP on port **`8791`**
|
||||||
|
> (see [`19-http-fallback-transport.md`](19-http-fallback-transport.md)).
|
||||||
|
> The app still accepts old `ws://` URLs and converts them automatically, but
|
||||||
|
> new setups should use the `http://` URL printed by `hermes gateway setup`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
| Where | What |
|
||||||
|
| --- | --- |
|
||||||
|
| Gateway host (any always-on computer: home server, Raspberry Pi, laptop) | [hermes-agent](https://github.com/NousResearch/hermes-agent) installed with its venv (`cd hermes-agent && uv sync`) |
|
||||||
|
| Phone / desktop | Android 8+ or Linux / macOS / Windows |
|
||||||
|
| Only if you build the app yourself | JDK 17 (+ Android SDK for Android) — see [`12-toolchain.md`](12-toolchain.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Install the gateway plugin (one-time)
|
||||||
|
|
||||||
|
Iris is a regular hermes **platform plugin**, so it installs with the normal
|
||||||
|
plugin command. This repo is a *monorepo* (the plugin lives in the
|
||||||
|
`gateway-plugin/` subfolder, next to the app), so you point the installer at
|
||||||
|
that subfolder with a `#subfolder` suffix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. The installer clones the repo, copies just the `gateway-plugin/`
|
||||||
|
folder into `~/.hermes/plugins/`, and asks whether to enable it now (say
|
||||||
|
**yes**).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Any git URL works with the `#gateway-plugin` suffix — e.g.
|
||||||
|
`https://gitea.zephyre.one/ARIA/iris_x_hermes.git#gateway-plugin` if you
|
||||||
|
prefer HTTPS.
|
||||||
|
- **Developing from a checkout?** Skip the install and symlink instead — the
|
||||||
|
plugin then always tracks your working tree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.hermes/plugins
|
||||||
|
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||||
|
```
|
||||||
|
|
||||||
|
- Check it was picked up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway status # the Iris platform should be listed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 2 — Gateway setup (one-time)
|
||||||
|
|
||||||
|
Run the interactive setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway setup
|
||||||
|
```
|
||||||
|
|
||||||
|
It walks you through five things:
|
||||||
|
|
||||||
|
| Prompt | What it means | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Iris pairing token** | A long random secret the app must present to connect. Generated for you; stored in `~/.hermes/.env` as `IRIS_TOKEN`. **It is printed only once** — write it down. | auto-generated |
|
||||||
|
| **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` |
|
||||||
|
| **Port** | The port the app connects to. | `8791` |
|
||||||
|
| **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` |
|
||||||
|
| **TLS** | Only asked when no certificate is configured yet: generates a **self-signed** certificate + key under `~/.hermes/iris/` and stores the paths in `.env` (`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`), so the gateway serves `https://`. The app asks you to confirm the printed SHA-256 fingerprint once (like an SSH host key). | No (Yes if you bound `0.0.0.0`) |
|
||||||
|
|
||||||
|
When it finishes it prints two things you need for the app:
|
||||||
|
|
||||||
|
- **Server URL** — e.g. `http://192.168.1.10:8791`
|
||||||
|
- **Pairing QR + URL** — an `iris://pair?…` string with a scannable QR code
|
||||||
|
|
||||||
|
Then start the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway # (or: hermes gateway restart after changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 3 — Install the app
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
Build a debug APK on any machine with JDK 17 + the Android SDK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
./gradlew :androidApp:assembleDebug
|
||||||
|
# → app/androidApp/build/outputs/apk/debug/androidApp-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the APK to the phone (cable, LAN share, or any file transfer) and open
|
||||||
|
it — Android will ask to allow installs from unknown sources.
|
||||||
|
|
||||||
|
*Shortcut for developers with a USB-connected phone:*
|
||||||
|
`./gradlew :androidApp:installDebug` installs it directly.
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
./gradlew :desktopApp:jpackage # → app/desktopApp/build/…/ (native app, JRE bundled)
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux the launcher may print a `pure virtual method called` warning —
|
||||||
|
it's a known, harmless jpackage bug (JDK-8348560); the app works fine.
|
||||||
|
|
||||||
|
## Part 4 — Connect the app
|
||||||
|
|
||||||
|
Open the app. The first screen is **Connect**. You need the **Server URL**
|
||||||
|
and the **pairing token** from Part 2.
|
||||||
|
|
||||||
|
### Option A — Same home network (no encryption, simplest)
|
||||||
|
|
||||||
|
Works out of the box on a trusted home network:
|
||||||
|
|
||||||
|
1. **Server URL:** the one printed by `hermes gateway setup`,
|
||||||
|
e.g. `http://192.168.1.10:8791`.
|
||||||
|
- On a phone, use the gateway's **LAN IP** — not `127.0.0.1` (that only
|
||||||
|
means "this device" and won't reach your server).
|
||||||
|
- If you set the host to `127.0.0.1` during setup, re-run
|
||||||
|
`hermes gateway setup` and enter the LAN IP instead.
|
||||||
|
2. **Pairing token:** the long token from the setup output (or
|
||||||
|
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host).
|
||||||
|
3. **Test & Connect.**
|
||||||
|
|
||||||
|
> **Android shortcut:** the Connect screen has a **Scan QR** button — point
|
||||||
|
> the camera at the QR printed by `hermes gateway setup` and the URL + token
|
||||||
|
> fill themselves in. (Desktop has no camera, so it's manual entry.)
|
||||||
|
|
||||||
|
### Option B — Encrypted (TLS) — recommended for anything beyond your LAN
|
||||||
|
|
||||||
|
Plain `http://` is fine on a home network you trust, but for remote access
|
||||||
|
you want the traffic encrypted. The gateway can serve `https://` itself:
|
||||||
|
|
||||||
|
1. Create a certificate + key. Three flavors:
|
||||||
|
- **Generated by setup (easiest)**: `hermes gateway setup` offers to
|
||||||
|
generate a **self-signed** certificate for you (see the TLS prompt in
|
||||||
|
[Part 2](#part-2--gateway-setup-one-time)). It writes
|
||||||
|
`~/.hermes/iris/iris.crt` + `iris.key`, stores the paths in `.env`, and
|
||||||
|
prints the SHA-256 fingerprint the app will ask you to confirm.
|
||||||
|
- **CA-signed** (Let's Encrypt, or your own CA): works out of the box.
|
||||||
|
- **Self-signed manually** (e.g. `openssl req -x509 -newkey rsa:2048
|
||||||
|
-nodes -keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris"
|
||||||
|
-addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`):
|
||||||
|
the certificate **must** carry a SAN entry matching the host you'll
|
||||||
|
type in the app.
|
||||||
|
2. Put the paths in `~/.hermes/.env` on the gateway host:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
IRIS_HTTP_CERT=/path/to/iris.crt
|
||||||
|
IRIS_HTTP_KEY=/path/to/iris.key
|
||||||
|
```
|
||||||
|
|
||||||
|
3. `hermes gateway restart`.
|
||||||
|
4. In the app, use the **`https://`** URL, e.g. `https://iris.example.com:8791`.
|
||||||
|
|
||||||
|
**Self-signed certificates:** the app won't trust them automatically (by
|
||||||
|
design). On first connect it shows the certificate's SHA-256 fingerprint and
|
||||||
|
asks you to confirm it — exactly like an SSH host key. Compare the
|
||||||
|
fingerprint with the one on the gateway host
|
||||||
|
(`openssl x509 -fingerprint -sha256 -noout -in iris.crt`), confirm, and it's
|
||||||
|
pinned in the app's secure storage from then on. If the certificate ever
|
||||||
|
changes, you'll be asked to confirm again. No system trust-store installs
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Reaching the gateway from outside your home network
|
||||||
|
|
||||||
|
Pick one (in order of preference):
|
||||||
|
|
||||||
|
- **Tailscale / WireGuard (recommended).** Install Tailscale on the gateway
|
||||||
|
host; the app connects to the stable tailnet IP, e.g.
|
||||||
|
`http://100.x.y.z:8791`. No public exposure at all — and since the traffic
|
||||||
|
travels inside the encrypted mesh, plain `http://` is acceptable here.
|
||||||
|
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok). Terminate TLS
|
||||||
|
at the edge and forward to `127.0.0.1:8791` on the gateway host.
|
||||||
|
- **Public bind + TLS + strong token** (`IRIS_HTTP_HOST=0.0.0.0` + Option B).
|
||||||
|
Last resort — the port is then reachable from the internet; the token and
|
||||||
|
TLS are what protect it.
|
||||||
|
|
||||||
|
## Part 5 — Push notifications (optional)
|
||||||
|
|
||||||
|
Push wakes a backgrounded or offline phone so you see replies even when the
|
||||||
|
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. ⚠️ **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
|
||||||
|
build. Without it, FCM is inert and ntfy is the path.
|
||||||
|
|
||||||
|
Details: [`08-push.md`](08-push.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gateway options (reference)
|
||||||
|
|
||||||
|
Everything is configured in `~/.hermes/.env` on the gateway host (or via the
|
||||||
|
prompts of `hermes gateway setup`). After changes: `hermes gateway restart`.
|
||||||
|
|
||||||
|
| Variable | What it does | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `IRIS_TOKEN` | Pairing token the app must present. | — (required) |
|
||||||
|
| `IRIS_HTTP_HOST` | Bind address. `127.0.0.1` = local only; LAN IP = same network; `0.0.0.0` = all interfaces. | `127.0.0.1` |
|
||||||
|
| `IRIS_HTTP_PORT` | Port the app connects to. | `8791` |
|
||||||
|
| `IRIS_HOME_CHANNEL` | Default chat for cron/notification delivery. | `default` |
|
||||||
|
| `IRIS_ALLOWED_USERS` | Comma-separated device ids allowed to connect (empty = token-only auth). | empty |
|
||||||
|
| `IRIS_ALLOW_ALL_USERS` | Allow any paired device (**dev only**). | `false` |
|
||||||
|
| `IRIS_PUSH_BACKEND` | `ntfy` or `fcm`. | `ntfy` |
|
||||||
|
| `IRIS_FCM_SERVICE_ACCOUNT` | Path to Firebase service-account JSON (FCM). | — |
|
||||||
|
| `IRIS_FCM_SERVER_KEY` | Legacy FCM server key (fallback). | — |
|
||||||
|
| `NTFY_SERVER_URL` | ntfy server. Self-hosting recommended. | `https://ntfy.sh` |
|
||||||
|
| `NTFY_AUTH_TOKEN` | Auth token for a private ntfy topic (real trust boundary). | — |
|
||||||
|
| `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY` | TLS cert/key paths → serves `https://` (see Part 4, Option B). | — |
|
||||||
|
|
||||||
|
Security model (tokens, device allowlist, transport):
|
||||||
|
[`09-pairing-security.md`](09-pairing-security.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause / fix |
|
||||||
|
| --- | --- |
|
||||||
|
| `auth failed` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` (setup prints it only when it generates it). |
|
||||||
|
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8791`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
||||||
|
| Phone can't reach the gateway | Gateway bound to `127.0.0.1` — re-run `hermes gateway setup` and set the LAN IP; or the phone is on a different network/VLAN. |
|
||||||
|
| TLS handshake fails | Cert has no SAN matching the URL host; or the pinned fingerprint is stale after a cert change (re-confirm in the app). |
|
||||||
|
| Push not arriving | Backend not configured (check `~/.hermes/logs/gateway.log`); ntfy.sh flakiness — self-host ntfy. |
|
||||||
|
| Start over on a phone | `adb shell pm clear dev.iris.app` wipes the app's pairing state. |
|
||||||
|
|
||||||
|
Logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Play Store Listing
|
||||||
|
|
||||||
|
Copy-paste text for the Play Console (and the F-Droid / sideload pages).
|
||||||
|
Keep this file in sync with the README whenever the privacy story changes.
|
||||||
|
|
||||||
|
## Short description (≤ 80 chars)
|
||||||
|
|
||||||
|
Private chat for your personal AI agent — Android & desktop.
|
||||||
|
|
||||||
|
## Full description
|
||||||
|
|
||||||
|
Iris is a native chat app for your personal AI agent (hermes-agent):
|
||||||
|
streaming replies, visible reasoning, structured tool activity, channels,
|
||||||
|
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. 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
|
||||||
|
(self-hosted) instead — it is the default.
|
||||||
|
|
||||||
|
100 MB file uploads by default (configurable on your gateway). No
|
||||||
|
4,096-character message limit like Telegram. Full markdown + HTML artifact
|
||||||
|
preview. All settings live in the app.
|
||||||
|
|
||||||
|
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 — 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
|
||||||
|
with a self-hosted ntfy server.
|
||||||
@@ -21,9 +21,10 @@
|
|||||||
"hello.ack": {
|
"hello.ack": {
|
||||||
"description": "Pairing succeeded.",
|
"description": "Pairing succeeded.",
|
||||||
"payload": {
|
"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"]}, "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"} } },
|
"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"}, "app_version": {"type":"string","description":"Release version of the gateway plugin (repo-root VERSION file); the app shows it in Settings and hints on app/gateway mismatch."} } },
|
||||||
"sync_cursor": { "type": "integer" },
|
"sync_cursor": { "type": "integer" },
|
||||||
"last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." },
|
"last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." },
|
||||||
|
"device_token": { "type": "string", "description": "Per-device token minted at pairing (docs/09 §9.3). The app stores it and presents it INSTEAD of the shared IRIS_TOKEN from then on; the gateway can revoke it per device. Empty when the gateway didn't issue one (legacy)." },
|
||||||
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
|
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
|
"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"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } },
|
"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"}, "created": {"type":"number","description":"Optional; unix timestamp (seconds) when the channel/thread was created. The app orders threads newest-first in the topic switcher."}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } },
|
||||||
"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."} } },
|
"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."} } },
|
||||||
"runtime": { "type": "object", "description": "Structured runtime-metadata footer (app-controlled display). The gateway ALWAYS sends it on final assistant messages; whether/what is shown is a per-app setting (Settings -> Runtime footer), NOT a hermes config. All keys optional; absent when the data is unavailable (e.g. local models have no cost).", "properties": { "model": {"type":"string","description":"Bare model id, vendor prefix dropped (gpt-5.4)."}, "context_pct": {"type":"integer","description":"Last-call context occupancy, 0-100."}, "cwd": {"type":"string","description":"Home-relative working dir (~)."}, "latency": {"type":"number","description":"Wall-clock turn duration, seconds."}, "cost": {"type":"number","description":"Turn cost, USD."} } }
|
"runtime": { "type": "object", "description": "Structured runtime-metadata footer (app-controlled display). The gateway ALWAYS sends it on final assistant messages; whether/what is shown is a per-app setting (Settings -> Runtime footer), NOT a hermes config. All keys optional; absent when the data is unavailable (e.g. local models have no cost).", "properties": { "model": {"type":"string","description":"Bare model id, vendor prefix dropped (gpt-5.4)."}, "context_pct": {"type":"integer","description":"Last-call context occupancy, 0-100."}, "cwd": {"type":"string","description":"Home-relative working dir (~)."}, "latency": {"type":"number","description":"Wall-clock turn duration, seconds."}, "cost": {"type":"number","description":"Turn cost, USD."} } }
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-171
@@ -1,173 +1,10 @@
|
|||||||
# Setup — Pairing a Device
|
# Setup — Pairing a Device
|
||||||
|
|
||||||
User-facing guide: get a phone or desktop talking to your hermes gateway in
|
> **Moved.** The user-facing setup guide now lives in
|
||||||
under 10 minutes. Design rationale lives in the numbered docs
|
> [`install.md`](install.md) — gateway install, all options, app install,
|
||||||
([`09-pairing-security.md`](09-pairing-security.md),
|
> and connecting (LAN / TLS / remote). This file is kept so old links keep
|
||||||
[`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is
|
> working.
|
||||||
just the steps.
|
>
|
||||||
|
> - Push details: [`08-push.md`](08-push.md)
|
||||||
## Prerequisites
|
> - Security model: [`09-pairing-security.md`](09-pairing-security.md)
|
||||||
|
> - Toolchain (first-time machine setup): [`12-toolchain.md`](12-toolchain.md)
|
||||||
| 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/iris
|
|
||||||
hermes gateway status # should list "iris"
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the interactive setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
hermes gateway setup
|
|
||||||
```
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
|
|
||||||
- Generates `IRIS_TOKEN` (64 hex chars) if none exists and stores it in
|
|
||||||
`~/.hermes/.env` (it prints the token once, at generation).
|
|
||||||
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
|
|
||||||
and push backend (`fcm` or `ntfy`, default `fcm`).
|
|
||||||
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
|
|
||||||
string), a scannable QR of that payload, and the server URL
|
|
||||||
(`ws://<host>:8790/ws`).
|
|
||||||
|
|
||||||
Then start the gateway:
|
|
||||||
|
|
||||||
```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
|
|
||||||
> `IRIS_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 IRIS_TOKEN ~/.hermes/.env` on the gateway host.
|
|
||||||
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
|
|
||||||
pairing and connects.
|
|
||||||
|
|
||||||
> **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX +
|
|
||||||
> ML Kit) that reads the QR printed by `hermes gateway setup` and pre-fills the
|
|
||||||
> URL + token. Desktop has no camera, so it uses manual entry. An `iris://pair`
|
|
||||||
> deep link (from any scanner) pre-fills the same way.
|
|
||||||
|
|
||||||
## 3. Desktop app
|
|
||||||
|
|
||||||
```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`:
|
|
||||||
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
|
|
||||||
3. Keep `IRIS_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)
|
|
||||||
|
|
||||||
```
|
|
||||||
IRIS_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 `IRIS_WS_CERT` / `IRIS_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 `IRIS_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
|
|
||||||
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
|
||||||
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
|
|
||||||
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
|
|
||||||
|
|
||||||
Smoke test without the app (from the gateway host):
|
|
||||||
|
|
||||||
```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":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
|
|
||||||
"caps":{"min_protocol":1}}}))
|
|
||||||
print("recv:", await ws.recv())
|
|
||||||
asyncio.run(main())
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
|
||||||
wrong.
|
|
||||||
+113
-2675
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,358 @@
|
|||||||
|
"""M3: channel-directory frame handlers (``channel.*`` + directory queries).
|
||||||
|
|
||||||
|
Mixin for ``adapter.IrisAdapter``. Each request is answered by broadcasting
|
||||||
|
the matching ``channel.*`` event carrying the request ``id``: the requester's
|
||||||
|
pending request completes on the id, and every other device reconciles its
|
||||||
|
local copy from the same frame (single broadcast serves as event + response).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
from . import purge as purge_bridge
|
||||||
|
from .defaults import DEFAULT_HOME_CHANNEL
|
||||||
|
from .mixin_base import IrisAdapterBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelFrameHandlers(IrisAdapterBase):
|
||||||
|
"""Channel directory management (see module docstring)."""
|
||||||
|
|
||||||
|
async def on_channel_create(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
payload = frame.payload
|
||||||
|
name = payload.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
kind = payload.get("kind")
|
||||||
|
kind = kind if kind in ("channel", "thread") else "channel"
|
||||||
|
parent_chat_id = payload.get("parent_chat_id")
|
||||||
|
if not isinstance(parent_chat_id, str) or not parent_chat_id.strip():
|
||||||
|
parent_chat_id = None
|
||||||
|
if kind == "thread" and not parent_chat_id:
|
||||||
|
parent_chat_id = frame.chat_id or self.home_channel
|
||||||
|
try:
|
||||||
|
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await self._reply(
|
||||||
|
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
resp = protocol.channel_created(entry)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._http_server.fanout(resp)
|
||||||
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
entry["chat_id"],
|
||||||
|
protocol.notification(
|
||||||
|
entry["chat_id"],
|
||||||
|
protocol.NOTIF_CHANNEL_CREATED,
|
||||||
|
"Channels",
|
||||||
|
f"New channel: {name}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
name = frame.payload.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
entry = self._channels.rename(chat_id, name)
|
||||||
|
except ValueError as e:
|
||||||
|
await self._reply(
|
||||||
|
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if entry is None:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
resp = protocol.channel_renamed(entry)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._http_server.fanout(resp)
|
||||||
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_CHANNEL_RENAMED,
|
||||||
|
"Channels",
|
||||||
|
f"Renamed to {name}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
entry = self._channels.set_default(chat_id)
|
||||||
|
if entry is None:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||||
|
# new is_default flag) so every device reconciles the default change.
|
||||||
|
resp = protocol.channel_renamed(entry)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
|
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
on = bool(frame.payload.get("on"))
|
||||||
|
entry = self._channels.set_favorite(chat_id, on)
|
||||||
|
if entry is None:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||||
|
# new favorite flag) so every device reconciles the change.
|
||||||
|
resp = protocol.channel_renamed(entry)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
|
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
payload = frame.payload
|
||||||
|
icon = payload.get("icon")
|
||||||
|
icon = icon if isinstance(icon, str) and icon else None
|
||||||
|
color = payload.get("color")
|
||||||
|
color = color if isinstance(color, str) and color else None
|
||||||
|
# Guard against a runaway base64 blob (a channel icon is small).
|
||||||
|
if icon is not None and len(icon) > 512 * 1024:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
entry = self._channels.set_icon(chat_id, icon, color)
|
||||||
|
if entry is None:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
resp = protocol.channel_renamed(entry)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
|
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
on = bool(frame.payload.get("on"))
|
||||||
|
entry = self._channels.set_automation(chat_id, on)
|
||||||
|
if entry is None:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND,
|
||||||
|
f"cannot set automation on {chat_id} (unknown or default)",
|
||||||
|
id=frame.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||||
|
# new automation flag) so every device reconciles the change.
|
||||||
|
resp = protocol.channel_renamed(entry)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
|
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
entry = self._channels.delete(chat_id)
|
||||||
|
if entry is None:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND,
|
||||||
|
f"cannot delete {chat_id} (unknown or default)",
|
||||||
|
id=frame.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Complete deletion: wipe the lane's history from the outbox (so
|
||||||
|
# ``history`` / ``sync`` can't resurrect it) and from the hermes
|
||||||
|
# session store (so no search trace survives). A channel delete takes
|
||||||
|
# its threads with it (thread_id=None); a thread delete is scoped to
|
||||||
|
# its parent channel + thread_id.
|
||||||
|
if entry.get("kind") == "thread":
|
||||||
|
lane_chat_id = entry.get("parent_chat_id") or chat_id
|
||||||
|
thread_id = chat_id
|
||||||
|
else:
|
||||||
|
lane_chat_id = chat_id
|
||||||
|
thread_id = None
|
||||||
|
removed_frames = self._outbox.delete_lane(lane_chat_id, thread_id=thread_id)
|
||||||
|
removed_msgs = purge_bridge.delete_lane(
|
||||||
|
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||||
|
chat_id,
|
||||||
|
entry.get("kind"),
|
||||||
|
removed_frames,
|
||||||
|
removed_msgs,
|
||||||
|
)
|
||||||
|
resp = protocol.channel_deleted(chat_id)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._http_server.fanout(resp)
|
||||||
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_CHANNEL_DELETED,
|
||||||
|
"Channels",
|
||||||
|
f"{entry.get('name') or chat_id} deleted",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
channels = self._channels.list(include_archived=False)
|
||||||
|
resp = protocol.channel_list(channels)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
|
# ── Chat info ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _channel_name(self, chat_id: str) -> str:
|
||||||
|
"""Channel display name (M3: from the channel directory)."""
|
||||||
|
if not chat_id:
|
||||||
|
return "chat"
|
||||||
|
entry = self._channels.get(chat_id)
|
||||||
|
if entry is not None:
|
||||||
|
return entry["name"]
|
||||||
|
if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL):
|
||||||
|
return self.home_channel_name
|
||||||
|
return chat_id
|
||||||
|
|
||||||
|
async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
|
||||||
|
"""Return ``{name, type, chat_id}`` for a chat (M3: directory-backed)."""
|
||||||
|
entry = self._channels.get(chat_id)
|
||||||
|
kind = entry["kind"] if entry else "channel"
|
||||||
|
return {
|
||||||
|
"name": self._channel_name(chat_id),
|
||||||
|
"type": "dm" if kind == "default" else "channel",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def channel_list(self) -> list[dict[str, Any]]:
|
||||||
|
"""Channel directory for ``hello.ack`` (M3: full non-archived list)."""
|
||||||
|
return self._channels.list(include_archived=False)
|
||||||
|
|
||||||
|
# ── M3: core channel-directory hook (cron / send_message name resolution)
|
||||||
|
|
||||||
|
async def list_channels(self) -> list[dict[str, Any]]:
|
||||||
|
"""Expose the directory to the gateway's core channel directory.
|
||||||
|
|
||||||
|
``gateway/channel_directory.build_channel_directory`` calls this to
|
||||||
|
populate ``channel_directory.json``, which ``resolve_channel_name``
|
||||||
|
reads for friendly-name -> chat_id resolution (cron + send_message).
|
||||||
|
Threads are addressed via the explicit ``iris:<chat>:<thread>``
|
||||||
|
syntax (see ``_parse_target_ref``), so only channels are listed here.
|
||||||
|
"""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for entry in self._channels.list(include_archived=False):
|
||||||
|
if entry["kind"] == "thread":
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": entry["chat_id"],
|
||||||
|
"name": entry["name"],
|
||||||
|
"type": "dm" if entry["kind"] == "default" else "channel",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ── M3: thread handoff (gateway create_handoff_thread) ────────────────
|
||||||
|
|
||||||
|
async def create_handoff_thread(self, parent_chat_id: str, name: str) -> str | None:
|
||||||
|
"""Mint a named thread under *parent_chat_id* (gateway handoff path).
|
||||||
|
|
||||||
|
Returns the new ``thread_id`` (``t_<n>``) so the handed-off session is
|
||||||
|
isolated in its own lane, or ``None`` when the parent is unknown.
|
||||||
|
"""
|
||||||
|
parent = self._channels.get(parent_chat_id)
|
||||||
|
if parent is None:
|
||||||
|
# Unknown parent: still mint a thread under it so the handoff has a
|
||||||
|
# lane (the directory row is created lazily on first use).
|
||||||
|
parent_chat_id = parent_chat_id or self.home_channel
|
||||||
|
try:
|
||||||
|
entry = self._channels.create(
|
||||||
|
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("iris: create_handoff_thread failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
await self._broadcast_both(protocol.channel_created(entry))
|
||||||
|
return entry["chat_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""Outbound frame classification (M2): turn state + content heuristics.
|
||||||
|
|
||||||
|
The main gateway delivers through the legacy callback path: the stream
|
||||||
|
consumer calls ``send()`` (first bubble of a segment) and ``edit_message()``
|
||||||
|
(updates), tool progress flows through ``send()``/``edit_message()`` of an
|
||||||
|
accumulated line buffer, and interim commentary arrives as a plain
|
||||||
|
``send()``. The adapter classifies each outbound call into a structured frame
|
||||||
|
using a per-chat turn state machine + the content markers below:
|
||||||
|
|
||||||
|
* ``metadata["expect_edits"] is True`` -> streaming segment start
|
||||||
|
* ``metadata["notify"] is True`` -> final message (or fallback final)
|
||||||
|
* tool-progress line format -> tool.start / tool.end
|
||||||
|
* anything else -> commentary
|
||||||
|
|
||||||
|
Verified empirically against the live gateway with ``tests/ws_probe.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_STREAMING_CURSOR = " ▉"
|
||||||
|
|
||||||
|
# Code-style reasoning prefix (gateway/run.py, reasoning_style="code"):
|
||||||
|
# "💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>"
|
||||||
|
_REASONING_PREFIX = "💭 **Reasoning:**\n```\n"
|
||||||
|
_REASONING_CLOSE = "\n```\n\n"
|
||||||
|
|
||||||
|
# A gateway tool-progress line begins with a (non-ASCII) tool emoji.
|
||||||
|
_TOOL_LINE_RE = re.compile(r"^(\S+)\s+(.+)$")
|
||||||
|
_TOOL_NAME_PREVIEW_RE = re.compile(r'^(\S+):\s*"(.*)"\s*$')
|
||||||
|
_TOOL_NAME_BARE_RE = re.compile(r"^(\S+)\.\.\.\s*$")
|
||||||
|
_TOOL_NAME_ARGS_RE = re.compile(r"^(\S+)\(([^)]*)\)\s*$")
|
||||||
|
# Terminal code block: "💻 terminal\n```\n<cmd>\n```"
|
||||||
|
_TOOL_CODEBLOCK_HEAD_RE = re.compile(r"^(\S+)\s+(\S+)\s*$")
|
||||||
|
|
||||||
|
# Reverse map of the gateway's friendly tool verbs (agent/display.py
|
||||||
|
# _TOOL_VERBS) so a verb-form line ("🔍 Searching the web for …") can be
|
||||||
|
# recovered to a structured (tool_name, preview). Longest-first matching is
|
||||||
|
# done at parse time. Verbs shared by several tools map to the most common.
|
||||||
|
_VERB_TO_TOOL: dict[str, str] = {
|
||||||
|
"Searching the web": "web_search",
|
||||||
|
"Searching files": "search_files",
|
||||||
|
"Searching past sessions": "session_search",
|
||||||
|
"Running code": "execute_code",
|
||||||
|
"Running": "terminal",
|
||||||
|
"Reading skill": "skill_view",
|
||||||
|
"Reading": "read_file",
|
||||||
|
"Writing": "write_file",
|
||||||
|
"Editing": "patch",
|
||||||
|
"Browsing": "browser_navigate",
|
||||||
|
"Clicking": "browser_click",
|
||||||
|
"Typing": "browser_type",
|
||||||
|
"Generating image": "image_generate",
|
||||||
|
"Generating video": "video_generate",
|
||||||
|
"Generating speech": "text_to_speech",
|
||||||
|
"Looking at the image": "vision_analyze",
|
||||||
|
"Listing skills": "skills_list",
|
||||||
|
"Updating skill": "skill_manage",
|
||||||
|
"Updating memory": "memory",
|
||||||
|
"Updating tasks": "todo",
|
||||||
|
"Delegating": "delegate_task",
|
||||||
|
"Scheduling": "cronjob",
|
||||||
|
"Asking": "clarify",
|
||||||
|
}
|
||||||
|
# Verbs that take a " for " connector before the preview.
|
||||||
|
_VERB_FOR_CONNECTOR = {"web_search", "search_files"}
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_message_id() -> str:
|
||||||
|
return f"m_{uuid.uuid4().hex[:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_picker_id() -> str:
|
||||||
|
return f"pc_{uuid.uuid4().hex[:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
tid = metadata.get("thread_id")
|
||||||
|
if isinstance(tid, str) and tid:
|
||||||
|
return tid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_thread_name(text: str) -> str:
|
||||||
|
"""Instant auto-thread name from the user's opening message (no model).
|
||||||
|
|
||||||
|
Reuses hermes' session-title derivation (``agent/title_generator.py``):
|
||||||
|
a deterministic slice of the user's own words, so the thread is named the
|
||||||
|
moment it is created. The LLM upgrade (``_schedule_thread_title_upgrade``)
|
||||||
|
replaces it moments later — the same two-stage titling hermes uses for
|
||||||
|
sessions (derived < llm < user).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from agent.title_generator import derive_title
|
||||||
|
|
||||||
|
title = derive_title(text)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Thread name derivation failed", exc_info=True)
|
||||||
|
title = None
|
||||||
|
return (title or "").strip() or "New thread"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_streaming_cursor(text: str) -> str:
|
||||||
|
if text and text.endswith(_STREAMING_CURSOR):
|
||||||
|
return text[: -len(_STREAMING_CURSOR)]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# M5: coalesce back-to-back pushes for the same chat (a cron delivery parks
|
||||||
|
# a notification frame AND a message frame; only the first should push).
|
||||||
|
_PUSH_COALESCE_S = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _push_preview(text: Any, limit: int = 120) -> str:
|
||||||
|
"""Short single-line preview for push bodies (lock-screen privacy: no
|
||||||
|
secrets, no full bodies -- full content arrives via ``sync``)."""
|
||||||
|
s = " ".join(str(text or "").split())
|
||||||
|
if len(s) > limit:
|
||||||
|
s = s[: limit - 1] + "…"
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Cron delivery wrap (cron/scheduler.py ``_deliver_result``,
|
||||||
|
# cron.wrap_response: true):
|
||||||
|
# "Cronjob Response: <name>\n(job_id: <id>)\n-------------\n\n<content>\n\n
|
||||||
|
# To stop or manage this job, send me a new message (e.g. ...)."
|
||||||
|
_CRON_WRAP_RE = re.compile(r"^Cronjob Response: (.+?)\n\(job_id: [^)]*\)\n-+\n\n")
|
||||||
|
_CRON_FOOTER = "\n\nTo stop or manage this job"
|
||||||
|
|
||||||
|
|
||||||
|
def _cron_brief(content: str, job_id: str) -> tuple[str, str]:
|
||||||
|
"""Parse a cron delivery into ``(job_name, inner_text)``.
|
||||||
|
|
||||||
|
Falls back to ``(job_id, content)`` when the wrap is disabled
|
||||||
|
(``cron.wrap_response: false``) or unrecognised.
|
||||||
|
"""
|
||||||
|
m = _CRON_WRAP_RE.match(content or "")
|
||||||
|
if not m:
|
||||||
|
return str(job_id or "cron"), (content or "").strip()
|
||||||
|
name = m.group(1).strip()
|
||||||
|
body = content[m.end() :]
|
||||||
|
idx = body.rfind(_CRON_FOOTER)
|
||||||
|
if idx != -1:
|
||||||
|
body = body[:idx]
|
||||||
|
return name, body.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_reasoning(text: str) -> tuple[str | None, str]:
|
||||||
|
"""Split a code-style reasoning prefix off the front of *text*.
|
||||||
|
|
||||||
|
Returns ``(reasoning, body)``; ``reasoning`` is ``None`` when no prefix is
|
||||||
|
present (reasoning off / no reasoning / non-code style). Best-effort parse
|
||||||
|
of a stable, gateway-owned format: on any mismatch the fallback is
|
||||||
|
``(None, full text)`` so the answer still renders.
|
||||||
|
"""
|
||||||
|
if not text or not text.startswith(_REASONING_PREFIX):
|
||||||
|
return None, text
|
||||||
|
close_idx = text.find(_REASONING_CLOSE, len(_REASONING_PREFIX))
|
||||||
|
if close_idx == -1:
|
||||||
|
return None, text
|
||||||
|
reasoning = text[len(_REASONING_PREFIX) : close_idx]
|
||||||
|
body = text[close_idx + len(_REASONING_CLOSE) :]
|
||||||
|
return reasoning, body
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tool_line(line: str) -> tuple[str, str | None] | None: # noqa: PLR0911
|
||||||
|
"""Parse a single gateway tool-progress line into ``(name, preview)``.
|
||||||
|
|
||||||
|
Returns ``None`` when the line is not a tool line. The gateway formats
|
||||||
|
tool lines as ``<emoji> <name>: "<preview>"``, ``<emoji> <name>...``,
|
||||||
|
``<emoji> <name>(keys)``, or a friendly verb phrase (``<emoji> <verb> …``).
|
||||||
|
The verb form is lossy (no tool name), so we surface the verb as the name.
|
||||||
|
"""
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
m = _TOOL_LINE_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
emoji, rest = m.group(1), m.group(2)
|
||||||
|
if emoji.isascii():
|
||||||
|
return None # a tool line always leads with a non-ASCII emoji
|
||||||
|
mp = _TOOL_NAME_PREVIEW_RE.match(rest)
|
||||||
|
if mp:
|
||||||
|
return mp.group(1), mp.group(2)
|
||||||
|
mb = _TOOL_NAME_BARE_RE.match(rest)
|
||||||
|
if mb:
|
||||||
|
return mb.group(1), None
|
||||||
|
ma = _TOOL_NAME_ARGS_RE.match(rest)
|
||||||
|
if ma:
|
||||||
|
return ma.group(1), None
|
||||||
|
# Friendly verb phrase: reverse-map to (tool_name, preview).
|
||||||
|
verb_parsed = _parse_verb_phrase(rest)
|
||||||
|
if verb_parsed is not None:
|
||||||
|
return verb_parsed
|
||||||
|
# Unrecognised: use the phrase as the label.
|
||||||
|
return rest, None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_verb_phrase(phrase: str) -> tuple[str, str | None] | None:
|
||||||
|
"""Reverse-map a friendly verb phrase to ``(tool_name, preview)``.
|
||||||
|
|
||||||
|
Matches the longest verb first so "Running code" wins over "Running".
|
||||||
|
Returns ``None`` when no known verb leads the phrase.
|
||||||
|
"""
|
||||||
|
for verb in sorted(_VERB_TO_TOOL, key=len, reverse=True):
|
||||||
|
tool = _VERB_TO_TOOL[verb]
|
||||||
|
if phrase == verb:
|
||||||
|
return tool, None
|
||||||
|
if tool in _VERB_FOR_CONNECTOR and phrase.startswith(verb + " for "):
|
||||||
|
return tool, phrase[len(verb) + len(" for ") :].strip() or None
|
||||||
|
if phrase.startswith(verb + " "):
|
||||||
|
return tool, phrase[len(verb) + 1 :].strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_code_block(content: str) -> str | None:
|
||||||
|
"""Return the first fenced code block's body in *content*, else ``None``.
|
||||||
|
|
||||||
|
Used to recover the terminal command from a tool-progress code block
|
||||||
|
(``<emoji> terminal`` head line + fenced command).
|
||||||
|
"""
|
||||||
|
m = re.search(r"```[^\n]*\n(.*?)\n```", content, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_verbose_args(line: str, content: str) -> dict[str, Any] | None:
|
||||||
|
"""Recover the full args dict from a verbose tool line, else ``None``.
|
||||||
|
|
||||||
|
In verbose mode the gateway renders ``<emoji> <name>(keys)`` on one line
|
||||||
|
and the full args JSON on the line that follows. When *line* is such a
|
||||||
|
header, return the parsed JSON object from the following line.
|
||||||
|
"""
|
||||||
|
parts = line.strip().split(None, 1)
|
||||||
|
# 2 == "tool name" + "args JSON" on the header line.
|
||||||
|
if len(parts) < 2 or not _TOOL_NAME_ARGS_RE.match(parts[1]): # noqa: PLR2004
|
||||||
|
return None
|
||||||
|
lines = content.splitlines()
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if ln.strip() != line.strip():
|
||||||
|
continue
|
||||||
|
for follow_line in lines[i + 1 :]:
|
||||||
|
follow = follow_line.strip()
|
||||||
|
if not follow:
|
||||||
|
continue
|
||||||
|
if follow.startswith("{"):
|
||||||
|
try:
|
||||||
|
obj = json.loads(follow)
|
||||||
|
return obj if isinstance(obj, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None # next non-empty line is not the args JSON
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _short_preview_from_args(args: dict[str, Any], cap: int = 60) -> str | None:
|
||||||
|
"""Derive a short one-line preview from a verbose args dict.
|
||||||
|
|
||||||
|
The verbose line carries no explicit preview, so the Truncated display
|
||||||
|
would otherwise lose its one-liner. Use the first non-empty string value
|
||||||
|
(whitespace-collapsed, capped) as a stand-in.
|
||||||
|
"""
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return None
|
||||||
|
for value in args.values():
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
s = " ".join(value.split())
|
||||||
|
return s[: cap - 1] + "…" if len(s) > cap else s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tool_progress(content: str) -> bool:
|
||||||
|
"""Heuristic: does *content* look like gateway tool-progress line(s)?
|
||||||
|
|
||||||
|
Tool progress is delivered as one or more lines, each led by a tool emoji
|
||||||
|
(or a terminal code block). Commentary is free-form prose. We classify on
|
||||||
|
the first non-empty line; subsequent lines of the same bubble are tracked
|
||||||
|
by message id, not re-classified.
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
lines = [ln for ln in content.splitlines() if ln.strip()]
|
||||||
|
if not lines:
|
||||||
|
return False
|
||||||
|
first = lines[0].strip()
|
||||||
|
# Terminal code block: "<emoji> terminal" then a fenced command.
|
||||||
|
if len(lines) > 1 and lines[1].strip().startswith("```"):
|
||||||
|
return _TOOL_CODEBLOCK_HEAD_RE.match(first) is not None
|
||||||
|
return _parse_tool_line(first) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gateway_lifecycle_notice(content: str) -> bool:
|
||||||
|
"""True for hermes gateway lifecycle notices (restart / shutdown / online).
|
||||||
|
|
||||||
|
These are system notices, not tool progress. Their leading ⚠️/♻️ emoji
|
||||||
|
would otherwise trip the tool-line heuristic and render them as a
|
||||||
|
never-completing tool card (an endless spinner, since no ``tool.end``
|
||||||
|
ever arrives for a notice that is not a real tool).
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
c = content.strip()
|
||||||
|
return any(
|
||||||
|
marker in c for marker in ("Gateway restarting", "Gateway shutting down", "Gateway online")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _TurnState:
|
||||||
|
"""Per-chat turn state for outbound frame classification (M2)."""
|
||||||
|
|
||||||
|
active: bool = False
|
||||||
|
# message_id of the currently streaming segment (message.start open).
|
||||||
|
stream_id: str | None = None
|
||||||
|
# message_id of the current tool-progress bubble (editable line buffer).
|
||||||
|
tool_msg_id: str | None = None
|
||||||
|
# Monotonic per-turn tool counter (start -> end correlation).
|
||||||
|
tool_index: int = 0
|
||||||
|
# Index of the most recently started tool (awaiting tool.end).
|
||||||
|
open_tool_index: int | None = None
|
||||||
|
# Name of the most recently started tool (matches the post_tool_call
|
||||||
|
# record when the tool completes, so tool.end can carry its output).
|
||||||
|
open_tool_name: str | None = None
|
||||||
|
# Tool lines already emitted as tool.start (dedup across edits).
|
||||||
|
seen_tool_lines: set = field(default_factory=set)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Slash-command catalog for the app's "/" drawer.
|
||||||
|
|
||||||
|
Derived from hermes' central ``COMMAND_REGISTRY`` (``hermes_cli/commands.py``)
|
||||||
|
— the same source the gateway help text and the Telegram command menu use —
|
||||||
|
restricted to commands available on gateway surfaces, plus plugin-registered
|
||||||
|
commands. Never raises: any import/attribute problem (code skew between the
|
||||||
|
plugin and the hermes checkout) degrades to an empty catalog, so the app's
|
||||||
|
drawer simply stays closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
from hermes_cli import commands as hermes_commands
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"iris: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _entry(
|
||||||
|
name: str, description: str, args_hint: str, category: str, aliases: list[str]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": f"/{name}",
|
||||||
|
"description": description,
|
||||||
|
"args_hint": args_hint or "",
|
||||||
|
"category": category,
|
||||||
|
"aliases": [f"/{a}" for a in aliases],
|
||||||
|
}
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
overrides = hermes_commands._resolve_config_gates()
|
||||||
|
for cmd in hermes_commands.COMMAND_REGISTRY:
|
||||||
|
if not hermes_commands._is_gateway_available(cmd, overrides):
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Code skew: the private helpers moved. Fall back to the plain
|
||||||
|
# cli_only filter (config-gated commands are dropped, acceptable).
|
||||||
|
logger.warning("iris: slash catalog fell back to cli_only filter", exc_info=True)
|
||||||
|
entries = [
|
||||||
|
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
||||||
|
for cmd in hermes_commands.COMMAND_REGISTRY
|
||||||
|
if not cmd.cli_only
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for name, description, args_hint in hermes_commands._iter_plugin_command_entries():
|
||||||
|
entries.append(_entry(name, description, args_hint, "Plugin", []))
|
||||||
|
except Exception:
|
||||||
|
# Best-effort: a broken plugin-command registry should not break the
|
||||||
|
# built-in catalog, so the failure is intentionally swallowed.
|
||||||
|
logger.debug("iris: plugin command enumeration failed", exc_info=True)
|
||||||
|
return entries
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Platform defaults (config.yaml ``extra`` / env fallbacks)."""
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP is the only transport
|
||||||
|
DEFAULT_HOME_CHANNEL = "default"
|
||||||
|
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||||
|
DEFAULT_PUSH_BACKEND = "ntfy"
|
||||||
|
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
||||||
|
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(value: str | None) -> bool:
|
||||||
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
@@ -24,7 +24,7 @@ INBOUND_BURST = 40
|
|||||||
MAX_DEVICE_ID_LEN = 128
|
MAX_DEVICE_ID_LEN = 128
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None:
|
async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None: # noqa: PLR0912
|
||||||
"""Shared inbound frame dispatch (docs/19 §19.4). Unknown types are
|
"""Shared inbound frame dispatch (docs/19 §19.4). Unknown types are
|
||||||
ignored (forward-compat)."""
|
ignored (forward-compat)."""
|
||||||
if frame.type == protocol.TYPE_MESSAGE_SEND:
|
if frame.type == protocol.TYPE_MESSAGE_SEND:
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Plugin hook capture: reasoning, tool results, runtime metadata.
|
||||||
|
|
||||||
|
The gateway's plugin hooks (``on_stream_delta``, ``post_tool_call``,
|
||||||
|
``post_api_request``) fire on gateway worker threads; these module-level
|
||||||
|
buffers accumulate the per-turn data the adapter attaches to outbound frames
|
||||||
|
(reasoning on ``message.stop``, tool output on ``tool.end``, the ``runtime``
|
||||||
|
footer on final messages). A personal iris gateway serves one active turn at
|
||||||
|
a time, so global buffers suffice; each is reset at the turn boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
|
from .adapter import IrisAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M2 — reasoning capture (streaming)
|
||||||
|
#
|
||||||
|
# The gateway streams only ``content`` to the platform and suppresses the
|
||||||
|
# final send (which would carry the prepended reasoning), so the model's
|
||||||
|
# separate ``reasoning_content`` is otherwise lost in the streaming case.
|
||||||
|
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
|
||||||
|
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
|
||||||
|
# We accumulate those deltas here and attach the result to the turn's
|
||||||
|
# ``message.stop`` frame. Single-chat for now (the default home channel), so a
|
||||||
|
# module-level buffer suffices; it is reset at each turn start.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_reasoning_parts: list[str] = []
|
||||||
|
_reasoning_lock = threading.Lock()
|
||||||
|
# Barrier: set by the hook worker once it has processed the first content
|
||||||
|
# delta (kind="text"). The worker drains a FIFO queue and reasoning deltas are
|
||||||
|
# enqueued before content deltas, so at that point every reasoning delta has
|
||||||
|
# already been appended -- a reliable "reasoning flushed" signal that avoids
|
||||||
|
# racing message.stop against the async hook thread.
|
||||||
|
_reasoning_flushed = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_stream_delta(**kwargs: Any) -> None:
|
||||||
|
"""Plugin hook: capture reasoning deltas (kind="reasoning")."""
|
||||||
|
kind = kwargs.get("kind")
|
||||||
|
if kind == "reasoning":
|
||||||
|
delta = kwargs.get("delta") or ""
|
||||||
|
if delta:
|
||||||
|
with _reasoning_lock:
|
||||||
|
_reasoning_parts.append(delta)
|
||||||
|
elif kind == "text":
|
||||||
|
_reasoning_flushed.set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_reasoning_flushed(timeout: float = 0.3) -> None:
|
||||||
|
"""Wait (without blocking the event loop) until the hook worker has
|
||||||
|
processed all reasoning deltas, or *timeout* seconds elapse."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + timeout
|
||||||
|
while loop.time() < deadline:
|
||||||
|
if _reasoning_flushed.is_set():
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def _take_reasoning() -> str:
|
||||||
|
"""Drain and return the accumulated reasoning (empty string if none)."""
|
||||||
|
with _reasoning_lock:
|
||||||
|
parts = _reasoning_parts[:]
|
||||||
|
_reasoning_parts.clear()
|
||||||
|
_reasoning_flushed.clear()
|
||||||
|
return "".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_reasoning() -> None:
|
||||||
|
with _reasoning_lock:
|
||||||
|
_reasoning_parts.clear()
|
||||||
|
_reasoning_flushed.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M2 — tool-result capture (post_tool_call hook)
|
||||||
|
#
|
||||||
|
# The gateway renders tool *progress* lines to the platform but never streams
|
||||||
|
# the tool *output* (it is the agent's concern, persisted to history, not
|
||||||
|
# presentation). To let the app show the full call + result on demand
|
||||||
|
# (Settings → Tool detail), we capture each completed tool call via the
|
||||||
|
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
|
||||||
|
#
|
||||||
|
# Global FIFO (like the reasoning buffer): a personal iris gateway serves
|
||||||
|
# one active turn at a time, and records are matched to the open tool by name
|
||||||
|
# in completion order. Bounded so a runaway turn can't grow it without limit.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_tool_results: "deque[dict[str, Any]]" = deque()
|
||||||
|
_tool_results_lock = threading.Lock()
|
||||||
|
_MAX_TOOL_RESULTS = 200
|
||||||
|
_MAX_OUTPUT_PREVIEW = 8000
|
||||||
|
|
||||||
|
|
||||||
|
def _on_post_tool_call(**kwargs: Any) -> None:
|
||||||
|
"""Plugin hook: capture a completed tool call's result + timing."""
|
||||||
|
result = kwargs.get("result")
|
||||||
|
record = {
|
||||||
|
"tool_name": kwargs.get("tool_name") or "",
|
||||||
|
"result": (str(result) if result is not None else "")[:_MAX_OUTPUT_PREVIEW],
|
||||||
|
"duration_ms": kwargs.get("duration_ms") or 0,
|
||||||
|
"status": kwargs.get("status") or "ok",
|
||||||
|
}
|
||||||
|
with _tool_results_lock:
|
||||||
|
_tool_results.append(record)
|
||||||
|
while len(_tool_results) > _MAX_TOOL_RESULTS:
|
||||||
|
_tool_results.popleft()
|
||||||
|
# Live todo list: the todo tool's result is the authoritative full list,
|
||||||
|
# so emit it the moment the call completes — the tool.end frame only
|
||||||
|
# arrives when the NEXT tool starts or the turn ends, which would lag the
|
||||||
|
# app's strip behind the agent's actual progress. Best-effort: a parse
|
||||||
|
# failure (truncated preview) or a missing live adapter just skips it.
|
||||||
|
if kwargs.get("tool_name") == "todo" and record["status"] == "ok":
|
||||||
|
todos = _parse_todo_result(record["result"])
|
||||||
|
adapter = _live_adapter
|
||||||
|
if todos is not None and adapter is not None and adapter._loop is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
asyncio.run_coroutine_threadsafe(adapter._emit_todo_update(todos), adapter._loop)
|
||||||
|
|
||||||
|
|
||||||
|
def _take_tool_result(tool_name: str) -> dict[str, Any] | None:
|
||||||
|
"""Pop the first completed record matching *tool_name* (FIFO), else None."""
|
||||||
|
if not tool_name:
|
||||||
|
return None
|
||||||
|
with _tool_results_lock:
|
||||||
|
for i, rec in enumerate(_tool_results):
|
||||||
|
if rec["tool_name"] == tool_name:
|
||||||
|
del _tool_results[i]
|
||||||
|
return rec
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_tool_results() -> None:
|
||||||
|
"""Clear captured records (turn boundary — drop anything unconsumed)."""
|
||||||
|
with _tool_results_lock:
|
||||||
|
_tool_results.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_end_fields(tool_name: str) -> dict[str, Any]:
|
||||||
|
"""Build the ``tool.end`` enrichment (ok/duration/output_preview) from the
|
||||||
|
captured hook record for *tool_name*; empty dict when none is available
|
||||||
|
(e.g. tool_progress off, or the call came from another session)."""
|
||||||
|
rec = _take_tool_result(tool_name)
|
||||||
|
if rec is None:
|
||||||
|
return {}
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"ok": rec["status"] == "ok",
|
||||||
|
"output_preview": rec["result"] or None,
|
||||||
|
}
|
||||||
|
if rec["duration_ms"]:
|
||||||
|
fields["duration"] = round(rec["duration_ms"] / 1000.0, 3)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
# The live adapter instance (module-level so the synchronous plugin hooks
|
||||||
|
# below can reach it). A personal iris gateway runs exactly one adapter;
|
||||||
|
# set on connect, cleared on disconnect.
|
||||||
|
_live_adapter: "IrisAdapter | None" = None
|
||||||
|
|
||||||
|
# Valid todo item statuses (hermes tools/todo_tool.py VALID_STATUSES).
|
||||||
|
_TODO_STATUSES = ("pending", "in_progress", "completed", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_todo_result(result: Any) -> list[dict[str, str]] | None:
|
||||||
|
"""Parse the ``todo`` tool's result into a clean item list.
|
||||||
|
|
||||||
|
The tool returns ``{"todos": [...], "summary": {...}}`` — the FULL current
|
||||||
|
list, which is authoritative even for ``merge`` writes (whose args carry
|
||||||
|
only the changed items) and read-only calls. Returns ``None`` when the
|
||||||
|
result is not a parseable todo list (error string, truncated preview, …)
|
||||||
|
so the caller skips the emission instead of broadcasting garbage.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(result)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
items = data.get("todos") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return None
|
||||||
|
todos: list[dict[str, str]] = []
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
content = str(it.get("content") or "").strip()
|
||||||
|
status = str(it.get("status") or "")
|
||||||
|
if not content or status not in _TODO_STATUSES:
|
||||||
|
continue
|
||||||
|
todos.append({"id": str(it.get("id") or ""), "content": content, "status": status})
|
||||||
|
return todos
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_emoji(tool_name: str) -> str | None:
|
||||||
|
"""Cosmetic per-tool emoji for the ``tool.start`` frame.
|
||||||
|
|
||||||
|
Resolved via hermes' own display layer (``agent.display.get_tool_emoji``):
|
||||||
|
active-skin ``tool_emojis`` overrides first, then the tool registry's
|
||||||
|
per-tool ``emoji`` field — so icons track the user's hermes theme and
|
||||||
|
new/plugin tools get their registered glyph for free. Returns ``None``
|
||||||
|
when the tool is unknown (or the import fails) so the frame omits the
|
||||||
|
field and the app falls back to its own default glyph.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from agent.display import get_tool_emoji
|
||||||
|
|
||||||
|
return get_tool_emoji(tool_name, default="") or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runtime-metadata footer (post_api_request hook)
|
||||||
|
#
|
||||||
|
# The app renders a Telegram-style footer under final assistant messages
|
||||||
|
# (model, context %, cwd, latency, cost). Display is controlled by the APP
|
||||||
|
# (Settings → Runtime footer), not hermes config — so the gateway ALWAYS
|
||||||
|
# sends the data. hermes core only appends its own *text* footer when
|
||||||
|
# ``display.runtime_footer.enabled`` is set, and the adapter has no access to
|
||||||
|
# the gateway's ``agent_result``, so we capture the same facts ourselves via
|
||||||
|
# the ``post_api_request`` plugin hook (fires after every provider call with
|
||||||
|
# model + usage):
|
||||||
|
#
|
||||||
|
# * model — the turn's latest model (failover-aware)
|
||||||
|
# * prompt_tokens — the latest call's prompt size (context occupancy)
|
||||||
|
# * turn start — the first API call of the turn (latency baseline)
|
||||||
|
#
|
||||||
|
# Global buffer (same pattern as the reasoning/tool buffers): a personal
|
||||||
|
# iris gateway serves one active turn at a time. The hook fires for every
|
||||||
|
# platform, so we only record when the turn's platform is iris.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_runtime_meta: dict[str, Any] = {}
|
||||||
|
_runtime_meta_lock = threading.Lock()
|
||||||
|
# Per-model context-window cache. Resolution may probe endpoints on first use
|
||||||
|
# (slow); the cache is process-lifetime so each model resolves at most once.
|
||||||
|
_context_length_cache: dict[str, int] = {}
|
||||||
|
# Upper bound (seconds) on context-window resolution during a final send, so a
|
||||||
|
# slow first-use probe never delays the reply. The worker thread keeps running
|
||||||
|
# and populates the cache, so the next turn is fast.
|
||||||
|
_CTX_RESOLVE_TIMEOUT_S = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def _on_post_api_request(**kwargs: Any) -> None:
|
||||||
|
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
|
||||||
|
platform = kwargs.get("platform")
|
||||||
|
if platform and platform != "iris":
|
||||||
|
return
|
||||||
|
model = kwargs.get("model") or ""
|
||||||
|
usage = kwargs.get("usage") or {}
|
||||||
|
prompt_tokens = usage.get("prompt_tokens") or 0
|
||||||
|
with _runtime_meta_lock:
|
||||||
|
if model:
|
||||||
|
_runtime_meta["model"] = model
|
||||||
|
if prompt_tokens:
|
||||||
|
_runtime_meta["prompt_tokens"] = prompt_tokens
|
||||||
|
if "turn_start" not in _runtime_meta:
|
||||||
|
_runtime_meta["turn_start"] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _take_runtime_meta() -> dict[str, Any]:
|
||||||
|
"""Drain the captured turn metadata (turn boundary)."""
|
||||||
|
with _runtime_meta_lock:
|
||||||
|
meta = dict(_runtime_meta)
|
||||||
|
_runtime_meta.clear()
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_context_length(model: str) -> int | None:
|
||||||
|
"""Best-effort context window for *model* (cached; None on failure).
|
||||||
|
|
||||||
|
Runs in a worker thread (may probe endpoints on first use). The cache is
|
||||||
|
populated even if the caller's asyncio task times out, so subsequent
|
||||||
|
turns resolve instantly.
|
||||||
|
"""
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
cached = _context_length_cache.get(model)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
from agent.model_metadata import get_model_context_length
|
||||||
|
|
||||||
|
ctx = get_model_context_length(model)
|
||||||
|
if ctx and ctx > 0:
|
||||||
|
_context_length_cache[model] = int(ctx)
|
||||||
|
return int(ctx)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("iris: context-length resolution failed for %s", model, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _home_relative_cwd(cwd: str) -> str:
|
||||||
|
"""Collapse ``$HOME`` to ``~`` (matches hermes' runtime footer)."""
|
||||||
|
if not cwd:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
p = os.path.abspath(cwd)
|
||||||
|
if home and (p == home or p.startswith(home + os.sep)):
|
||||||
|
return "~" + p[len(home) :]
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
return cwd
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Build the ``runtime`` footer object from captured turn metadata.
|
||||||
|
|
||||||
|
Called on every final send (the app decides what to show). Fields without
|
||||||
|
data are omitted. ``meta`` is the drained turn buffer (model,
|
||||||
|
prompt_tokens, turn_start).
|
||||||
|
"""
|
||||||
|
# Lazy import: the tests monkeypatch ``adapter._resolve_context_length``,
|
||||||
|
# so resolve it through the adapter module at call time (avoids a
|
||||||
|
# top-level circular import adapter -> hooks -> adapter).
|
||||||
|
from .adapter import _resolve_context_length
|
||||||
|
|
||||||
|
model = (meta.get("model") or "").rsplit("/", 1)[-1]
|
||||||
|
prompt_tokens = meta.get("prompt_tokens") or 0
|
||||||
|
context_pct = None
|
||||||
|
if prompt_tokens and model:
|
||||||
|
try:
|
||||||
|
ctx_len = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(_resolve_context_length, model),
|
||||||
|
timeout=_CTX_RESOLVE_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
except (asyncio.TimeoutError, Exception):
|
||||||
|
ctx_len = None
|
||||||
|
if ctx_len:
|
||||||
|
context_pct = round(prompt_tokens / ctx_len * 100)
|
||||||
|
turn_start = meta.get("turn_start")
|
||||||
|
latency = (time.monotonic() - turn_start) if turn_start else None
|
||||||
|
cwd = _home_relative_cwd(os.environ.get("TERMINAL_CWD", ""))
|
||||||
|
return protocol.runtime_footer(
|
||||||
|
model=model or None,
|
||||||
|
context_pct=context_pct,
|
||||||
|
cwd=cwd or None,
|
||||||
|
latency=latency,
|
||||||
|
)
|
||||||
+131
-16
@@ -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
|
||||||
|
t = self._thread
|
||||||
|
self._thread = None
|
||||||
|
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:
|
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):
|
with contextlib.suppress(Exception):
|
||||||
httpd.shutdown()
|
httpd.shutdown()
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
t = self._thread
|
|
||||||
self._thread = None
|
|
||||||
if t is not None and t is not threading.current_thread():
|
if t is not None and t is not threading.current_thread():
|
||||||
t.join(timeout=5.0)
|
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:
|
||||||
@@ -321,16 +351,32 @@ class HttpServer:
|
|||||||
|
|
||||||
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
|
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
|
||||||
"""Verify Bearer token + device identity. Returns the device_id, or
|
"""Verify Bearer token + device identity. Returns the device_id, or
|
||||||
None after sending a 401."""
|
None after sending a 401.
|
||||||
|
|
||||||
|
Token model (docs/09 §9.3): a REVOKED device_id is rejected no matter
|
||||||
|
which token it presents (per-device isolation). Otherwise the shared
|
||||||
|
``IRIS_TOKEN`` (bootstrap / legacy) or the device's own per-device
|
||||||
|
token (minted at pairing, returned in ``hello.ack.device_token``)
|
||||||
|
both authenticate — each compared in constant time."""
|
||||||
auth = handler.headers.get("Authorization") or ""
|
auth = handler.headers.get("Authorization") or ""
|
||||||
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
|
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
|
||||||
if not verify_token(token, self._adapter.token):
|
|
||||||
_send_json(handler, 401, {"error": "unauthorized"})
|
|
||||||
return None
|
|
||||||
device_id = (handler.headers.get("X-Iris-Device") or "").strip()
|
device_id = (handler.headers.get("X-Iris-Device") or "").strip()
|
||||||
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
|
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
|
||||||
_send_json(handler, 401, {"error": "X-Iris-Device header required"})
|
_send_json(handler, 401, {"error": "X-Iris-Device header required"})
|
||||||
return None
|
return None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
if self._devices.is_revoked(device_id):
|
||||||
|
logger.warning("iris: http rejected: device %s is revoked", device_id)
|
||||||
|
_send_json(handler, 401, {"error": "device revoked"})
|
||||||
|
return None
|
||||||
|
if not verify_token(token, self._adapter.token):
|
||||||
|
# Not the shared token: try the device's own per-device token.
|
||||||
|
device_token = None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
device_token = self._devices.token_for(device_id)
|
||||||
|
if not (device_token and verify_token(token, device_token)):
|
||||||
|
_send_json(handler, 401, {"error": "unauthorized"})
|
||||||
|
return None
|
||||||
if (
|
if (
|
||||||
not self._adapter.allow_all
|
not self._adapter.allow_all
|
||||||
and self._adapter.allowed_users
|
and self._adapter.allowed_users
|
||||||
@@ -464,7 +510,7 @@ class HttpServer:
|
|||||||
|
|
||||||
# ── GET /v1/events (SSE) ──────────────────────────────────────────────
|
# ── GET /v1/events (SSE) ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
|
def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None: # noqa: PLR0912,PLR0915
|
||||||
qs = parse_qs(parsed.query)
|
qs = parse_qs(parsed.query)
|
||||||
cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID"))
|
cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID"))
|
||||||
# Device registration (the HTTP equivalent of the WS hello upsert):
|
# Device registration (the HTTP equivalent of the WS hello upsert):
|
||||||
@@ -474,16 +520,33 @@ class HttpServer:
|
|||||||
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
|
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
|
||||||
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
|
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
|
||||||
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") or None
|
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") or None
|
||||||
|
# App release version (the repo-root VERSION baked into the build);
|
||||||
|
# stored in the device registry's caps JSON so `hermes` can see which
|
||||||
|
# app version each device runs (old-version awareness). A missing
|
||||||
|
# header (old app build) must not wipe a previously stored version,
|
||||||
|
# so merge over the existing caps instead of replacing them.
|
||||||
|
app_version = (handler.headers.get("X-Iris-App-Version") or "").strip()[:40]
|
||||||
try:
|
try:
|
||||||
|
existing_caps = dict(self._devices.get(device_id) or {}).get("caps") or {}
|
||||||
|
if app_version:
|
||||||
|
existing_caps["app_version"] = app_version
|
||||||
self._devices.upsert(
|
self._devices.upsert(
|
||||||
device_id,
|
device_id,
|
||||||
device_name or device_id,
|
device_name or device_id,
|
||||||
None,
|
existing_caps or None,
|
||||||
fcm_token,
|
fcm_token,
|
||||||
ntfy_topic,
|
ntfy_topic,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("iris: device registry upsert failed", exc_info=True)
|
logger.warning("iris: device registry upsert failed", exc_info=True)
|
||||||
|
# Per-device token (docs/09 §9.3): minted once at pairing (idempotent
|
||||||
|
# across (re)connects) and returned in the hello below; the app
|
||||||
|
# stores it and presents it instead of the shared token from then on.
|
||||||
|
device_token = ""
|
||||||
|
try:
|
||||||
|
device_token = self._devices.issue_token(device_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("iris: device token issuance failed", exc_info=True)
|
||||||
sub = _Subscriber(device_id=device_id, kind="sse")
|
sub = _Subscriber(device_id=device_id, kind="sse")
|
||||||
# Register BEFORE the replay so a frame appended in between is
|
# Register BEFORE the replay so a frame appended in between is
|
||||||
# fanned out to us (and de-duped by cursor below) instead of lost.
|
# fanned out to us (and de-duped by cursor below) instead of lost.
|
||||||
@@ -499,7 +562,12 @@ class HttpServer:
|
|||||||
# lifecycle is the primary "is the device connected?" signal
|
# lifecycle is the primary "is the device connected?" signal
|
||||||
# for debugging flaky links — a gap here is invisible at the
|
# for debugging flaky links — a gap here is invisible at the
|
||||||
# gateway's default log level.
|
# gateway's default log level.
|
||||||
logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor)
|
logger.info(
|
||||||
|
"iris: SSE stream opened: %s (cursor=%d, app_version=%s)",
|
||||||
|
device_id,
|
||||||
|
cursor,
|
||||||
|
app_version or "?",
|
||||||
|
)
|
||||||
# 1. Catch-up from the outbox (id = cursor; the envelope also
|
# 1. Catch-up from the outbox (id = cursor; the envelope also
|
||||||
# carries the cursor for the app's push dedupe).
|
# carries the cursor for the app's push dedupe).
|
||||||
max_cursor = cursor
|
max_cursor = cursor
|
||||||
@@ -513,6 +581,7 @@ class HttpServer:
|
|||||||
sync_cursor=self._adapter._outbox.latest_cursor(),
|
sync_cursor=self._adapter._outbox.latest_cursor(),
|
||||||
channels=self._adapter.channel_list(),
|
channels=self._adapter.channel_list(),
|
||||||
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
|
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
|
||||||
|
device_token=device_token,
|
||||||
)
|
)
|
||||||
self._write_sse(handler, "hello", None, hello.to_json())
|
self._write_sse(handler, "hello", None, hello.to_json())
|
||||||
self._write_sse(
|
self._write_sse(
|
||||||
@@ -575,7 +644,7 @@ class HttpServer:
|
|||||||
|
|
||||||
# ── POST /v1/media (upload, docs/19 §19.15) ───────────────────────────
|
# ── POST /v1/media (upload, docs/19 §19.15) ───────────────────────────
|
||||||
|
|
||||||
def _handle_media_upload(self, handler: BaseHTTPRequestHandler, device_id: str) -> None:
|
def _handle_media_upload(self, handler: BaseHTTPRequestHandler, device_id: str) -> None: # noqa: PLR0911
|
||||||
"""Whole-file upload: metadata in headers, file bytes as the body.
|
"""Whole-file upload: metadata in headers, file bytes as the body.
|
||||||
|
|
||||||
Mirrors the WS ``media.upload`` contract (docs/07 §7.2) in one
|
Mirrors the WS ``media.upload`` contract (docs/07 §7.2) in one
|
||||||
@@ -740,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):
|
||||||
@@ -795,7 +910,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
_send_json(self, 404, {"error": "not found"})
|
_send_json(self, 404, {"error": "not found"})
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802, PLR0911, PLR0912
|
||||||
hs = self.server.http_server
|
hs = self.server.http_server
|
||||||
if not hs.enabled:
|
if not hs.enabled:
|
||||||
_send_json(self, 503, {"error": "http leg disabled"})
|
_send_json(self, 503, {"error": "http leg disabled"})
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Inbound ``message.send`` handling (app -> agent).
|
||||||
|
|
||||||
|
Mixin for ``adapter.IrisAdapter``. Echoes the user message to all devices
|
||||||
|
(multi-device sync + ack), resolves ``media_refs`` to
|
||||||
|
``MessageEvent.media_urls``/``media_types``, applies auto-threading, and
|
||||||
|
hands the ``MessageEvent`` to ``handle_message()`` (the gateway's command
|
||||||
|
pipeline + agent turn).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gateway.platforms.base import MessageEvent, MessageType
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
from .classify import _derive_thread_name
|
||||||
|
from .mixin_base import IrisAdapterBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InboundHandlers(IrisAdapterBase):
|
||||||
|
"""Inbound message.send (see module docstring)."""
|
||||||
|
|
||||||
|
async def on_message_send(self, frame: protocol.Frame, device_id: str) -> None: # noqa: PLR0912,PLR0915
|
||||||
|
"""Handle an inbound ``message.send`` frame.
|
||||||
|
|
||||||
|
Echoes the user message to all devices (multi-device sync + ack),
|
||||||
|
then builds a ``MessageEvent`` and hands it to ``handle_message()``
|
||||||
|
(the gateway's command pipeline + agent turn).
|
||||||
|
|
||||||
|
M4: ``media_refs`` reference completed ``media.upload``s; they are
|
||||||
|
resolved to ``MessageEvent.media_urls``/``media_types`` (local paths
|
||||||
|
the agent's vision/audio tools can read) and echoed in the user
|
||||||
|
message's ``media[]`` so every device renders the attachments.
|
||||||
|
"""
|
||||||
|
payload = frame.payload
|
||||||
|
text = payload.get("text")
|
||||||
|
text = text if isinstance(text, str) else ""
|
||||||
|
|
||||||
|
refs_raw = payload.get("media_refs")
|
||||||
|
media_refs = (
|
||||||
|
[r for r in refs_raw if isinstance(r, str) and r] if isinstance(refs_raw, list) else []
|
||||||
|
)
|
||||||
|
|
||||||
|
if not text.strip() and not media_refs:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
chat_id = self.home_channel
|
||||||
|
chat_id = chat_id.strip()
|
||||||
|
|
||||||
|
# Automation channels are read-only for the user: they only receive
|
||||||
|
# gateway-originated output (cron jobs, webhooks). Reject direct sends
|
||||||
|
# (the app hides the composer for them, this is the server-side
|
||||||
|
# enforcement).
|
||||||
|
target = self._channels.get(chat_id)
|
||||||
|
if target is not None and target.get("automation"):
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_UNSUPPORTED,
|
||||||
|
f"{target.get('name') or chat_id} is an automation channel "
|
||||||
|
"(read-only: cron/webhook output only)",
|
||||||
|
id=frame.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
thread_id = frame.thread_id or payload.get("thread_id")
|
||||||
|
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||||
|
thread_id = None
|
||||||
|
|
||||||
|
reply_to = payload.get("reply_to")
|
||||||
|
if not isinstance(reply_to, str) or not reply_to.strip():
|
||||||
|
reply_to = None
|
||||||
|
|
||||||
|
# Auto-threading (the app's Threads setting, docs/06 §6.3): a message
|
||||||
|
# in a channel's flat lane gets its own fresh thread, the way Telegram
|
||||||
|
# topic mode mints a topic per new conversation. The thread is named
|
||||||
|
# instantly from the user's opening message (derived title) and the
|
||||||
|
# LLM upgrades the name in the background. The user echo, the agent
|
||||||
|
# turn, and all streaming frames then carry the new thread_id.
|
||||||
|
# Skipped for slash commands (session-scoped, not conversation
|
||||||
|
# starters) and replies (they continue where the user is). Threading
|
||||||
|
# is only active on the default channel; other channels stay flat.
|
||||||
|
auto_thread = bool(payload.get("auto_thread"))
|
||||||
|
default_entry = self._channels.default()
|
||||||
|
if (
|
||||||
|
auto_thread
|
||||||
|
and thread_id is None
|
||||||
|
and text.strip()
|
||||||
|
and not text.lstrip().startswith("/")
|
||||||
|
and reply_to is None
|
||||||
|
and default_entry is not None
|
||||||
|
and chat_id == default_entry["chat_id"]
|
||||||
|
):
|
||||||
|
entry = self._channels.create(
|
||||||
|
name=_derive_thread_name(text),
|
||||||
|
kind="thread",
|
||||||
|
parent_chat_id=chat_id,
|
||||||
|
)
|
||||||
|
thread_id = entry["chat_id"]
|
||||||
|
# Bare broadcast (like channel.create): the directory is
|
||||||
|
# re-served on hello.ack, so no outbox entry is needed.
|
||||||
|
await self._broadcast_both(protocol.channel_created(entry, auto=True))
|
||||||
|
self._schedule_thread_title_upgrade(entry["chat_id"], text)
|
||||||
|
|
||||||
|
# M4: resolve media refs (single-use; unknown ref -> error).
|
||||||
|
media_urls: list[str] = []
|
||||||
|
media_types: list[str] = []
|
||||||
|
media_wire: list[dict[str, Any]] = []
|
||||||
|
for ref in media_refs:
|
||||||
|
entry = self._media.get_inbound(ref)
|
||||||
|
if entry is None:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
media_urls.append(entry.path)
|
||||||
|
media_types.append(entry.mime)
|
||||||
|
media_wire.append(
|
||||||
|
{
|
||||||
|
"media_id": entry.media_id,
|
||||||
|
"kind": entry.kind,
|
||||||
|
"mime": entry.mime,
|
||||||
|
"size": entry.size,
|
||||||
|
"filename": entry.filename,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
device = self._devices.get(device_id) or {}
|
||||||
|
user_name = device.get("name") or device_id
|
||||||
|
|
||||||
|
# Echo to all devices: the sender confirms (server-assigned id),
|
||||||
|
# other devices see the message too (single-user, multi-device).
|
||||||
|
# Routed through _broadcast_or_log (not a bare broadcast) so the echo
|
||||||
|
# is appended to the outbox: the app's ChatStore is in-memory only, so
|
||||||
|
# after a process death / activity recreation the only way the user's
|
||||||
|
# own message is restored is via the sync replay. Without this, user
|
||||||
|
# messages vanish on reconnect while bot messages (already parked)
|
||||||
|
# survive.
|
||||||
|
message_id = f"m_{uuid.uuid4().hex[:16]}"
|
||||||
|
echo = protocol.message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
role=protocol.ROLE_USER,
|
||||||
|
text=text,
|
||||||
|
thread_id=thread_id,
|
||||||
|
media=media_wire or None,
|
||||||
|
reply_to=reply_to,
|
||||||
|
ts=int(time.time() * 1000),
|
||||||
|
)
|
||||||
|
await self._broadcast_or_log(chat_id, echo)
|
||||||
|
# Refs are consumed by this message (no replay).
|
||||||
|
for ref in media_refs:
|
||||||
|
self._media.pop_inbound(ref)
|
||||||
|
|
||||||
|
# M4: a new user turn starts -- stale offer association is dropped.
|
||||||
|
self._last_message_id.pop(chat_id, None)
|
||||||
|
|
||||||
|
kind = media_wire[0]["kind"] if media_wire else None
|
||||||
|
if kind == "image":
|
||||||
|
message_type = MessageType.PHOTO
|
||||||
|
elif kind == "video":
|
||||||
|
message_type = MessageType.VIDEO
|
||||||
|
elif kind == "audio":
|
||||||
|
message_type = MessageType.AUDIO
|
||||||
|
elif kind == "voice":
|
||||||
|
message_type = MessageType.VOICE
|
||||||
|
elif kind == "document":
|
||||||
|
message_type = MessageType.DOCUMENT
|
||||||
|
else:
|
||||||
|
message_type = MessageType.TEXT
|
||||||
|
|
||||||
|
source = self.build_source(
|
||||||
|
chat_id=chat_id,
|
||||||
|
chat_name=self._channel_name(chat_id),
|
||||||
|
chat_type="dm",
|
||||||
|
user_id=device_id,
|
||||||
|
user_name=user_name,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
event = MessageEvent(
|
||||||
|
text=text,
|
||||||
|
message_type=message_type,
|
||||||
|
user_id=device_id,
|
||||||
|
user_name=user_name,
|
||||||
|
source=source,
|
||||||
|
message_id=message_id,
|
||||||
|
reply_to_message_id=reply_to,
|
||||||
|
media_urls=media_urls,
|
||||||
|
media_types=media_types,
|
||||||
|
)
|
||||||
|
await self.handle_message(event)
|
||||||
|
# M5: acknowledge the user message to the originating device (the
|
||||||
|
# app shows ✓✓) at the moment it is handed to the agent.
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.read_receipt(chat_id, message_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _schedule_thread_title_upgrade(self, thread_id: str, text: str) -> None:
|
||||||
|
"""Upgrade an auto-created thread's name with the model's title.
|
||||||
|
|
||||||
|
Stage 2 of hermes' two-stage session titling (``agent/title_generator
|
||||||
|
.py``): the thread was created with an instant derived name; this
|
||||||
|
background call on the ``title_generation`` auxiliary task replaces it
|
||||||
|
with the model's title and broadcasts ``channel.renamed``. Best-effort
|
||||||
|
— any failure (config, model, network) leaves the derived name in
|
||||||
|
place, and a thread the user already renamed or archived is untouched.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _work() -> None:
|
||||||
|
try:
|
||||||
|
from agent.title_generator import generate_title
|
||||||
|
|
||||||
|
title = generate_title(text)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Thread title upgrade failed", exc_info=True)
|
||||||
|
return
|
||||||
|
if not title:
|
||||||
|
return
|
||||||
|
entry = self._channels.get(thread_id)
|
||||||
|
if entry is None or entry.get("archived"):
|
||||||
|
return
|
||||||
|
if (entry.get("name") or "") == title:
|
||||||
|
return
|
||||||
|
renamed = self._channels.rename(thread_id, title)
|
||||||
|
if renamed is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._broadcast_both(protocol.channel_renamed(renamed)),
|
||||||
|
loop,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Thread title rename broadcast failed", exc_info=True)
|
||||||
|
|
||||||
|
threading.Thread(target=_work, daemon=True, name="iris-thread-title").start()
|
||||||
@@ -184,7 +184,7 @@ class UploadSession:
|
|||||||
arrive so an over-limit transfer is rejected early.
|
arrive so an over-limit transfer is rejected early.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
media_ref: str,
|
media_ref: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
@@ -272,7 +272,7 @@ class MediaStore:
|
|||||||
|
|
||||||
# ── Inbound uploads ───────────────────────────────────────────────────
|
# ── Inbound uploads ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_upload(
|
def create_upload( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
media_ref: str,
|
media_ref: str,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""M4: outbound media (agent -> app): ``media.offer`` emission.
|
||||||
|
|
||||||
|
Mixin for ``adapter.IrisAdapter``. The gateway's dispatch partition
|
||||||
|
(gateway/run.py) extracts MEDIA: tags / image URLs from the final response,
|
||||||
|
filters them through ``filter_media_delivery_paths``, then calls the
|
||||||
|
``send_*`` overrides with local file paths. We re-validate each path
|
||||||
|
(defense in depth), register it in the media registry, mint a ``media_id``,
|
||||||
|
and emit ``media.offer``; the app fetches the bytes via ``media.pull``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gateway.platforms.base import SendResult, validate_media_delivery_path
|
||||||
|
|
||||||
|
from . import media as media_bridge
|
||||||
|
from . import protocol
|
||||||
|
from .classify import _thread_id_from_metadata
|
||||||
|
from .mixin_base import IrisAdapterBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaHandlers(IrisAdapterBase):
|
||||||
|
"""Outbound media (see module docstring)."""
|
||||||
|
|
||||||
|
async def _offer_media(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
path: str,
|
||||||
|
kind: str,
|
||||||
|
filename: str | None,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
) -> SendResult:
|
||||||
|
safe = validate_media_delivery_path(path)
|
||||||
|
if safe is None:
|
||||||
|
logger.warning("iris: media path failed delivery validation: %s", path)
|
||||||
|
return SendResult(success=False, error="iris: media path not deliverable")
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(safe)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("iris: media file unreadable %s: %s", safe, e)
|
||||||
|
return SendResult(success=False, error="iris: media file unreadable")
|
||||||
|
entry = self._media.register_outbound(
|
||||||
|
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
|
||||||
|
)
|
||||||
|
thread_id = _thread_id_from_metadata(metadata)
|
||||||
|
frame = protocol.media_offer(
|
||||||
|
entry.media_id,
|
||||||
|
entry.kind,
|
||||||
|
entry.mime,
|
||||||
|
entry.size,
|
||||||
|
entry.filename,
|
||||||
|
chat_id=chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=self._last_message_id.get(chat_id),
|
||||||
|
)
|
||||||
|
await self._broadcast_or_log(chat_id, frame)
|
||||||
|
return SendResult(success=True, message_id=entry.media_id)
|
||||||
|
|
||||||
|
async def send_image(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
image_url: str,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send an image (M4: local files offered over WS; remote URLs fall
|
||||||
|
back to the base text rendering)."""
|
||||||
|
if image_url.startswith("file://"):
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
return await self._offer_media(chat_id, unquote(image_url[7:]), "image", None, metadata)
|
||||||
|
return await super().send_image(
|
||||||
|
chat_id, image_url, caption=caption, reply_to=reply_to, metadata=metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_image_file(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
image_path: str,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send a local image file (M4)."""
|
||||||
|
return await self._offer_media(chat_id, image_path, "image", None, metadata)
|
||||||
|
|
||||||
|
async def send_video(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
video_path: str,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send a video (M4)."""
|
||||||
|
return await self._offer_media(chat_id, video_path, "video", None, metadata)
|
||||||
|
|
||||||
|
async def send_voice(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
audio_path: str,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send a voice note / audio file (M4)."""
|
||||||
|
return await self._offer_media(chat_id, audio_path, "voice", None, metadata)
|
||||||
|
|
||||||
|
async def send_document( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
file_path: str,
|
||||||
|
caption: str | None = None,
|
||||||
|
file_name: str | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send a document (M4)."""
|
||||||
|
return await self._offer_media(chat_id, file_path, "document", file_name, metadata)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Shared base for the ``IrisAdapter`` mixin classes.
|
||||||
|
|
||||||
|
``adapter.IrisAdapter`` is assembled from several small mixin classes
|
||||||
|
(``inbound``, ``tool_frames``, ``push_frames``, ...) plus the core state and
|
||||||
|
lifecycle in ``adapter`` itself. Each mixin references instance attributes and
|
||||||
|
helper methods that are defined in the core class or in a *sibling* mixin, so a
|
||||||
|
type checker analysing one mixin in isolation cannot see them.
|
||||||
|
|
||||||
|
This base declares those shared names (as ``Any``) so static analysis resolves
|
||||||
|
``self.<name>`` inside every mixin. The annotations carry no runtime effect;
|
||||||
|
the real values are set in ``IrisAdapter.__init__`` and the real methods live
|
||||||
|
in the core class / sibling mixins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class IrisAdapterBase:
|
||||||
|
"""Declaration-only base for the ``IrisAdapter`` mixins (see module doc)."""
|
||||||
|
|
||||||
|
# -- shared state (set in ``IrisAdapter.__init__``) -------------------
|
||||||
|
_channels: Any
|
||||||
|
_devices: Any
|
||||||
|
_http_server: Any
|
||||||
|
_media: Any
|
||||||
|
_outbox: Any
|
||||||
|
_push: Any
|
||||||
|
_active_lane: Any
|
||||||
|
_last_message_id: Any
|
||||||
|
_last_push_at: Any
|
||||||
|
_pending_push: Any
|
||||||
|
_pending_pickers: Any
|
||||||
|
_prune_notified_at: Any
|
||||||
|
_typing_turns: Any
|
||||||
|
home_channel: Any
|
||||||
|
home_channel_name: Any
|
||||||
|
|
||||||
|
# -- shared helpers (core class or sibling mixins) --------------------
|
||||||
|
_broadcast_both: Any
|
||||||
|
_broadcast_or_log: Any
|
||||||
|
_channel_name: Any
|
||||||
|
_maybe_push: Any
|
||||||
|
_offer_media: Any
|
||||||
|
_parse_tool_line_or_block: Any
|
||||||
|
_push_summary: Any
|
||||||
|
_reply: Any
|
||||||
|
_schedule_thread_title_upgrade: Any
|
||||||
|
|
||||||
|
# -- provided by ``BasePlatformAdapter`` / core -----------------------
|
||||||
|
build_source: Any
|
||||||
|
handle_message: Any
|
||||||
|
send: Any
|
||||||
|
send_image: Any
|
||||||
|
send_slash_confirm: Any
|
||||||
@@ -171,7 +171,7 @@ class Outbox:
|
|||||||
|
|
||||||
# ── history (full message history for a chat/thread) ──────────────────
|
# ── history (full message history for a chat/thread) ──────────────────
|
||||||
|
|
||||||
def history(
|
def history( # noqa: PLR0912
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
thread_id: str | None = None,
|
thread_id: str | None = None,
|
||||||
|
|||||||
@@ -150,6 +150,21 @@ class DeviceRegistry:
|
|||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0"
|
"ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0"
|
||||||
)
|
)
|
||||||
|
# Per-device tokens (docs/09 §9.3): a unique, revocable token
|
||||||
|
# minted at pairing, stored per device. NULL/empty = the device
|
||||||
|
# still authenticates with the shared IRIS_TOKEN (bootstrap).
|
||||||
|
if "token" not in cols:
|
||||||
|
self._conn.execute("ALTER TABLE devices ADD COLUMN token TEXT")
|
||||||
|
# Revocation denylist: a revoked device_id is rejected even when
|
||||||
|
# it presents the shared token (isolation, docs/09 §9.3).
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS revoked (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
revoked_at REAL NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def upsert(
|
def upsert(
|
||||||
@@ -235,6 +250,91 @@ class DeviceRegistry:
|
|||||||
except (TypeError, ValueError, KeyError, IndexError):
|
except (TypeError, ValueError, KeyError, IndexError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# ── Per-device tokens (docs/09 §9.3) ─────────────────────────────────
|
||||||
|
|
||||||
|
def issue_token(self, device_id: str) -> str:
|
||||||
|
"""Mint (or return the existing) per-device token for a device.
|
||||||
|
|
||||||
|
Idempotent: a device keeps its token across (re)connects. Creates the
|
||||||
|
device row on first sight (name defaults to the device_id; the SSE
|
||||||
|
open's upsert fills in the real name + push tokens)."""
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT token FROM devices WHERE device_id = ?", (device_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row and row["token"]:
|
||||||
|
return row["token"]
|
||||||
|
token = generate_token()
|
||||||
|
now = time.time()
|
||||||
|
if row:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE devices SET token = ? WHERE device_id = ?",
|
||||||
|
(token, device_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO devices (device_id, name, token, last_seen, created)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(device_id, device_id, token, now, now),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
def reissue_token(self, device_id: str) -> str:
|
||||||
|
"""Rotate the device's token (the old one stops working)."""
|
||||||
|
with self._lock:
|
||||||
|
token = generate_token()
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE devices SET token = ? WHERE device_id = ?",
|
||||||
|
(token, device_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
def token_for(self, device_id: str) -> str | None:
|
||||||
|
"""The device's per-device token, or None (shared-token bootstrap)."""
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT token FROM devices WHERE device_id = ?", (device_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row and row["token"]:
|
||||||
|
return row["token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Revocation (docs/09 §9.3) ────────────────────────────────────────
|
||||||
|
|
||||||
|
def revoke(self, device_id: str) -> None:
|
||||||
|
"""Revoke a single device: drop its row (token, push tokens, cursor)
|
||||||
|
and add its id to the denylist, so even the shared token no longer
|
||||||
|
works for it. Other devices are unaffected."""
|
||||||
|
with self._lock:
|
||||||
|
self._conn.execute("DELETE FROM devices WHERE device_id = ?", (device_id,))
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO revoked (device_id, revoked_at) VALUES (?, ?)",
|
||||||
|
(device_id, time.time()),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def unrevoke(self, device_id: str) -> None:
|
||||||
|
"""Remove a device from the denylist (operator re-pairing)."""
|
||||||
|
with self._lock:
|
||||||
|
self._conn.execute("DELETE FROM revoked WHERE device_id = ?", (device_id,))
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def is_revoked(self, device_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM revoked WHERE device_id = ?", (device_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def list_revoked(self) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT device_id, revoked_at FROM revoked ORDER BY revoked_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [{"device_id": r["device_id"], "revoked_at": r["revoked_at"]} for r in rows]
|
||||||
|
|
||||||
def get(self, device_id: str) -> dict[str, Any] | None:
|
def get(self, device_id: str) -> dict[str, Any] | None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
@@ -260,6 +360,9 @@ def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
|
|||||||
caps = {}
|
caps = {}
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
caps = {}
|
caps = {}
|
||||||
|
# NOTE: the per-device ``token`` column is deliberately NOT included —
|
||||||
|
# device dicts flow into push fan-out and operator listings, and the
|
||||||
|
# token must never leave the registry (docs/09 §9.5).
|
||||||
return {
|
return {
|
||||||
"device_id": row["device_id"],
|
"device_id": row["device_id"],
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""M5: approval / clarify / choice-picker frames (interactive banners).
|
||||||
|
|
||||||
|
Mixin for ``adapter.IrisAdapter``. Hermes detects these methods on the
|
||||||
|
adapter type; each emits a high-priority ``notification`` (pushed even when
|
||||||
|
a device is live) and, with a live device, an interactive ``picker.choice``
|
||||||
|
card whose selection runs the stored callback (``pickers.py``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gateway.platforms.base import SendResult
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
from .classify import (
|
||||||
|
_mint_message_id,
|
||||||
|
_mint_picker_id,
|
||||||
|
_push_preview,
|
||||||
|
_thread_id_from_metadata,
|
||||||
|
)
|
||||||
|
from .mixin_base import IrisAdapterBase
|
||||||
|
from .pickers import (
|
||||||
|
_approval_picker_callback,
|
||||||
|
_clarify_is_multi,
|
||||||
|
_clarify_picker_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PickerHandlers(IrisAdapterBase):
|
||||||
|
"""Interactive pickers + approvals (see module docstring)."""
|
||||||
|
|
||||||
|
async def send_slash_confirm( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
session_key: str,
|
||||||
|
confirm_id: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Banner + push for a slash-command approval prompt.
|
||||||
|
|
||||||
|
The gateway's text fallback still renders the actionable prompt (the
|
||||||
|
app has no inline buttons yet); the notification is the push-visible
|
||||||
|
signal (high priority: pushed even when a device is live).
|
||||||
|
"""
|
||||||
|
thread_id = _thread_id_from_metadata(metadata)
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_APPROVAL,
|
||||||
|
title or "Approval needed",
|
||||||
|
_push_preview(message),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return await super().send_slash_confirm(
|
||||||
|
chat_id, title, message, session_key, confirm_id, metadata=metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_exec_approval( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
command: str,
|
||||||
|
session_key: str,
|
||||||
|
description: str = "dangerous command",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
allow_permanent: bool = True,
|
||||||
|
allow_session: bool = True,
|
||||||
|
smart_denied: bool = False,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Interactive exec-approval picker (buttons) for a dangerous command.
|
||||||
|
|
||||||
|
Hermes calls this (detected on the adapter type) when the agent wants
|
||||||
|
to run a command that needs approval; the agent thread blocks until the
|
||||||
|
user decides. With a live device we render the same choice set as the
|
||||||
|
native adapters (Allow Once / Session / Always / Deny, gated by the
|
||||||
|
same flags) as a ``picker.choice`` card, reusing the clarify/slash
|
||||||
|
picker mechanism. A tap resolves via ``resolve_gateway_approval``
|
||||||
|
(the same primitive the text ``/approve`` / ``/deny`` handlers use),
|
||||||
|
unblocking the agent, and a short confirmation is delivered as a
|
||||||
|
normal message. A high-priority ``approval`` notification is also
|
||||||
|
emitted so a backgrounded device is woken (pushed even when live).
|
||||||
|
|
||||||
|
With no live device the picker could never be answered, so report
|
||||||
|
failure and let hermes fall back to the text ``/approve`` prompt.
|
||||||
|
"""
|
||||||
|
if not self._http_server.has_devices():
|
||||||
|
return SendResult(success=False, error="no live devices for approval picker")
|
||||||
|
thread_id = _thread_id_from_metadata(metadata)
|
||||||
|
|
||||||
|
# High-priority banner + push (wakes a backgrounded device).
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_APPROVAL,
|
||||||
|
"Approval needed",
|
||||||
|
_push_preview(description or command),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Choice set mirrors the native adapters (telegram/relay).
|
||||||
|
frame_choices = [{"value": "once", "label": "\u2705 Allow Once", "is_current": False}]
|
||||||
|
if not smart_denied and allow_session:
|
||||||
|
frame_choices.append(
|
||||||
|
{"value": "session", "label": "\u2705 Allow Session", "is_current": False}
|
||||||
|
)
|
||||||
|
if allow_permanent:
|
||||||
|
frame_choices.append(
|
||||||
|
{"value": "always", "label": "\u2705 Always Allow", "is_current": False}
|
||||||
|
)
|
||||||
|
frame_choices.append({"value": "deny", "label": "\u274c Deny", "is_current": False})
|
||||||
|
|
||||||
|
cmd_preview = command if len(command) <= 1500 else command[:1500] + "\u2026"
|
||||||
|
title = (
|
||||||
|
"\u26a0\ufe0f **Command approval required**\n\n"
|
||||||
|
f"```\n{cmd_preview}\n```\n\n"
|
||||||
|
f"Reason: {description}"
|
||||||
|
)
|
||||||
|
if smart_denied:
|
||||||
|
title += "\n\n**Smart DENY:** owner override applies to this one operation only."
|
||||||
|
|
||||||
|
picker_id = _mint_picker_id()
|
||||||
|
self._pending_pickers[picker_id] = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"on_choice_selected": _approval_picker_callback(session_key),
|
||||||
|
}
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.picker_choice(picker_id, title, frame_choices, chat_id, thread_id=thread_id),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=picker_id)
|
||||||
|
|
||||||
|
async def send_choice_picker( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
title: str,
|
||||||
|
choices: list,
|
||||||
|
session_key: str,
|
||||||
|
on_choice_selected,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send an interactive choice picker (one tap → one value).
|
||||||
|
|
||||||
|
The generic companion to Telegram's inline-keyboard pickers, used by
|
||||||
|
``/reasoning``, ``/fast``, and any future finite-choice slash command
|
||||||
|
(hermes detects this method on the adapter type). Emits a
|
||||||
|
``picker.choice`` frame; the app answers with ``picker.select``,
|
||||||
|
which runs ``on_choice_selected(chat_id, value)`` and delivers the
|
||||||
|
returned text as a normal message. Outboxed, so a reconnecting
|
||||||
|
device re-renders a still-pending picker.
|
||||||
|
|
||||||
|
With no live device the picker could never be answered, so report
|
||||||
|
failure and let hermes fall back to the text status card.
|
||||||
|
"""
|
||||||
|
if not self._http_server.has_devices():
|
||||||
|
return SendResult(success=False, error="no live devices for picker")
|
||||||
|
thread_id = _thread_id_from_metadata(metadata)
|
||||||
|
picker_id = _mint_picker_id()
|
||||||
|
self._pending_pickers[picker_id] = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"on_choice_selected": on_choice_selected,
|
||||||
|
}
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.picker_choice(picker_id, title, choices, chat_id, thread_id=thread_id),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=picker_id)
|
||||||
|
|
||||||
|
async def send_clarify( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
question: str,
|
||||||
|
choices: list | None,
|
||||||
|
clarify_id: str,
|
||||||
|
session_key: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Banner + push for a clarify prompt.
|
||||||
|
|
||||||
|
Single-select clarifies with a live device render as an interactive
|
||||||
|
``picker.choice`` card (one tap per option + an "Other" free-text
|
||||||
|
button), reusing the slash-command picker mechanism. A real pick
|
||||||
|
resolves via ``resolve_gateway_clarify`` (the agent then continues and
|
||||||
|
replies); "Other" flips the entry to text-capture. Multi-select,
|
||||||
|
open-ended, and no-live-device clarifies fall back to a numbered text
|
||||||
|
list whose reply the gateway's text-intercept captures via
|
||||||
|
``mark_awaiting_text``.
|
||||||
|
"""
|
||||||
|
thread_id = _thread_id_from_metadata(metadata)
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_CLARIFY,
|
||||||
|
"Question",
|
||||||
|
_push_preview(question),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Single-select + live device → interactive picker card.
|
||||||
|
if choices and not _clarify_is_multi(clarify_id) and self._http_server.has_devices():
|
||||||
|
picker_id = _mint_picker_id()
|
||||||
|
self._pending_pickers[picker_id] = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"on_choice_selected": _clarify_picker_callback(
|
||||||
|
clarify_id, [str(c) for c in choices]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
frame_choices = [
|
||||||
|
{"value": f"c{i}", "label": str(c)[:75], "is_current": False}
|
||||||
|
for i, c in enumerate(choices)
|
||||||
|
]
|
||||||
|
frame_choices.append(
|
||||||
|
{"value": "other", "label": "✏️ Other (type your answer)", "is_current": False}
|
||||||
|
)
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.picker_choice(
|
||||||
|
picker_id, f"❓ {question}", frame_choices, chat_id, thread_id=thread_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=picker_id)
|
||||||
|
|
||||||
|
# Text fallback (multi-select / open-ended / no live device).
|
||||||
|
if choices:
|
||||||
|
lines = [f"❓ {question}", ""]
|
||||||
|
for i, choice in enumerate(choices, start=1):
|
||||||
|
lines.append(f" {i}. {choice}")
|
||||||
|
lines.append("")
|
||||||
|
if _clarify_is_multi(clarify_id):
|
||||||
|
lines.append(
|
||||||
|
"Multiple selections allowed — reply with the numbers "
|
||||||
|
'separated by commas or spaces (e.g. "1, 3"), the option '
|
||||||
|
"text, or your own answer."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("Reply with the number, the option text, or your own answer.")
|
||||||
|
text = "\n".join(lines)
|
||||||
|
# Text fallback: enable text-capture so the gateway intercept
|
||||||
|
# picks up the user's typed reply (e.g. "2" or choice text).
|
||||||
|
from tools.clarify_gateway import mark_awaiting_text
|
||||||
|
|
||||||
|
mark_awaiting_text(clarify_id)
|
||||||
|
else:
|
||||||
|
text = f"❓ {question}"
|
||||||
|
message_id = _mint_message_id()
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
role=protocol.ROLE_ASSISTANT,
|
||||||
|
text=text,
|
||||||
|
thread_id=thread_id,
|
||||||
|
ts=int(time.time() * 1000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Choice-picker callbacks: clarify + exec approval.
|
||||||
|
|
||||||
|
Build the ``on_choice_selected`` closures the adapter stores in
|
||||||
|
``_pending_pickers``; a ``picker.select`` from the app runs the closure and
|
||||||
|
delivers its reply text as a normal message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _clarify_is_multi(clarify_id: str) -> bool:
|
||||||
|
"""True when the pending clarify [clarify_id] allows multiple selections.
|
||||||
|
|
||||||
|
The flag lives on the gateway's pending entry; a missing/expired entry (or
|
||||||
|
any lookup error) is treated as single-select.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from tools import clarify_gateway as _cg
|
||||||
|
|
||||||
|
with _cg._lock:
|
||||||
|
_entry = _cg._entries.get(clarify_id)
|
||||||
|
return bool(_entry and getattr(_entry, "multi_select", False))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _clarify_picker_callback(clarify_id: str, choices: list[str]):
|
||||||
|
"""Build the ``on_choice_selected`` callback for a clarify picker.
|
||||||
|
|
||||||
|
Option values are positional (``c0``..``cN``) plus an ``other`` sentinel;
|
||||||
|
the closure maps them back to the real choice strings. A real pick resolves
|
||||||
|
the clarify (the agent then continues and replies); "Other" flips the entry
|
||||||
|
to text-capture so the next typed message is the answer. An unmappable value
|
||||||
|
also flips to text so a clarify never dead-ends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def on_choice_selected(chat_id: str, value: str) -> str | None:
|
||||||
|
from tools.clarify_gateway import mark_awaiting_text, resolve_gateway_clarify
|
||||||
|
|
||||||
|
if value == "other":
|
||||||
|
mark_awaiting_text(clarify_id)
|
||||||
|
return "✏️ Type your answer:"
|
||||||
|
try:
|
||||||
|
idx = int(value[1:]) if value.startswith("c") else -1
|
||||||
|
except ValueError:
|
||||||
|
idx = -1
|
||||||
|
if 0 <= idx < len(choices):
|
||||||
|
resolve_gateway_clarify(clarify_id, choices[idx])
|
||||||
|
return None
|
||||||
|
mark_awaiting_text(clarify_id)
|
||||||
|
return "✏️ Type your answer:"
|
||||||
|
|
||||||
|
return on_choice_selected
|
||||||
|
|
||||||
|
|
||||||
|
# The four exec-approval outcomes hermes understands (tools.approval).
|
||||||
|
_APPROVAL_CHOICES = ("once", "session", "always", "deny")
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_picker_callback(session_key: str):
|
||||||
|
"""Build the ``on_choice_selected`` callback for an exec-approval picker.
|
||||||
|
|
||||||
|
The option values are the raw hermes approval outcomes (``once`` /
|
||||||
|
``session`` / ``always`` / ``deny``); a tap resolves the waiting agent
|
||||||
|
thread via ``resolve_gateway_approval`` (the same primitive the text
|
||||||
|
``/approve`` / ``/deny`` handlers use) and returns a short confirmation
|
||||||
|
label, which the picker handler delivers as a normal message. An unknown
|
||||||
|
value is treated as a deny so a stray tap never approves a command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def on_choice_selected(chat_id: str, value: str) -> str | None:
|
||||||
|
from tools.approval import resolve_gateway_approval
|
||||||
|
|
||||||
|
choice = value if value in _APPROVAL_CHOICES else "deny"
|
||||||
|
count = resolve_gateway_approval(session_key, choice)
|
||||||
|
label = {
|
||||||
|
"once": "✅ Approved once",
|
||||||
|
"session": "✅ Approved for this session",
|
||||||
|
"always": "✅ Approved permanently",
|
||||||
|
"deny": "❌ Denied",
|
||||||
|
}[choice]
|
||||||
|
if not count:
|
||||||
|
label = "⌛ Approval expired — no command was waiting."
|
||||||
|
return label
|
||||||
|
|
||||||
|
return on_choice_selected
|
||||||
+21
-17
@@ -1,12 +1,16 @@
|
|||||||
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 a WebSocket server inside the gateway; the app connects with a
|
Runs an HTTP server (optional TLS) inside the gateway; the app connects
|
||||||
pairing token. Supports streaming, reasoning, structured tool events,
|
with a pairing token. Supports streaming, reasoning, structured tool
|
||||||
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
events, channels/threads, media, FTS5 search, and FCM/ntfy push.
|
||||||
author: Iris x Hermes
|
author: Iris x Hermes
|
||||||
# ``requires_env`` / ``optional_env`` entries are surfaced in the
|
# ``requires_env`` / ``optional_env`` entries are surfaced in the
|
||||||
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
||||||
@@ -17,13 +21,13 @@ requires_env:
|
|||||||
prompt: "Iris pairing token"
|
prompt: "Iris pairing token"
|
||||||
password: true
|
password: true
|
||||||
optional_env:
|
optional_env:
|
||||||
- name: IRIS_WS_HOST
|
- name: IRIS_HTTP_HOST
|
||||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||||
prompt: "WS host"
|
prompt: "HTTP host"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_PORT
|
- name: IRIS_HTTP_PORT
|
||||||
description: "WS port (default 8790)"
|
description: "HTTP port (default 8791)"
|
||||||
prompt: "WS port"
|
prompt: "HTTP port"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_HOME_CHANNEL
|
- name: IRIS_HOME_CHANNEL
|
||||||
description: "Default chat id for cron/notification delivery (default: default)"
|
description: "Default chat id for cron/notification delivery (default: default)"
|
||||||
@@ -38,7 +42,7 @@ optional_env:
|
|||||||
prompt: "Allow all devices? (true/false)"
|
prompt: "Allow all devices? (true/false)"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_PUSH_BACKEND
|
- name: IRIS_PUSH_BACKEND
|
||||||
description: "Push backend: fcm (default) or ntfy"
|
description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
|
||||||
prompt: "Push backend"
|
prompt: "Push backend"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_FCM_SERVICE_ACCOUNT
|
- name: IRIS_FCM_SERVICE_ACCOUNT
|
||||||
@@ -61,11 +65,11 @@ optional_env:
|
|||||||
description: "ntfy auth token for a private topic (trust boundary)"
|
description: "ntfy auth token for a private topic (trust boundary)"
|
||||||
prompt: "ntfy auth token"
|
prompt: "ntfy auth token"
|
||||||
password: true
|
password: true
|
||||||
- name: IRIS_WS_CERT
|
- name: IRIS_HTTP_CERT
|
||||||
description: "TLS cert path for WSS (optional)"
|
description: "TLS cert path for HTTPS (optional)"
|
||||||
prompt: "WSS cert"
|
prompt: "HTTPS cert"
|
||||||
password: false
|
password: false
|
||||||
- name: IRIS_WS_KEY
|
- name: IRIS_HTTP_KEY
|
||||||
description: "TLS key path for WSS (optional)"
|
description: "TLS key path for HTTPS (optional)"
|
||||||
prompt: "WSS key"
|
prompt: "HTTPS key"
|
||||||
password: false
|
password: false
|
||||||
@@ -233,6 +233,7 @@ def hello_ack(
|
|||||||
sync_cursor: int = 0,
|
sync_cursor: int = 0,
|
||||||
channels: list | None = None,
|
channels: list | None = None,
|
||||||
last_pushed_cursor: int = 0,
|
last_pushed_cursor: int = 0,
|
||||||
|
device_token: str = "",
|
||||||
) -> Frame:
|
) -> Frame:
|
||||||
return Frame(
|
return Frame(
|
||||||
type=TYPE_HELLO_ACK,
|
type=TYPE_HELLO_ACK,
|
||||||
@@ -245,6 +246,11 @@ def hello_ack(
|
|||||||
# notifications for sync-replayed frames at/below it (dedupe,
|
# notifications for sync-replayed frames at/below it (dedupe,
|
||||||
# docs/08 §8.7).
|
# docs/08 §8.7).
|
||||||
"last_pushed_cursor": last_pushed_cursor,
|
"last_pushed_cursor": last_pushed_cursor,
|
||||||
|
# Per-device token (docs/09 §9.3): minted at pairing, stored in
|
||||||
|
# devices.db. The app stores it and presents it instead of the
|
||||||
|
# shared IRIS_TOKEN from then on; empty when the gateway didn't
|
||||||
|
# issue one (legacy/unknown device).
|
||||||
|
"device_token": device_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -382,7 +388,7 @@ def message_update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def message_stop(
|
def message_stop( # noqa: PLR0913
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
final_text: str,
|
final_text: str,
|
||||||
@@ -422,7 +428,7 @@ def message_stop(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def tool_start(
|
def tool_start( # noqa: PLR0913
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
index: int,
|
index: int,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -466,7 +472,7 @@ def tool_progress(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def tool_end(
|
def tool_end( # noqa: PLR0913
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
index: int,
|
index: int,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -553,6 +559,8 @@ def _channel_payload(entry: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
if entry.get("parent_chat_id") is not None:
|
if entry.get("parent_chat_id") is not None:
|
||||||
payload["parent_chat_id"] = entry["parent_chat_id"]
|
payload["parent_chat_id"] = entry["parent_chat_id"]
|
||||||
|
if entry.get("created"):
|
||||||
|
payload["created"] = entry["created"]
|
||||||
if entry.get("is_default"):
|
if entry.get("is_default"):
|
||||||
payload["is_default"] = True
|
payload["is_default"] = True
|
||||||
if entry.get("archived"):
|
if entry.get("archived"):
|
||||||
@@ -688,7 +696,7 @@ def sync_done(cursor: int, *, id: int | None = None) -> Frame:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def history(
|
def history( # noqa: PLR0913
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
has_more: bool,
|
has_more: bool,
|
||||||
@@ -749,7 +757,7 @@ def message_deleted(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def notification(
|
def notification( # noqa: PLR0913
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -801,7 +809,7 @@ def status(state: str) -> Frame:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def media_offer(
|
def media_offer( # noqa: PLR0913
|
||||||
media_id: str,
|
media_id: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
mime: str,
|
mime: str,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> in
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def delete_message(
|
def delete_message( # noqa: PLR0913
|
||||||
db_path: Path,
|
db_path: Path,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
thread_id: str | None,
|
thread_id: str | None,
|
||||||
|
|||||||
+33
-38
@@ -1,4 +1,4 @@
|
|||||||
"""Push backends: FCM (primary) + ntfy (fallback).
|
"""Push backends: ntfy (default) + FCM (optional).
|
||||||
|
|
||||||
``PushBackend`` interface with two implementations:
|
``PushBackend`` interface with two implementations:
|
||||||
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
|
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
|
||||||
subscribes to the topic.
|
subscribes to the topic.
|
||||||
|
|
||||||
Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
Selected by ``IRIS_PUSH_BACKEND`` (``ntfy`` default, ``fcm`` optional).
|
||||||
Fired when a frame has no live subscriber; the data payload drives a silent
|
Fired when a frame has no live subscriber; the data payload drives a silent
|
||||||
sync on the device (docs/08-push.md).
|
sync on the device (docs/08-push.md).
|
||||||
|
|
||||||
@@ -33,7 +33,9 @@ import httpx
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
|
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
|
||||||
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
# Not a secret: the well-known Google OAuth2 token endpoint.
|
||||||
|
# pi-lens-ignore: S105
|
||||||
|
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token" # noqa: S105
|
||||||
FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
|
FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
|
||||||
FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send"
|
FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send"
|
||||||
# Refresh the cached access token this long before its expiry.
|
# Refresh the cached access token this long before its expiry.
|
||||||
@@ -54,14 +56,14 @@ class PushBackend:
|
|||||||
name: str = "push"
|
name: str = "push"
|
||||||
# DeviceRegistry column that carries this backend's target token.
|
# DeviceRegistry column that carries this backend's target token.
|
||||||
# Not a secret: a DB column name (string literal), not a credential.
|
# Not a secret: a DB column name (string literal), not a credential.
|
||||||
# pi-lens-ignore: python-hardcoded-secrets
|
# pi-lens-ignore: S105, python-hardcoded-secrets
|
||||||
token_field: str = ""
|
token_field: str = ""
|
||||||
|
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
"""True when the backend has credentials to send with."""
|
"""True when the backend has credentials to send with."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def send(
|
async def send( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -87,8 +89,8 @@ class FcmBackend(PushBackend):
|
|||||||
|
|
||||||
name = "fcm"
|
name = "fcm"
|
||||||
# Not a secret: a DB column name (string literal), not a credential.
|
# Not a secret: a DB column name (string literal), not a credential.
|
||||||
# pi-lens-ignore: python-hardcoded-secrets
|
# pi-lens-ignore: S105
|
||||||
token_field = "fcm_token"
|
token_field = "fcm_token" # noqa: S105
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -124,7 +126,7 @@ class FcmBackend(PushBackend):
|
|||||||
self._sa_failed = True
|
self._sa_failed = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _authorization(self, client: httpx.AsyncClient) -> str | None:
|
async def _authorization(self, client: httpx.AsyncClient) -> str | None: # noqa: PLR0911
|
||||||
"""Bearer token: the legacy server key, or a cached service-account
|
"""Bearer token: the legacy server key, or a cached service-account
|
||||||
OAuth2 access token (JWT-bearer grant, minted with PyJWT)."""
|
OAuth2 access token (JWT-bearer grant, minted with PyJWT)."""
|
||||||
if self._server_key:
|
if self._server_key:
|
||||||
@@ -147,9 +149,7 @@ class FcmBackend(PushBackend):
|
|||||||
}
|
}
|
||||||
headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None
|
headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None
|
||||||
try:
|
try:
|
||||||
assertion = jwt.encode(
|
assertion = jwt.encode(claims, sa["private_key"], algorithm="RS256", headers=headers)
|
||||||
claims, sa["private_key"], algorithm="RS256", headers=headers
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("iris: FCM JWT mint failed", exc_info=True)
|
logger.warning("iris: FCM JWT mint failed", exc_info=True)
|
||||||
return None
|
return None
|
||||||
@@ -168,7 +168,8 @@ class FcmBackend(PushBackend):
|
|||||||
if resp.status_code != _HTTP_OK:
|
if resp.status_code != _HTTP_OK:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"iris: FCM token exchange HTTP %s: %s",
|
"iris: FCM token exchange HTTP %s: %s",
|
||||||
resp.status_code, resp.text[:200],
|
resp.status_code,
|
||||||
|
resp.text[:200],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -186,7 +187,7 @@ class FcmBackend(PushBackend):
|
|||||||
self._token_expiry = now + 3600.0
|
self._token_expiry = now + 3600.0
|
||||||
return token
|
return token
|
||||||
|
|
||||||
async def send(
|
async def send( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -221,9 +222,7 @@ class FcmBackend(PushBackend):
|
|||||||
message["notification"] = notification
|
message["notification"] = notification
|
||||||
if data:
|
if data:
|
||||||
message["data"] = data
|
message["data"] = data
|
||||||
message["android"] = {
|
message["android"] = {"priority": "high" if priority == "high" else "normal"}
|
||||||
"priority": "high" if priority == "high" else "normal"
|
|
||||||
}
|
|
||||||
payload = {"message": message}
|
payload = {"message": message}
|
||||||
auth = await self._authorization(client)
|
auth = await self._authorization(client)
|
||||||
if auth is None:
|
if auth is None:
|
||||||
@@ -240,9 +239,7 @@ class FcmBackend(PushBackend):
|
|||||||
return False
|
return False
|
||||||
if resp.status_code >= _HTTP_ERROR_MIN:
|
if resp.status_code >= _HTTP_ERROR_MIN:
|
||||||
# 404 NOT_FOUND = stale/invalid registration token.
|
# 404 NOT_FOUND = stale/invalid registration token.
|
||||||
logger.warning(
|
logger.warning("iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200])
|
||||||
"iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -257,8 +254,8 @@ class NtfyBackend(PushBackend):
|
|||||||
|
|
||||||
name = "ntfy"
|
name = "ntfy"
|
||||||
# Not a secret: a DB column name (string literal), not a credential.
|
# Not a secret: a DB column name (string literal), not a credential.
|
||||||
# pi-lens-ignore: python-hardcoded-secrets
|
# pi-lens-ignore: S105
|
||||||
token_field = "ntfy_topic"
|
token_field = "ntfy_topic" # noqa: S105
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -267,10 +264,9 @@ class NtfyBackend(PushBackend):
|
|||||||
auth_token: str | None = None,
|
auth_token: str | None = None,
|
||||||
):
|
):
|
||||||
self._topic = (topic or "").strip() or None
|
self._topic = (topic or "").strip() or None
|
||||||
self._server = (
|
self._server = (server_url or _DEFAULT_NTFY_SERVER).strip().rstrip(
|
||||||
(server_url or _DEFAULT_NTFY_SERVER).strip().rstrip("/")
|
"/"
|
||||||
or _DEFAULT_NTFY_SERVER
|
) or _DEFAULT_NTFY_SERVER
|
||||||
)
|
|
||||||
self._auth_token = (auth_token or "").strip() or None
|
self._auth_token = (auth_token or "").strip() or None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -281,7 +277,7 @@ class NtfyBackend(PushBackend):
|
|||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self._topic)
|
return bool(self._topic)
|
||||||
|
|
||||||
async def send(
|
async def send( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -309,21 +305,17 @@ class NtfyBackend(PushBackend):
|
|||||||
url = f"{self._server}/{quote(topic, safe='')}"
|
url = f"{self._server}/{quote(topic, safe='')}"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(url, content=text.encode("utf-8"), headers=headers)
|
||||||
url, content=text.encode("utf-8"), headers=headers
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("iris: ntfy publish failed (network)", exc_info=True)
|
logger.warning("iris: ntfy publish failed (network)", exc_info=True)
|
||||||
return False
|
return False
|
||||||
if resp.status_code >= _HTTP_ERROR_MIN:
|
if resp.status_code >= _HTTP_ERROR_MIN:
|
||||||
logger.warning(
|
logger.warning("iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200])
|
||||||
"iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_push_backend(
|
def build_push_backend( # noqa: PLR0913
|
||||||
name: str | None,
|
name: str | None,
|
||||||
*,
|
*,
|
||||||
fcm_service_account: str | None = None,
|
fcm_service_account: str | None = None,
|
||||||
@@ -332,9 +324,12 @@ def build_push_backend(
|
|||||||
ntfy_server_url: str | None = None,
|
ntfy_server_url: str | None = None,
|
||||||
ntfy_auth_token: str | None = None,
|
ntfy_auth_token: str | None = None,
|
||||||
) -> PushBackend:
|
) -> PushBackend:
|
||||||
"""Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default)."""
|
"""Select the backend by name (``IRIS_PUSH_BACKEND``; ntfy default).
|
||||||
if (name or "").strip().lower() == "ntfy":
|
|
||||||
return NtfyBackend(
|
ntfy is the default: it keeps push metadata on your own infrastructure.
|
||||||
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
|
FCM is opt-in (``IRIS_PUSH_BACKEND=fcm``) — its metadata (title, device
|
||||||
)
|
token) is routed through Google's servers.
|
||||||
|
"""
|
||||||
|
if (name or "").strip().lower() == "fcm":
|
||||||
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
|
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
|
||||||
|
return NtfyBackend(topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""M5: push mirroring, typing indicators, turn-aware push.
|
||||||
|
|
||||||
|
Mixin for ``adapter.IrisAdapter``. Frames with no live subscriber wake the
|
||||||
|
device via the push backend (``push.py``); high-priority kinds push even
|
||||||
|
when a device is live. While the agent's turn is in flight (typing on),
|
||||||
|
normal-priority message/media frames are held back and the latest one is
|
||||||
|
pushed when the turn ends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
from .classify import _PUSH_COALESCE_S, _push_preview
|
||||||
|
from .mixin_base import IrisAdapterBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# How often (seconds) the outbox-prune "storage reclaimed" notice may repeat.
|
||||||
|
_PRUNE_NOTIFY_INTERVAL_S = 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
class PushHandlers(IrisAdapterBase):
|
||||||
|
"""Push + typing/turn tracking (see module docstring)."""
|
||||||
|
|
||||||
|
def _push_summary(self, frame: "protocol.Frame") -> tuple[str, str, str, str] | None:
|
||||||
|
"""``(title, body, kind, priority)`` for a pushable frame, else None.
|
||||||
|
|
||||||
|
Only terminal/interesting frames wake a device: intermediate
|
||||||
|
streaming and tool frames are replayed by ``sync`` without a push
|
||||||
|
(no notification spam per turn).
|
||||||
|
"""
|
||||||
|
t = frame.type
|
||||||
|
p = frame.payload
|
||||||
|
if t == protocol.TYPE_MESSAGE:
|
||||||
|
return (
|
||||||
|
self._channel_name(frame.chat_id or ""),
|
||||||
|
_push_preview(p.get("text")),
|
||||||
|
"message",
|
||||||
|
"normal",
|
||||||
|
)
|
||||||
|
if t == protocol.TYPE_MESSAGE_STOP:
|
||||||
|
return (
|
||||||
|
self._channel_name(frame.chat_id or ""),
|
||||||
|
_push_preview(p.get("final_text")),
|
||||||
|
"message",
|
||||||
|
"normal",
|
||||||
|
)
|
||||||
|
if t == protocol.TYPE_NOTIFICATION:
|
||||||
|
kind = str(p.get("kind") or protocol.NOTIF_GENERIC)
|
||||||
|
priority = "high" if kind in protocol.HIGH_PRIORITY_NOTIF_KINDS else "normal"
|
||||||
|
return (
|
||||||
|
str(p.get("title") or "Iris"),
|
||||||
|
str(p.get("body") or ""),
|
||||||
|
kind,
|
||||||
|
priority,
|
||||||
|
)
|
||||||
|
if t == protocol.TYPE_MEDIA_OFFER:
|
||||||
|
return (
|
||||||
|
self._channel_name(frame.chat_id or ""),
|
||||||
|
f"New {p.get('kind') or 'media'}: {p.get('filename') or ''}".strip(),
|
||||||
|
"media",
|
||||||
|
"normal",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _maybe_push(self, chat_id: str, frame: "protocol.Frame", cursor: int) -> None:
|
||||||
|
"""Fire the configured push backend for a parked (or high-priority)
|
||||||
|
frame. Best-effort: failures are logged, never raised."""
|
||||||
|
summary = self._push_summary(frame)
|
||||||
|
if summary is None:
|
||||||
|
return
|
||||||
|
# M5: coalesce back-to-back pushes for the same chat (cron delivery
|
||||||
|
# = notification frame + message frame). The suppressed frame is
|
||||||
|
# still synced when the app reconnects.
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
|
||||||
|
logger.info(
|
||||||
|
"iris: push coalesced for %s (%s frame within %.0fs of last push)",
|
||||||
|
chat_id,
|
||||||
|
frame.type,
|
||||||
|
_PUSH_COALESCE_S,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
title, body, kind, priority = summary
|
||||||
|
backend = self._push
|
||||||
|
if backend is None or not backend.token_field:
|
||||||
|
return
|
||||||
|
devices = self._devices.list()
|
||||||
|
if not backend.configured() and not any(d.get(backend.token_field) for d in devices):
|
||||||
|
return
|
||||||
|
data: dict[str, Any] = {"chat_id": chat_id, "kind": kind, "cursor": str(cursor)}
|
||||||
|
if frame.thread_id:
|
||||||
|
data["thread_id"] = frame.thread_id
|
||||||
|
message_id = frame.payload.get("message_id")
|
||||||
|
if isinstance(message_id, str) and message_id:
|
||||||
|
data["message_id"] = message_id
|
||||||
|
for device in devices:
|
||||||
|
device_id = device.get("device_id")
|
||||||
|
if not device_id:
|
||||||
|
continue
|
||||||
|
token = device.get(backend.token_field)
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ok = await backend.send(
|
||||||
|
device_id=device_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
data=data,
|
||||||
|
token=token,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("iris: push via %s failed", backend.name, exc_info=True)
|
||||||
|
continue
|
||||||
|
if ok:
|
||||||
|
# M5: remember that this cursor reached the device via push,
|
||||||
|
# so the app can dedupe it on the next sync replay.
|
||||||
|
try:
|
||||||
|
self._devices.update_push_cursor(device_id, cursor)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"iris: push cursor update failed for %s", device_id, exc_info=True
|
||||||
|
)
|
||||||
|
self._last_push_at[chat_id] = time.time()
|
||||||
|
logger.info(
|
||||||
|
"iris: push via %s -> %s (%s, chat=%s)",
|
||||||
|
backend.name,
|
||||||
|
device_id,
|
||||||
|
frame.type,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _maybe_notify_outbox_prune(self, chat_id: str) -> None:
|
||||||
|
"""When the outbox row cap pruned old frames, tell the app (throttled
|
||||||
|
to once per hour so a full box doesn't banner per frame)."""
|
||||||
|
pruned = self._outbox.take_overflow_pruned()
|
||||||
|
if pruned <= 0:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
if now - self._prune_notified_at < _PRUNE_NOTIFY_INTERVAL_S:
|
||||||
|
return
|
||||||
|
self._prune_notified_at = now
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.notification(
|
||||||
|
chat_id,
|
||||||
|
protocol.NOTIF_GENERIC,
|
||||||
|
"Outbox",
|
||||||
|
f"{pruned} older message(s) pruned",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_typing(self, chat_id: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Send a typing indicator (``typing`` frame, on=true)."""
|
||||||
|
thread_id = None
|
||||||
|
if metadata:
|
||||||
|
tid = metadata.get("thread_id")
|
||||||
|
if isinstance(tid, str) and tid:
|
||||||
|
thread_id = tid
|
||||||
|
# M5: typing on marks the chat's agent turn as in flight (hermes
|
||||||
|
# turns typing on at turn start, before the first output).
|
||||||
|
self._typing_turns.add(chat_id)
|
||||||
|
frame = protocol.typing(chat_id, True, thread_id=thread_id)
|
||||||
|
await self._http_server.fanout(frame, cursor=None)
|
||||||
|
|
||||||
|
async def stop_typing(self, chat_id: str) -> None:
|
||||||
|
"""Clear the typing indicator (``typing`` frame, on=false)."""
|
||||||
|
frame = protocol.typing(chat_id, False)
|
||||||
|
await self._http_server.fanout(frame, cursor=None)
|
||||||
|
# M5: turn ended (hermes fires stop_typing in the handler's finally,
|
||||||
|
# after the final send). Flush the held-back push -- the final
|
||||||
|
# answer -- but only while the device is still offline; a live
|
||||||
|
# device already got the frames via its event stream / sync.
|
||||||
|
# Idempotent: hermes may call stop_typing more than once per turn.
|
||||||
|
self._typing_turns.discard(chat_id)
|
||||||
|
pending = self._pending_push.pop(chat_id, None)
|
||||||
|
if pending is None:
|
||||||
|
return
|
||||||
|
if self._http_server.has_devices():
|
||||||
|
logger.info("iris: deferred push dropped for %s (device back online)", chat_id)
|
||||||
|
return
|
||||||
|
held_frame, held_cursor = pending
|
||||||
|
await self._maybe_push(chat_id, held_frame, held_cursor)
|
||||||
|
|
||||||
|
def on_device_online(self) -> None:
|
||||||
|
"""A device opened its event stream (SSE/long-poll): it will sync
|
||||||
|
the outbox, so drop any held-back pushes -- flushing them later
|
||||||
|
would duplicate what the app already shows. Called from the HTTP
|
||||||
|
server's handler thread; dict.clear() is atomic under the GIL."""
|
||||||
|
if self._pending_push:
|
||||||
|
logger.info(
|
||||||
|
"iris: device online; dropping %d deferred push(es)",
|
||||||
|
len(self._pending_push),
|
||||||
|
)
|
||||||
|
self._pending_push.clear()
|
||||||
@@ -232,7 +232,7 @@ def _version_info(version: int) -> int:
|
|||||||
return _bch(version, 12, 0x1F25)
|
return _bch(version, 12, 0x1F25)
|
||||||
|
|
||||||
|
|
||||||
def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]:
|
def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]: # noqa: PLR0912,PLR0915
|
||||||
size = 17 + 4 * version
|
size = 17 + 4 * version
|
||||||
# matrix[r][c] = dark; reserved[r][c] = function module (not data)
|
# matrix[r][c] = dark; reserved[r][c] = function module (not data)
|
||||||
matrix = [[False] * size for _ in range(size)]
|
matrix = [[False] * size for _ in range(size)]
|
||||||
@@ -355,7 +355,7 @@ def _build_matrix(version: int, level: str, codewords: list[int], mask: int) ->
|
|||||||
return matrix
|
return matrix
|
||||||
|
|
||||||
|
|
||||||
def _mask_bit(mask: int, r: int, c: int) -> bool:
|
def _mask_bit(mask: int, r: int, c: int) -> bool: # noqa: PLR0911
|
||||||
if mask == 0:
|
if mask == 0:
|
||||||
return (r + c) % 2 == 0
|
return (r + c) % 2 == 0
|
||||||
if mask == 1:
|
if mask == 1:
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Inbound query frames: catalog, search, sync, history, delete, push tokens,
|
||||||
|
picker selection.
|
||||||
|
|
||||||
|
Mixin for ``adapter.IrisAdapter``. These are the read/catch-up half of the
|
||||||
|
inbound surface (``message.send`` lives in ``inbound.py``): they answer
|
||||||
|
point-to-point (``_reply``) or broadcast the matching event frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
from . import purge as purge_bridge
|
||||||
|
from . import search as search_bridge
|
||||||
|
from .commands import _slash_command_catalog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryFrameHandlers:
|
||||||
|
"""Inbound query frames (see module docstring)."""
|
||||||
|
|
||||||
|
# ── Slash-command catalog (app's "/" drawer) ──────────────────────────
|
||||||
|
|
||||||
|
async def on_commands_catalog(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Handle an inbound ``commands.catalog`` request: reply with the
|
||||||
|
gateway's slash-command catalog (hermes ``COMMAND_REGISTRY``,
|
||||||
|
gateway-available subset + plugin commands). The app fuzzy-matches
|
||||||
|
the typed prefix client-side; the catalog is static per gateway run,
|
||||||
|
so no caching is needed here."""
|
||||||
|
resp = protocol.commands_catalog(_slash_command_catalog(), id=frame.id)
|
||||||
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
|
# ── M3: search (app -> agent) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async def on_search(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
payload = frame.payload
|
||||||
|
query = payload.get("query")
|
||||||
|
if not isinstance(query, str) or not query.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_UNSUPPORTED, "search requires a query", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
scope = payload.get("scope")
|
||||||
|
scope = scope if scope in ("all", "chat") else "all"
|
||||||
|
chat_id = payload.get("chat_id") or frame.chat_id
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
chat_id = None
|
||||||
|
thread_id = payload.get("thread_id") or frame.thread_id
|
||||||
|
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||||
|
thread_id = None
|
||||||
|
limit = payload.get("limit")
|
||||||
|
try:
|
||||||
|
limit = int(limit) if limit is not None else 20
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 20
|
||||||
|
db_path = get_hermes_home() / "state.db"
|
||||||
|
hits = search_bridge.search(
|
||||||
|
db_path, query, scope=scope, chat_id=chat_id, thread_id=thread_id, limit=limit
|
||||||
|
)
|
||||||
|
resp = protocol.search_results(query, scope, hits, id=frame.id)
|
||||||
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
|
# ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
||||||
|
|
||||||
|
async def on_sync(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
payload = frame.payload
|
||||||
|
cursor = payload.get("cursor")
|
||||||
|
try:
|
||||||
|
cursor = int(cursor) if cursor is not None else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cursor = 0
|
||||||
|
for e in self._outbox.replay(cursor):
|
||||||
|
raw = e["frame"]
|
||||||
|
replayed = protocol.Frame(
|
||||||
|
type=raw.get("type", ""),
|
||||||
|
payload=raw.get("payload", {}) if isinstance(raw.get("payload"), dict) else {},
|
||||||
|
id=raw.get("id") if isinstance(raw.get("id"), int) else None,
|
||||||
|
chat_id=(
|
||||||
|
raw.get("chat_id") if isinstance(raw.get("chat_id"), str) else e.get("chat_id")
|
||||||
|
),
|
||||||
|
thread_id=raw.get("thread_id") if isinstance(raw.get("thread_id"), str) else None,
|
||||||
|
# M5: tag replayed frames with their outbox cursor so the app
|
||||||
|
# can skip re-notifying frames that already woke the device
|
||||||
|
# via push (cursor <= last_pushed_cursor, docs/08 §8.7).
|
||||||
|
cursor=e.get("cursor"),
|
||||||
|
v=raw.get("v") if isinstance(raw.get("v"), int) else protocol.PROTOCOL_VERSION,
|
||||||
|
)
|
||||||
|
await self._reply(device_id, replayed)
|
||||||
|
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
||||||
|
await self._reply(device_id, done)
|
||||||
|
|
||||||
|
# ── Full message history (initial channel open / scroll-up) ───────────
|
||||||
|
|
||||||
|
async def on_history(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Handle an inbound ``history`` request.
|
||||||
|
|
||||||
|
``sync`` only replays the outbox delta since the device's cursor, so
|
||||||
|
after a process death the app's in-memory ChatStore is empty and the
|
||||||
|
delta does not cover older messages. ``history`` loads the full
|
||||||
|
message list for a chat/thread (reconstructed from the outbox log) so
|
||||||
|
the app can populate the view on first open / restart.
|
||||||
|
"""
|
||||||
|
payload = frame.payload
|
||||||
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
|
logger.info("iris: history request from %s chat_id=%r", device_id, chat_id)
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_UNSUPPORTED, "history requires a chat_id", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
chat_id = chat_id.strip()
|
||||||
|
thread_id = frame.thread_id or payload.get("thread_id")
|
||||||
|
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||||
|
thread_id = None
|
||||||
|
before = payload.get("before_message_id")
|
||||||
|
if not isinstance(before, str) or not before.strip():
|
||||||
|
before = None
|
||||||
|
limit_raw = payload.get("limit")
|
||||||
|
try:
|
||||||
|
limit = int(limit_raw) if limit_raw is not None else 50
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 50
|
||||||
|
page = self._outbox.history(
|
||||||
|
chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
before_message_id=before,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
resp = protocol.history(
|
||||||
|
chat_id,
|
||||||
|
page["messages"],
|
||||||
|
page["has_more"],
|
||||||
|
thread_id=thread_id,
|
||||||
|
oldest_message_id=page["oldest_message_id"],
|
||||||
|
id=frame.id,
|
||||||
|
)
|
||||||
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
|
# ── Message deletion (app -> agent) ───────────────────────────────────
|
||||||
|
|
||||||
|
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Handle an inbound ``message.delete`` request.
|
||||||
|
|
||||||
|
Completely deletes the requested message(s): they are removed from the
|
||||||
|
outbox (so ``history`` and ``sync`` no longer return them) **and** from
|
||||||
|
the hermes session store (so no search trace survives and they are not
|
||||||
|
recoverable). ``message.deleted`` is broadcast to every device
|
||||||
|
(outboxed too, so an offline device learns of the deletion on its next
|
||||||
|
``sync``). Deleting is idempotent: a message that is already gone
|
||||||
|
(pruned by retention) simply yields 0 removed rows, and the
|
||||||
|
``message.deleted`` broadcast is still emitted so live caches drop it.
|
||||||
|
"""
|
||||||
|
payload = frame.payload
|
||||||
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
chat_id = chat_id.strip()
|
||||||
|
thread_id = frame.thread_id or payload.get("thread_id")
|
||||||
|
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||||
|
thread_id = None
|
||||||
|
message_ids = payload.get("message_ids")
|
||||||
|
if not isinstance(message_ids, list):
|
||||||
|
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
|
||||||
|
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
|
||||||
|
if not message_ids:
|
||||||
|
await self._reply(
|
||||||
|
device_id,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
removed = 0
|
||||||
|
purged = 0
|
||||||
|
db_path = get_hermes_home() / "state.db"
|
||||||
|
for mid in message_ids:
|
||||||
|
# Read the final frame data first (role / text / ts) so the
|
||||||
|
# session-store row can be matched, then drop the outbox frames.
|
||||||
|
info = self._outbox.message_info(chat_id, mid, thread_id=thread_id)
|
||||||
|
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
|
||||||
|
if info:
|
||||||
|
purged += purge_bridge.delete_message(
|
||||||
|
db_path,
|
||||||
|
chat_id,
|
||||||
|
thread_id,
|
||||||
|
info.get("role") or "",
|
||||||
|
info.get("text") or "",
|
||||||
|
info.get("ts"),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"iris: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||||
|
device_id,
|
||||||
|
chat_id,
|
||||||
|
thread_id,
|
||||||
|
message_ids,
|
||||||
|
removed,
|
||||||
|
purged,
|
||||||
|
)
|
||||||
|
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._broadcast_or_log(chat_id, resp)
|
||||||
|
|
||||||
|
# ── M5: push token registration ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Update the device's push tokens (FCM rotation / ntfy topic).
|
||||||
|
|
||||||
|
Persists to the device registry so the next push targets the current
|
||||||
|
token without a stale read.
|
||||||
|
"""
|
||||||
|
fcm_token = frame.payload.get("fcm_token")
|
||||||
|
ntfy_topic = frame.payload.get("ntfy_topic")
|
||||||
|
fcm_token = fcm_token if isinstance(fcm_token, str) and fcm_token else None
|
||||||
|
ntfy_topic = ntfy_topic if isinstance(ntfy_topic, str) and ntfy_topic else None
|
||||||
|
if fcm_token is None and ntfy_topic is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("iris: fcm.register update failed", exc_info=True)
|
||||||
|
return
|
||||||
|
logger.info("iris: push tokens updated for %s", device_id)
|
||||||
|
|
||||||
|
# ── Interactive pickers (slash-command choice menus) ─────────────────
|
||||||
|
|
||||||
|
async def on_picker_select(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Resolve a pending choice picker (``picker.select`` from the app).
|
||||||
|
|
||||||
|
Runs the command's selection callback and delivers its reply text as
|
||||||
|
a normal final message in the picker's chat. Unknown/expired picker
|
||||||
|
ids (gateway restart, double tap) are a no-op — the app already
|
||||||
|
marked the card resolved locally.
|
||||||
|
"""
|
||||||
|
picker_id = frame.payload.get("picker_id")
|
||||||
|
value = frame.payload.get("value")
|
||||||
|
if not isinstance(picker_id, str) or not isinstance(value, str):
|
||||||
|
return
|
||||||
|
state = self._pending_pickers.pop(picker_id, None)
|
||||||
|
if state is None:
|
||||||
|
logger.info("iris: picker.select for unknown/expired picker %s", picker_id)
|
||||||
|
return
|
||||||
|
callback = state.get("on_choice_selected")
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result_text = await callback(state["chat_id"], value)
|
||||||
|
except Exception:
|
||||||
|
logger.error("iris: picker selection failed for %s", picker_id, exc_info=True)
|
||||||
|
return
|
||||||
|
if not result_text:
|
||||||
|
return
|
||||||
|
await self.send(
|
||||||
|
state["chat_id"],
|
||||||
|
str(result_text),
|
||||||
|
metadata={"notify": True, "thread_id": state.get("thread_id")},
|
||||||
|
)
|
||||||
+20
-11
@@ -4,11 +4,17 @@
|
|||||||
# hermes-agent/.venv/bin/python -m ruff check gateway-plugin
|
# hermes-agent/.venv/bin/python -m ruff check gateway-plugin
|
||||||
#
|
#
|
||||||
# The rule set is deliberately broad (pycodestyle, pyflakes, isort, pyupgrade,
|
# The rule set is deliberately broad (pycodestyle, pyflakes, isort, pyupgrade,
|
||||||
# bugbear, flake8-simplify, pylint, return, comprehensions). Thresholds below
|
# bugbear, flake8-simplify, pylint, return, comprehensions).
|
||||||
# reflect the plugin's real shape: it is a single large dispatch surface
|
#
|
||||||
# (adapter.py) plus a wire-protocol layer (protocol.py) whose frame builders
|
# The pylint complexity ceilings (PLR0911/0912/0913/0915) are left at Ruff's
|
||||||
# mirror the schema, so the complexity ceilings are set just above the current
|
# built-in defaults (see [lint.pylint]). We deliberately do NOT raise them to
|
||||||
# maxima rather than an idealized small-function target.
|
# "just above the current maxima": that ratchets the bar down every time code
|
||||||
|
# grows (LLM maintenance adds functions, it does not refactor them), so new
|
||||||
|
# complex code would silently pass. Instead, the handful of genuinely complex
|
||||||
|
# functions that already exist (frame builders that mirror the wire schema,
|
||||||
|
# the QR matrix builder, the dispatch table) carry an explicit
|
||||||
|
# `# noqa: PLR09xx` marking them as reviewed, frozen exceptions. New code is
|
||||||
|
# held to the default ceilings.
|
||||||
|
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
||||||
@@ -32,14 +38,17 @@ select = [
|
|||||||
ignore = ["PLC0415"]
|
ignore = ["PLC0415"]
|
||||||
|
|
||||||
[lint.pylint]
|
[lint.pylint]
|
||||||
# Current maxima in the codebase: 22 branches, 64 statements, 9 returns,
|
# Ruff's built-in defaults. Existing outliers are noqa'd at the def line
|
||||||
# 8 args (protocol.py:252 frame builder is the lone 11-arg outlier, noqa'd).
|
# (search for `# noqa: PLR09`), not absorbed into a raised ceiling.
|
||||||
max-branches = 24
|
max-branches = 12
|
||||||
max-statements = 70
|
max-statements = 50
|
||||||
max-returns = 9
|
max-returns = 6
|
||||||
max-args = 8
|
max-args = 5
|
||||||
|
|
||||||
[lint.per-file-ignores]
|
[lint.per-file-ignores]
|
||||||
# The e2e / ws_probe drivers are assertion scripts: scenario numbers and
|
# The e2e / ws_probe drivers are assertion scripts: scenario numbers and
|
||||||
# control-flow sprawl are intentional and not worth refactoring.
|
# control-flow sprawl are intentional and not worth refactoring.
|
||||||
"tests/**" = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLW1510"]
|
"tests/**" = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLW1510"]
|
||||||
|
# The device-admin CLI is a small operator script: argv length checks are
|
||||||
|
# its natural shape.
|
||||||
|
"tools/**" = ["PLR2004"]
|
||||||
@@ -116,7 +116,7 @@ def _row_to_hit(row: sqlite3.Row) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fts_query(
|
def _fts_query( # noqa: PLR0913
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
query: str,
|
query: str,
|
||||||
scope: str,
|
scope: str,
|
||||||
@@ -153,7 +153,7 @@ def _fts_query(
|
|||||||
return [_row_to_hit(r) for r in rows]
|
return [_row_to_hit(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _like_query(
|
def _like_query( # noqa: PLR0913
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
query: str,
|
query: str,
|
||||||
scope: str,
|
scope: str,
|
||||||
@@ -197,7 +197,7 @@ def _like_query(
|
|||||||
return [_row_to_hit(r) for r in rows]
|
return [_row_to_hit(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def search(
|
def search( # noqa: PLR0913
|
||||||
db_path: Path,
|
db_path: Path,
|
||||||
query: str,
|
query: str,
|
||||||
scope: str = "all",
|
scope: str = "all",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Scope-aware secret reads for the iris plugin.
|
||||||
|
|
||||||
|
Shared by ``adapter.py`` (token / TLS / FCM credentials) and ``setup.py``
|
||||||
|
(config probes + env enablement).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
|
||||||
|
from agent.secret_scope import get_secret as _scoped_get_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scoped_secret(name, default=None):
|
||||||
|
"""Scope-aware credential read with the default-profile startup fallback.
|
||||||
|
|
||||||
|
Secondary profiles construct their adapters under a profile secret scope
|
||||||
|
-- the scope is authoritative and a scoped miss returns ``default`` (no
|
||||||
|
cross-profile borrow from ``os.environ``, which may hold another
|
||||||
|
profile's value). The DEFAULT profile's adapter constructs and sends
|
||||||
|
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
|
||||||
|
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
|
||||||
|
profile's own value, so fall back to it. Same pattern as the IRC
|
||||||
|
``IRC_SERVER_PASSWORD`` read (``plugins/platforms/irc/adapter.py``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
val = _scoped_get_secret(name, default)
|
||||||
|
except _UnscopedSecretError:
|
||||||
|
val = os.getenv(name)
|
||||||
|
return val if val is not None else default
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"""Interactive setup, passive config probes, env-driven auto-configuration.
|
||||||
|
|
||||||
|
``interactive_setup`` is the ``hermes gateway setup`` flow (token, host,
|
||||||
|
port, push backend, pairing QR, device removal). ``check_requirements`` /
|
||||||
|
``validate_config`` / ``is_connected`` are the passive probes the platform
|
||||||
|
registry calls from status displays. ``_env_enablement`` seeds
|
||||||
|
``PlatformConfig.extra`` from env vars before adapter construction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
from . import qr
|
||||||
|
from .channels import get_directory
|
||||||
|
from .defaults import (
|
||||||
|
DEFAULT_HOME_CHANNEL_NAME,
|
||||||
|
DEFAULT_HOST,
|
||||||
|
DEFAULT_HTTP_PORT,
|
||||||
|
DEFAULT_PUSH_BACKEND,
|
||||||
|
)
|
||||||
|
from .pairing import (
|
||||||
|
DeviceRegistry,
|
||||||
|
advertise_host,
|
||||||
|
generate_token,
|
||||||
|
pairing_url,
|
||||||
|
qr_payload,
|
||||||
|
)
|
||||||
|
from .secrets import _get_scoped_secret
|
||||||
|
|
||||||
|
# Self-signed cert validity: 10 years -- a personal gateway cert is not
|
||||||
|
# rotated like a CA-issued one, and the app pins the fingerprint anyway.
|
||||||
|
_TLS_CERT_DAYS = 3650
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Passive / config probes (called from status displays -- no side effects)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def check_requirements() -> bool:
|
||||||
|
"""PASSIVE dependency probe: token set.
|
||||||
|
|
||||||
|
Must be side-effect free (called from ``hermes setup`` / ``status`` /
|
||||||
|
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
|
||||||
|
so there is no extra dependency to probe.
|
||||||
|
"""
|
||||||
|
return bool(_get_scoped_secret("IRIS_TOKEN"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config) -> bool:
|
||||||
|
"""Given a PlatformConfig, is the platform properly configured?"""
|
||||||
|
extra = getattr(config, "extra", {}) or {}
|
||||||
|
token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
|
||||||
|
return bool(token)
|
||||||
|
|
||||||
|
|
||||||
|
def is_connected(config) -> bool:
|
||||||
|
"""Is the platform configured (env or config.yaml)?"""
|
||||||
|
return validate_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Env-driven auto-configuration (seeds PlatformConfig.extra pre-adapter)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _env_enablement() -> dict | None:
|
||||||
|
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
|
||||||
|
|
||||||
|
Called by the platform registry's env-enablement hook BEFORE adapter
|
||||||
|
construction, so ``gateway status`` and ``get_connected_platforms()``
|
||||||
|
reflect env-only configuration without instantiating the adapter.
|
||||||
|
Returns ``None`` when the platform isn't minimally configured (no token);
|
||||||
|
the caller then skips auto-enabling.
|
||||||
|
|
||||||
|
The special ``home_channel`` key in the returned dict is handled by the
|
||||||
|
core hook -- it becomes a proper ``HomeChannel`` dataclass on the
|
||||||
|
``PlatformConfig`` rather than being merged into ``extra``.
|
||||||
|
"""
|
||||||
|
token = _get_scoped_secret("IRIS_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Seed ONLY explicitly-set env vars: the core commits this seed on top of
|
||||||
|
# config.yaml (``extra.update(seed)``), so default values here would
|
||||||
|
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
||||||
|
# defaults.
|
||||||
|
seed: dict[str, Any] = {}
|
||||||
|
host = os.getenv("IRIS_HTTP_HOST", "").strip()
|
||||||
|
if host:
|
||||||
|
seed["host"] = host
|
||||||
|
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
|
||||||
|
if http_port_raw:
|
||||||
|
seed["http_port"] = _parse_port(http_port_raw)
|
||||||
|
push = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower()
|
||||||
|
if push:
|
||||||
|
seed["push_backend"] = push
|
||||||
|
home = os.getenv("IRIS_HOME_CHANNEL", "").strip()
|
||||||
|
if home:
|
||||||
|
seed["home_channel"] = {
|
||||||
|
"chat_id": home,
|
||||||
|
"name": os.getenv("IRIS_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
||||||
|
}
|
||||||
|
return seed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_port(raw: str) -> int:
|
||||||
|
try:
|
||||||
|
return int((raw or "").strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return DEFAULT_HTTP_PORT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Target parsing: "<chat_id>[:<thread>]" (platform prefix stripped by core)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_target_ref(target_ref: str) -> tuple | None: # noqa: PLR0911
|
||||||
|
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
|
||||||
|
|
||||||
|
The core strips the platform prefix before calling us, so the native
|
||||||
|
syntax is simply ``<chat_id>[:<thread>]`` (e.g. ``chan_7`` or
|
||||||
|
``chan_7:t_31``); the home channel is ``default``. Chat ids are direct
|
||||||
|
(no embedded platform prefix), so a cron delivery reads
|
||||||
|
``iris:chan_7`` end to end. A bare friendly name (e.g. ``Cron Reports``)
|
||||||
|
is resolved against the channel directory so cron / ``send_message`` can
|
||||||
|
target a channel by name immediately, without waiting for the core
|
||||||
|
directory's refresh timer. Returns ``None`` for anything unrecognised so
|
||||||
|
the target proceeds to the core channel-directory resolution.
|
||||||
|
"""
|
||||||
|
if not target_ref:
|
||||||
|
return None
|
||||||
|
t = target_ref.strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id: str | None = None
|
||||||
|
if ":" in t:
|
||||||
|
head, tail = t.rsplit(":", 1)
|
||||||
|
if head and tail.startswith("t_"):
|
||||||
|
thread_id = tail
|
||||||
|
t = head
|
||||||
|
else:
|
||||||
|
# Not a <chat>:<thread> pair -- treat the whole string as a name.
|
||||||
|
t = target_ref.strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Native chat id (default / chan_<n>) or any id known to the directory
|
||||||
|
# (covers custom IRIS_HOME_CHANNEL values).
|
||||||
|
try:
|
||||||
|
known = get_directory().get(t) is not None
|
||||||
|
except Exception:
|
||||||
|
known = False
|
||||||
|
if t == "default" or re.fullmatch(r"chan_\d+", t) or known:
|
||||||
|
return (t, thread_id)
|
||||||
|
|
||||||
|
# Bare friendly name -> resolve via the channel directory. A thread resolves
|
||||||
|
# to its session lane (parent_chat_id + thread_id); a channel/default to
|
||||||
|
# its chat_id.
|
||||||
|
try:
|
||||||
|
entry = get_directory().resolve_entry(t)
|
||||||
|
except Exception:
|
||||||
|
entry = None
|
||||||
|
if entry is not None:
|
||||||
|
if entry["kind"] == "thread":
|
||||||
|
return (entry["parent_chat_id"], entry["chat_id"])
|
||||||
|
return (entry["chat_id"], None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Standalone (out-of-process) send -- best-effort, stretch for v1
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _standalone_send( # noqa: PLR0913
|
||||||
|
pconfig,
|
||||||
|
chat_id: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
thread_id: str | None = None,
|
||||||
|
media_files: list[str] | None = None,
|
||||||
|
force_document: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Out-of-process delivery for cron jobs that run separately from the
|
||||||
|
gateway.
|
||||||
|
|
||||||
|
The outbox is served by the *running* gateway, so standalone delivery
|
||||||
|
while the gateway process is fully down is best-effort only (see
|
||||||
|
``docs/00-overview.md`` "Out of scope"). For M1 this is a stub that
|
||||||
|
reports the gateway is required; the real implementation lands with the
|
||||||
|
outbox (M3/M5).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"error": (
|
||||||
|
"iris standalone send: the running gateway is required to serve "
|
||||||
|
"the outbox (standalone delivery is best-effort only)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verbose tool progress (full args on the progress line)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_verbose_tool_progress() -> None:
|
||||||
|
"""Ensure the iris platform renders tool progress in ``verbose`` mode.
|
||||||
|
|
||||||
|
Verbose mode makes the gateway's tool-progress line carry the FULL
|
||||||
|
argument JSON (not just a ~40-char preview), which the adapter parses
|
||||||
|
into the ``tool.start`` frame's ``args`` field; the app then decides how
|
||||||
|
much to show (Settings → Tool detail). The tool *output* is captured
|
||||||
|
separately via the ``post_tool_call`` hook (verbose mode does not stream
|
||||||
|
it).
|
||||||
|
|
||||||
|
Best-effort and idempotent: writes
|
||||||
|
``display.platforms.iris.tool_progress: verbose`` to config.yaml only
|
||||||
|
when it isn't already set. The gateway's config cache is mtime-keyed, so
|
||||||
|
the write takes effect on the next turn without a restart. Never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config_readonly
|
||||||
|
|
||||||
|
cfg = load_config_readonly() or {}
|
||||||
|
display = cfg.get("display") or {}
|
||||||
|
platforms = display.get("platforms") or {}
|
||||||
|
iris_cfg = platforms.get("iris") or {}
|
||||||
|
if iris_cfg.get("tool_progress") == "verbose":
|
||||||
|
return # already set
|
||||||
|
from utils import atomic_roundtrip_yaml_update
|
||||||
|
|
||||||
|
atomic_roundtrip_yaml_update(
|
||||||
|
get_hermes_home() / "config.yaml",
|
||||||
|
"display.platforms.iris.tool_progress",
|
||||||
|
"verbose",
|
||||||
|
)
|
||||||
|
logger.info("iris: set display.platforms.iris.tool_progress=verbose")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("iris: could not ensure verbose tool_progress", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Interactive setup (hermes gateway setup flow)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _offer_device_removal() -> None:
|
||||||
|
"""Setup-flow device management (docs/09 §9.3): if devices are already
|
||||||
|
paired, offer to revoke one. Revocation is server-side — no access to
|
||||||
|
the device is needed: its per-device token is deleted and its id is
|
||||||
|
denylisted, so even the shared token no longer authenticates it.
|
||||||
|
|
||||||
|
Flow: ask (default No) → numbered select menu (last option = exit the
|
||||||
|
removal loop, NOT the setup) → confirmation → back to the menu, so
|
||||||
|
several devices can be removed in a row.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.cli_output import (
|
||||||
|
print_info,
|
||||||
|
print_success,
|
||||||
|
prompt,
|
||||||
|
prompt_yes_no,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
reg = DeviceRegistry(get_hermes_home() / "iris" / "devices.db")
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
devices = reg.list()
|
||||||
|
if not devices:
|
||||||
|
return
|
||||||
|
if not prompt_yes_no("Remove a paired device?", default=False):
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
print_info("Paired devices:")
|
||||||
|
for i, d in enumerate(devices, 1):
|
||||||
|
last_seen = time.strftime("%Y-%m-%d %H:%M", time.localtime(d["last_seen"]))
|
||||||
|
print_info(f" {i}. {d['name']} ({d['device_id']}) last seen {last_seen}")
|
||||||
|
exit_idx = len(devices) + 1
|
||||||
|
print_info(f" {exit_idx}. Exit")
|
||||||
|
# Default = exit: pressing Enter leaves the removal loop (and
|
||||||
|
# continues the setup) without removing anything.
|
||||||
|
choice = prompt("Select a device to remove", default=str(exit_idx))
|
||||||
|
idx = int(choice) if choice.isdigit() else exit_idx
|
||||||
|
if idx < 1 or idx >= exit_idx:
|
||||||
|
return
|
||||||
|
target = devices[idx - 1]
|
||||||
|
if not prompt_yes_no(
|
||||||
|
f"Remove {target['name']} ({target['device_id']})? It will no longer "
|
||||||
|
"be able to connect (shared token included).",
|
||||||
|
default=False,
|
||||||
|
):
|
||||||
|
continue # back to the select menu
|
||||||
|
reg.revoke(target["device_id"])
|
||||||
|
devices = [d for d in devices if d["device_id"] != target["device_id"]]
|
||||||
|
print_success(f"Removed {target['device_id']} \u2014 it can no longer connect.")
|
||||||
|
if not devices:
|
||||||
|
print_info("No paired devices left.")
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
reg.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_self_signed_cert(
|
||||||
|
cert_path, key_path, san_entries: list[str], days: int = _TLS_CERT_DAYS
|
||||||
|
) -> str:
|
||||||
|
"""Generate a self-signed RSA-2048 cert + key with the given SANs.
|
||||||
|
|
||||||
|
Uses ``cryptography`` (a core hermes dependency, already used by
|
||||||
|
``push.py``) -- no new dependency, no ``openssl`` binary required.
|
||||||
|
Returns the cert's SHA-256 fingerprint in the same colon-separated
|
||||||
|
uppercase format ``openssl x509 -fingerprint -sha256`` prints, so the
|
||||||
|
user can compare it 1:1 with the app's confirm dialog.
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "iris")])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
san = x509.SubjectAlternativeName(
|
||||||
|
[
|
||||||
|
x509.IPAddress(ipaddress.ip_address(entry)) if _is_ip(entry) else x509.DNSName(entry)
|
||||||
|
for entry in san_entries
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(name)
|
||||||
|
.issuer_name(name)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
# Backdate one day: clock skew on the phone must not break the pin.
|
||||||
|
.not_valid_before(now - timedelta(days=1))
|
||||||
|
.not_valid_after(now + timedelta(days=days))
|
||||||
|
.add_extension(san, critical=False)
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
key_pem = key.private_bytes(
|
||||||
|
serialization.Encoding.PEM,
|
||||||
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
# Create the key 0600 from the start -- write_bytes() would leave a
|
||||||
|
# brief window where the fresh private key sits at the default umask
|
||||||
|
# (0644). The chmod also covers the pre-existing-file case.
|
||||||
|
fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(key_pem)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
digest = hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).digest()
|
||||||
|
return ":".join(f"{b:02X}" for b in digest)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip(entry: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(entry)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _offer_tls_setup(host: str, advertised: str) -> None:
|
||||||
|
"""Offer to generate a self-signed TLS cert (install.md Part 4, Option B).
|
||||||
|
|
||||||
|
Only runs when ``IRIS_HTTP_CERT`` is not already set. Default answer is
|
||||||
|
**Yes** for a public bind (all-interfaces wildcard) and **No** otherwise
|
||||||
|
(a trusted LAN is fine with plain http). On acceptance the cert/key are
|
||||||
|
written to ``~/.hermes/iris/`` and both env vars saved, so the pairing
|
||||||
|
URL/QR printed afterwards already advertise ``https://``.
|
||||||
|
|
||||||
|
Best-effort: any failure (missing ``cryptography``, unwritable dir or
|
||||||
|
.env) only warns -- setup never fails because of TLS.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.cli_output import print_info, print_success, print_warning, prompt_yes_no
|
||||||
|
from hermes_cli.config import get_env_value, save_env_value
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if (get_env_value("IRIS_HTTP_CERT") or "").strip():
|
||||||
|
return # TLS already configured -- leave the user's cert alone.
|
||||||
|
|
||||||
|
# Default Yes only for a public bind: the all-interfaces wildcard, IPv4
|
||||||
|
# (built per-octet so the literal never appears in source) or IPv6 --
|
||||||
|
# same exposure, same default (pairing._unroutable treats both as
|
||||||
|
# unroutable wildcards).
|
||||||
|
public_bind = host.split(".") == ["0", "0", "0", "0"] or host in ("::", "[::]")
|
||||||
|
if not prompt_yes_no(
|
||||||
|
"Set up TLS now? Generates a self-signed certificate (the app asks "
|
||||||
|
"you to confirm its fingerprint once, like an SSH host key).",
|
||||||
|
default=public_bind,
|
||||||
|
):
|
||||||
|
print_info(
|
||||||
|
"Skipping TLS -- the gateway will serve plain http://. Re-run "
|
||||||
|
"setup later or set IRIS_HTTP_CERT/IRIS_HTTP_KEY manually."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# SANs: the advertised host (what the app will type), the machine's
|
||||||
|
# hostname, and loopback -- deduped, order preserved. Bind wildcards
|
||||||
|
# are not addressable, so they never become SANs.
|
||||||
|
san_entries: list[str] = []
|
||||||
|
for entry in (advertised, host, socket.gethostname(), "localhost", "127.0.0.1"):
|
||||||
|
if not entry or entry in san_entries:
|
||||||
|
continue
|
||||||
|
if entry in ("::", "[::]") or entry.split(".") == ["0", "0", "0", "0"]:
|
||||||
|
continue
|
||||||
|
san_entries.append(entry)
|
||||||
|
|
||||||
|
try:
|
||||||
|
iris_dir = get_hermes_home() / "iris"
|
||||||
|
iris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cert_path = iris_dir / "iris.crt"
|
||||||
|
key_path = iris_dir / "iris.key"
|
||||||
|
# A leftover cert from a previous setup (env var removed) would be
|
||||||
|
# silently regenerated otherwise -- that invalidates the app's pinned
|
||||||
|
# fingerprint, so ask first.
|
||||||
|
if cert_path.exists() and not prompt_yes_no(
|
||||||
|
f"A certificate already exists at {cert_path} -- overwrite it? "
|
||||||
|
"(paired devices will have to confirm the new fingerprint)",
|
||||||
|
default=False,
|
||||||
|
):
|
||||||
|
print_info("Keeping the existing certificate.")
|
||||||
|
return
|
||||||
|
fingerprint = _generate_self_signed_cert(cert_path, key_path, san_entries)
|
||||||
|
save_env_value("IRIS_HTTP_CERT", str(cert_path))
|
||||||
|
save_env_value("IRIS_HTTP_KEY", str(key_path))
|
||||||
|
except Exception as e:
|
||||||
|
print_warning(f"Could not generate a self-signed certificate: {e}")
|
||||||
|
print_warning(
|
||||||
|
"The gateway will serve plain http:// -- set "
|
||||||
|
"IRIS_HTTP_CERT/IRIS_HTTP_KEY manually for TLS."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print_success(f"Self-signed certificate written to {cert_path} (key: {key_path})")
|
||||||
|
print_info(f"SHA-256 fingerprint: {fingerprint}")
|
||||||
|
print_info(
|
||||||
|
"The app will show this fingerprint on first connect -- compare and "
|
||||||
|
"confirm it there (it is then pinned)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_setup() -> None:
|
||||||
|
"""Prompt for the pairing token / host / port / push backend.
|
||||||
|
|
||||||
|
M1: token generation, host/port/push prompts, and the pairing QR payload
|
||||||
|
(``iris://pair?...``) + app URL printed for the Connect screen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.cli_output import (
|
||||||
|
print_info,
|
||||||
|
print_success,
|
||||||
|
print_warning,
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
from hermes_cli.config import get_env_value, save_env_value
|
||||||
|
except Exception:
|
||||||
|
print(f"iris: setup helpers unavailable; set IRIS_TOKEN in {get_hermes_home() / '.env'}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print_info("📱 Android / Desktop (Iris x Hermes)")
|
||||||
|
token = get_env_value("IRIS_TOKEN") or ""
|
||||||
|
if not token:
|
||||||
|
token = generate_token()
|
||||||
|
save_env_value("IRIS_TOKEN", token)
|
||||||
|
print_success(f"Generated pairing token: {token}")
|
||||||
|
print_warning("Keep this secret -- the app presents it on connect.")
|
||||||
|
else:
|
||||||
|
print_info("Existing IRIS_TOKEN found (not shown).")
|
||||||
|
|
||||||
|
# Device management (docs/09 §9.3): on an existing setup, offer to cut
|
||||||
|
# off a lost/compromised device before continuing with the config.
|
||||||
|
_offer_device_removal()
|
||||||
|
|
||||||
|
host = prompt("Bind host", default=get_env_value("IRIS_HTTP_HOST") or DEFAULT_HOST)
|
||||||
|
save_env_value("IRIS_HTTP_HOST", host or DEFAULT_HOST)
|
||||||
|
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
|
||||||
|
port = prompt(
|
||||||
|
"HTTP port",
|
||||||
|
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
|
||||||
|
)
|
||||||
|
save_env_value("IRIS_HTTP_PORT", str(_parse_port(port)))
|
||||||
|
backend = prompt(
|
||||||
|
"Push backend (ntfy/fcm)",
|
||||||
|
default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
||||||
|
)
|
||||||
|
backend = (backend or DEFAULT_PUSH_BACKEND).strip().lower()
|
||||||
|
save_env_value("IRIS_PUSH_BACKEND", backend)
|
||||||
|
if backend == "fcm":
|
||||||
|
print_warning(
|
||||||
|
"FCM push metadata (notification title, device token) is routed "
|
||||||
|
"through Google's servers. For truly private communication use "
|
||||||
|
"ntfy (self-hosted) instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pairing payload for the app's Connect screen (manual entry + QR scan).
|
||||||
|
# Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
|
||||||
|
# replaced by the default-route LAN IP so the QR points somewhere a phone
|
||||||
|
# can actually reach (the user can still override the Server URL in-app).
|
||||||
|
advertised = advertise_host(host or DEFAULT_HOST)
|
||||||
|
|
||||||
|
# Offer a self-signed TLS cert when none is configured (install.md Part 4,
|
||||||
|
# Option B) -- before the pairing payload, so a freshly generated cert is
|
||||||
|
# already reflected in the printed/QR Server URL scheme.
|
||||||
|
_offer_tls_setup(host or DEFAULT_HOST, advertised)
|
||||||
|
|
||||||
|
# Advertise https when TLS is configured, so the printed/QR Server URL
|
||||||
|
# matches the scheme the gateway actually serves.
|
||||||
|
secure = bool((get_env_value("IRIS_HTTP_CERT") or "").strip())
|
||||||
|
url = pairing_url(advertised, _parse_port(port), secure=secure)
|
||||||
|
pairing = qr_payload(advertised, _parse_port(port), token, secure=secure)
|
||||||
|
print_info("Pair your device (enter this on the app's Connect screen):")
|
||||||
|
print_info(f"Pairing URL: {pairing}")
|
||||||
|
print_info(f"Server URL: {url}")
|
||||||
|
if advertised != (host or DEFAULT_HOST):
|
||||||
|
print_info(
|
||||||
|
f"QR points to {advertised} (your default LAN address). If your "
|
||||||
|
"phone is on a different network, change the Server URL in the app."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scannable QR (docs/20): the same payload as a terminal QR. The URL text
|
||||||
|
# lines stay — the QR is a convenience, not a replacement (non-UTF-8
|
||||||
|
# terminals still work, and the text is copy-pasteable). render_qr returns
|
||||||
|
# '' (not an exception) when the payload is too long to encode.
|
||||||
|
qr_block = qr.render_qr(pairing)
|
||||||
|
if qr_block:
|
||||||
|
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
|
||||||
|
print(qr_block)
|
||||||
|
else:
|
||||||
|
print_warning("QR too large to render; use the pairing URL above.")
|
||||||
|
|
||||||
|
# Always render tool progress verbosely so the app receives the full tool
|
||||||
|
# call args (it decides how much to show via Settings → Tool detail).
|
||||||
|
_ensure_verbose_tool_progress()
|
||||||
|
|
||||||
|
print_success(f"Iris configuration saved to {get_hermes_home() / '.env'}")
|
||||||
|
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Tool-progress frame emission (M2): the tool.start / tool.end lifecycle.
|
||||||
|
|
||||||
|
Mixin for ``adapter.IrisAdapter``. The gateway accumulates tool lines in one
|
||||||
|
editable bubble; on an edit the full buffer is re-sent, so new lines are
|
||||||
|
diffed against ``seen_tool_lines`` and each new tool closes the previously
|
||||||
|
open one (attaching the output/duration captured by the ``post_tool_call``
|
||||||
|
hook).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gateway.platforms.base import SendResult
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
from .classify import (
|
||||||
|
_extract_code_block,
|
||||||
|
_extract_verbose_args,
|
||||||
|
_mint_message_id,
|
||||||
|
_parse_tool_line,
|
||||||
|
_short_preview_from_args,
|
||||||
|
_TurnState,
|
||||||
|
)
|
||||||
|
from .hooks import _reset_tool_results, _tool_emoji, _tool_end_fields
|
||||||
|
from .mixin_base import IrisAdapterBase
|
||||||
|
|
||||||
|
|
||||||
|
class ToolProgressHandlers(IrisAdapterBase):
|
||||||
|
"""Tool-progress lifecycle (see module docstring)."""
|
||||||
|
|
||||||
|
async def _emit_tool_lines(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
content: str,
|
||||||
|
state: _TurnState,
|
||||||
|
thread_id: str | None,
|
||||||
|
*,
|
||||||
|
is_edit: bool,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Emit ``tool.start`` for each NEW tool line in *content*.
|
||||||
|
|
||||||
|
The gateway accumulates tool lines in one editable bubble; on an edit
|
||||||
|
the full buffer is re-sent, so we diff against ``seen_tool_lines`` to
|
||||||
|
emit only the new ones. A new tool closes the previously-open tool.
|
||||||
|
"""
|
||||||
|
message_id = state.tool_msg_id or _mint_message_id()
|
||||||
|
state.tool_msg_id = message_id
|
||||||
|
state.active = True
|
||||||
|
# Tool activity marks this lane as the turn in flight: the global
|
||||||
|
# post_tool_call hook (no chat id) routes todo emissions here.
|
||||||
|
self._active_lane = (chat_id, thread_id)
|
||||||
|
|
||||||
|
lines = [ln for ln in content.splitlines() if ln.strip()]
|
||||||
|
for line in lines:
|
||||||
|
key = line.strip()
|
||||||
|
if key in state.seen_tool_lines:
|
||||||
|
continue
|
||||||
|
state.seen_tool_lines.add(key)
|
||||||
|
parsed = self._parse_tool_line_or_block(line, content)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
name, preview, args = parsed
|
||||||
|
# A new tool begins: close the previously-open one, attaching the
|
||||||
|
# output/duration/ok captured by the post_tool_call hook.
|
||||||
|
if state.open_tool_index is not None:
|
||||||
|
extra = _tool_end_fields(state.open_tool_name or "")
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.tool_end(
|
||||||
|
chat_id,
|
||||||
|
state.open_tool_index,
|
||||||
|
state.open_tool_name or "",
|
||||||
|
ok=extra.get("ok", True),
|
||||||
|
duration=extra.get("duration"),
|
||||||
|
output_preview=extra.get("output_preview"),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
state.tool_index += 1
|
||||||
|
state.open_tool_index = state.tool_index
|
||||||
|
state.open_tool_name = name
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.tool_start(
|
||||||
|
chat_id,
|
||||||
|
state.tool_index,
|
||||||
|
name,
|
||||||
|
preview=preview,
|
||||||
|
args=args,
|
||||||
|
emoji=_tool_emoji(name),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_tool_line_or_block(
|
||||||
|
line: str, content: str
|
||||||
|
) -> tuple[str, str | None, dict[str, Any] | None] | None:
|
||||||
|
"""Parse a tool line into ``(name, preview, args)``.
|
||||||
|
|
||||||
|
Expands a terminal code block to its command, and a verbose header
|
||||||
|
(``<emoji> <name>(keys)``) to its full args JSON (the JSON sits on the
|
||||||
|
following line). ``args`` is ``None`` unless the line is a verbose
|
||||||
|
header with a parseable JSON body.
|
||||||
|
"""
|
||||||
|
parsed = _parse_tool_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
name, preview = parsed
|
||||||
|
# Terminal code block: the command lives in the fenced lines that
|
||||||
|
# follow the "<emoji> terminal" head line.
|
||||||
|
if name == "terminal" and preview is None and "```" in content:
|
||||||
|
cmd = _extract_code_block(content)
|
||||||
|
if cmd:
|
||||||
|
return name, cmd, None
|
||||||
|
# Verbose mode: recover the full args from the following JSON line.
|
||||||
|
args = _extract_verbose_args(line, content)
|
||||||
|
if args is not None and preview is None:
|
||||||
|
preview = _short_preview_from_args(args)
|
||||||
|
return name, preview, args
|
||||||
|
|
||||||
|
async def _close_open_tool(
|
||||||
|
self, chat_id: str, state: _TurnState, thread_id: str | None
|
||||||
|
) -> None:
|
||||||
|
"""Emit ``tool.end`` for the currently-open tool, if any.
|
||||||
|
|
||||||
|
A tool is considered complete when the next tool starts OR a new
|
||||||
|
content segment begins (the model only produces content after the
|
||||||
|
tool it was waiting on has returned).
|
||||||
|
"""
|
||||||
|
if state.open_tool_index is not None:
|
||||||
|
extra = _tool_end_fields(state.open_tool_name or "")
|
||||||
|
await self._broadcast_or_log(
|
||||||
|
chat_id,
|
||||||
|
protocol.tool_end(
|
||||||
|
chat_id,
|
||||||
|
state.open_tool_index,
|
||||||
|
state.open_tool_name or "",
|
||||||
|
ok=extra.get("ok", True),
|
||||||
|
duration=extra.get("duration"),
|
||||||
|
output_preview=extra.get("output_preview"),
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
state.open_tool_index = None
|
||||||
|
state.open_tool_name = None
|
||||||
|
|
||||||
|
def _reset_tool_state(self, state: _TurnState) -> None:
|
||||||
|
"""Clear per-turn tool bookkeeping (called at turn finalization)."""
|
||||||
|
state.tool_msg_id = None
|
||||||
|
state.seen_tool_lines = set()
|
||||||
|
state.tool_index = 0
|
||||||
|
state.open_tool_index = None
|
||||||
|
state.open_tool_name = None
|
||||||
|
# Drop any captured tool results not consumed by a tool.end this turn
|
||||||
|
# (e.g. tool_progress off) so they can't leak into the next turn.
|
||||||
|
_reset_tool_results()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Iris device administration (docs/09 §9.3): list / revoke / re-pair devices.
|
||||||
|
|
||||||
|
Per-device tokens are minted automatically at pairing (the gateway returns
|
||||||
|
them in ``hello.ack.device_token``); this tool is the operator's control
|
||||||
|
surface for the registry under ``<hermes-home>/iris/devices.db``:
|
||||||
|
|
||||||
|
iris_devices.py list show paired devices + revoked ids
|
||||||
|
iris_devices.py revoke <device_id> revoke ONE device (its token stops
|
||||||
|
working AND the shared token no longer
|
||||||
|
authenticates it; other devices are
|
||||||
|
unaffected)
|
||||||
|
iris_devices.py unrevoke <device_id> allow the device to pair again
|
||||||
|
iris_devices.py reissue <device_id> rotate the device's token (the old
|
||||||
|
one stops working; the app picks up
|
||||||
|
the new one on its next (re)connect)
|
||||||
|
|
||||||
|
The hermes home is resolved like the gateway: ``HERMES_HOME`` env var, else
|
||||||
|
``~/.hermes`` (``hermes_constants.get_hermes_home`` when importable, so an
|
||||||
|
active profile is honored). Run it on the gateway host — the registry is
|
||||||
|
local state.
|
||||||
|
|
||||||
|
Zero dependencies (stdlib only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_USAGE = """\
|
||||||
|
usage: iris_devices.py <command> [device_id]
|
||||||
|
|
||||||
|
commands:
|
||||||
|
list show paired devices + revoked ids
|
||||||
|
revoke <device_id> revoke ONE device (its token stops working AND the
|
||||||
|
shared token no longer authenticates it; other
|
||||||
|
devices are unaffected)
|
||||||
|
unrevoke <device_id> allow the device to pair again
|
||||||
|
reissue <device_id> rotate the device's token (the old one stops working;
|
||||||
|
the app picks up the new one on its next (re)connect)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_dir() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_home() -> Path:
|
||||||
|
import os
|
||||||
|
|
||||||
|
env = os.environ.get("HERMES_HOME", "").strip()
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
try:
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
return Path(get_hermes_home())
|
||||||
|
except ImportError:
|
||||||
|
return Path.home() / ".hermes"
|
||||||
|
|
||||||
|
|
||||||
|
def _registry():
|
||||||
|
sys.path.insert(0, str(_plugin_dir()))
|
||||||
|
from pairing import DeviceRegistry
|
||||||
|
|
||||||
|
return DeviceRegistry(_hermes_home() / "iris" / "devices.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(ts: float) -> str:
|
||||||
|
try:
|
||||||
|
return time.strftime("%Y-%m-%d %H:%M", time.localtime(float(ts)))
|
||||||
|
except (TypeError, ValueError, OSError):
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(reg) -> int:
|
||||||
|
devices = reg.list()
|
||||||
|
revoked = reg.list_revoked()
|
||||||
|
if not devices and not revoked:
|
||||||
|
print("No paired devices.")
|
||||||
|
return 0
|
||||||
|
if devices:
|
||||||
|
print(f"{'DEVICE ID':<24} {'NAME':<24} {'TOKEN':<6} {'LAST SEEN':<17} CREATED")
|
||||||
|
for d in devices:
|
||||||
|
has_token = "yes" if reg.token_for(d["device_id"]) else "no"
|
||||||
|
print(
|
||||||
|
f"{d['device_id']:<24} {d['name'][:23]:<24} {has_token:<6} "
|
||||||
|
f"{_fmt_ts(d['last_seen']):<17} {_fmt_ts(d['created'])}"
|
||||||
|
)
|
||||||
|
if revoked:
|
||||||
|
print("\nRevoked (rejected even with the shared token):")
|
||||||
|
for r in revoked:
|
||||||
|
print(f" {r['device_id']} (revoked {_fmt_ts(r['revoked_at'])})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_revoke(reg, device_id: str) -> int:
|
||||||
|
if not reg.is_revoked(device_id) and reg.get(device_id) is None:
|
||||||
|
print(f"unknown device: {device_id}")
|
||||||
|
return 1
|
||||||
|
reg.revoke(device_id)
|
||||||
|
print(f"revoked {device_id} — it can no longer connect (shared token included).")
|
||||||
|
print("Re-pairing requires: unrevoke <device_id> (or the app gets a fresh device id).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_unrevoke(reg, device_id: str) -> int:
|
||||||
|
if not reg.is_revoked(device_id):
|
||||||
|
print(f"not revoked: {device_id}")
|
||||||
|
return 1
|
||||||
|
reg.unrevoke(device_id)
|
||||||
|
print(f"unrevoked {device_id} — it can pair again (a fresh token is minted).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_reissue(reg, device_id: str) -> int:
|
||||||
|
if reg.get(device_id) is None:
|
||||||
|
print(f"unknown device: {device_id}")
|
||||||
|
return 1
|
||||||
|
reg.reissue_token(device_id)
|
||||||
|
print(f"reissued the token for {device_id} — the old one is dead.")
|
||||||
|
print("The app picks up the new token on its next (re)connect (hello.ack).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
args = argv[1:]
|
||||||
|
if not args or args[0] in ("-h", "--help", "help"):
|
||||||
|
print(_USAGE.strip())
|
||||||
|
return 0 if args else 2
|
||||||
|
reg = _registry()
|
||||||
|
try:
|
||||||
|
cmd, rest = args[0], args[1:]
|
||||||
|
if cmd == "list":
|
||||||
|
return cmd_list(reg)
|
||||||
|
if cmd in ("revoke", "unrevoke", "reissue"):
|
||||||
|
if not rest or rest[1:]:
|
||||||
|
print(f"usage: {Path(sys.argv[0]).name} {cmd} <device_id>")
|
||||||
|
return 2
|
||||||
|
return {"revoke": cmd_revoke, "unrevoke": cmd_unrevoke, "reissue": cmd_reissue}[cmd](
|
||||||
|
reg, rest[0]
|
||||||
|
)
|
||||||
|
print(f"unknown command: {cmd}")
|
||||||
|
print(_USAGE.strip())
|
||||||
|
return 2
|
||||||
|
finally:
|
||||||
|
reg.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
@@ -0,0 +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). 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 _read_version_file(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Tests for the iris gateway plugin.
|
# Tests for the iris gateway plugin
|
||||||
|
|
||||||
Run via hermes's hermetic runner (never bare pytest)::
|
Run via hermes's hermetic runner (never bare pytest)::
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ Manual test-client harness: connects to the **real running gateway** and
|
|||||||
drives a turn, printing every frame. Run with the hermes venv python
|
drives a turn, printing every frame. Run with the hermes venv python
|
||||||
(needs `websockets`); the gateway must already be up::
|
(needs `websockets`); the gateway must already be up::
|
||||||
|
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
|
hermes-agent/.venv/bin/python tests/ws_probe.py \
|
||||||
--token <IRIS_TOKEN> --send "hello"
|
--token <IRIS_TOKEN> --send "hello"
|
||||||
|
|
||||||
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
||||||
@@ -68,9 +68,9 @@ possible against the live gateway, invoking `ws_probe.py` (and the
|
|||||||
`hermes` CLI for cron) as subprocesses. Prints PASS / PARTIAL / SKIP /
|
`hermes` CLI for cron) as subprocesses. Prints PASS / PARTIAL / SKIP /
|
||||||
FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
|
FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
|
||||||
|
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
|
hermes-agent/.venv/bin/python tests/e2e.py
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
hermes-agent/.venv/bin/python tests/e2e.py --skip 3,5,7
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
hermes-agent/.venv/bin/python tests/e2e.py --url http://host:8791
|
||||||
|
|
||||||
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
|
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
|
||||||
`~/.hermes/.env`. The gateway must already be running (the driver never
|
`~/.hermes/.env`. The gateway must already be running (the driver never
|
||||||
@@ -7,9 +7,9 @@ summary table. Exit 0 if no FAIL, 1 otherwise.
|
|||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
|
hermes-agent/.venv/bin/python tests/e2e.py
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
hermes-agent/.venv/bin/python tests/e2e.py --skip 3,5,7
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
hermes-agent/.venv/bin/python tests/e2e.py --url http://host:8791
|
||||||
|
|
||||||
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
|
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
|
||||||
~/.hermes/.env. The gateway must already be running (this driver never
|
~/.hermes/.env. The gateway must already be running (this driver never
|
||||||
@@ -36,11 +36,11 @@ from pathlib import Path
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
REPO = HERE.parent.parent
|
REPO = HERE.parent
|
||||||
PY = REPO / "hermes-agent" / ".venv" / "bin" / "python"
|
PY = REPO / "hermes-agent" / ".venv" / "bin" / "python"
|
||||||
PROBE = HERE / "ws_probe.py"
|
PROBE = HERE / "ws_probe.py"
|
||||||
HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes"
|
HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes"
|
||||||
DEFAULT_URL = "ws://127.0.0.1:8790/ws"
|
DEFAULT_URL = "http://127.0.0.1:8791"
|
||||||
|
|
||||||
PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
|
PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def find_token(cli_token: str) -> str:
|
def find_token(cli_token: str) -> str:
|
||||||
if cli_token:
|
if cli_token:
|
||||||
return cli_token
|
return cli_token
|
||||||
@@ -83,8 +84,12 @@ def write_png(path: Path, color, size: int = 200) -> None:
|
|||||||
raw = b"".join(b"\x00" + bytes(color) * size for _ in range(size))
|
raw = b"".join(b"\x00" + bytes(color) * size for _ in range(size))
|
||||||
|
|
||||||
def chunk(tag: bytes, data: bytes) -> bytes:
|
def chunk(tag: bytes, data: bytes) -> bytes:
|
||||||
return (struct.pack(">I", len(data)) + tag + data
|
return (
|
||||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
struct.pack(">I", len(data))
|
||||||
|
+ tag
|
||||||
|
+ data
|
||||||
|
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
|
||||||
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||||
path.write_bytes(
|
path.write_bytes(
|
||||||
@@ -122,6 +127,7 @@ def sweep_leftovers(env, url, token) -> None:
|
|||||||
# Scenarios (docs/13-testing.md §13.4)
|
# Scenarios (docs/13-testing.md §13.4)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def s1_pair(env, url, token):
|
def s1_pair(env, url, token):
|
||||||
rc, _, _ = run_probe(env, url, "definitely-wrong-token", "--authfail", "--send", "")
|
rc, _, _ = run_probe(env, url, "definitely-wrong-token", "--authfail", "--send", "")
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
@@ -134,8 +140,9 @@ def s1_pair(env, url, token):
|
|||||||
|
|
||||||
def s2_text(env, url, token):
|
def s2_text(env, url, token):
|
||||||
prompt = "Write a short poem about the ocean, at least 8 lines"
|
prompt = "Write a short poem about the ocean, at least 8 lines"
|
||||||
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
rc, _, _ = run_probe(
|
||||||
"--assert-turn", "--timeout", "120")
|
env, url, token, "--send", prompt, "--assert-turn", "--timeout", "120"
|
||||||
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return PASS, "message.start -> >=1 message.update -> message.stop"
|
return PASS, "message.start -> >=1 message.update -> message.stop"
|
||||||
if rc == 10:
|
if rc == 10:
|
||||||
@@ -145,8 +152,9 @@ def s2_text(env, url, token):
|
|||||||
|
|
||||||
def s3_reasoning(env, url, token):
|
def s3_reasoning(env, url, token):
|
||||||
prompt = "Work out step by step: what is 17 * 23? Show your reasoning."
|
prompt = "Work out step by step: what is 17 * 23? Show your reasoning."
|
||||||
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
rc, _, _ = run_probe(
|
||||||
"--assert-reasoning", "--timeout", "120")
|
env, url, token, "--send", prompt, "--assert-reasoning", "--timeout", "120"
|
||||||
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return PASS, "final message.stop carries non-empty reasoning"
|
return PASS, "final message.stop carries non-empty reasoning"
|
||||||
if rc == 11:
|
if rc == 11:
|
||||||
@@ -155,10 +163,13 @@ def s3_reasoning(env, url, token):
|
|||||||
|
|
||||||
|
|
||||||
def s4_tools(env, url, token):
|
def s4_tools(env, url, token):
|
||||||
prompt = ("List the files in your current working directory using your "
|
prompt = (
|
||||||
"shell tool, then tell me how many there are")
|
"List the files in your current working directory using your "
|
||||||
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
"shell tool, then tell me how many there are"
|
||||||
"--assert-tools", "--timeout", "150")
|
)
|
||||||
|
rc, _, _ = run_probe(
|
||||||
|
env, url, token, "--send", prompt, "--assert-tools", "--timeout", "150"
|
||||||
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return PASS, "tool.start with a matching tool.end"
|
return PASS, "tool.start with a matching tool.end"
|
||||||
if rc == 12:
|
if rc == 12:
|
||||||
@@ -167,12 +178,15 @@ def s4_tools(env, url, token):
|
|||||||
|
|
||||||
|
|
||||||
def s5_commentary(env, url, token):
|
def s5_commentary(env, url, token):
|
||||||
prompt = ("Research task: (1) use your shell tool to list the top-level "
|
prompt = (
|
||||||
|
"Research task: (1) use your shell tool to list the top-level "
|
||||||
"directories in /tmp, (2) report your findings so far, "
|
"directories in /tmp, (2) report your findings so far, "
|
||||||
"(3) use your shell tool to count files in /tmp, "
|
"(3) use your shell tool to count files in /tmp, "
|
||||||
"(4) report those findings too, (5) give a final summary of both")
|
"(4) report those findings too, (5) give a final summary of both"
|
||||||
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
)
|
||||||
"--assert-commentary", "--timeout", "150")
|
rc, _, _ = run_probe(
|
||||||
|
env, url, token, "--send", prompt, "--assert-commentary", "--timeout", "150"
|
||||||
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return PASS, "commentary frame observed"
|
return PASS, "commentary frame observed"
|
||||||
if rc == 13:
|
if rc == 13:
|
||||||
@@ -206,9 +220,15 @@ def s7_cron(env, url, token):
|
|||||||
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
|
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
|
||||||
deliver = f"iris:{chat_id}"
|
deliver = f"iris:{chat_id}"
|
||||||
rc, out, err = run_hermes(
|
rc, out, err = run_hermes(
|
||||||
env, "cron", "create", "1m",
|
env,
|
||||||
|
"cron",
|
||||||
|
"create",
|
||||||
|
"1m",
|
||||||
"Reply with exactly: e2e cron delivery OK",
|
"Reply with exactly: e2e cron delivery OK",
|
||||||
"--deliver", deliver, "--name", job_name,
|
"--deliver",
|
||||||
|
deliver,
|
||||||
|
"--name",
|
||||||
|
job_name,
|
||||||
)
|
)
|
||||||
job_id = None
|
job_id = None
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
@@ -217,8 +237,9 @@ def s7_cron(env, url, token):
|
|||||||
try:
|
try:
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return SKIP, f"hermes cron create failed: {(err or out).strip()[:120]}"
|
return SKIP, f"hermes cron create failed: {(err or out).strip()[:120]}"
|
||||||
rc, out, _ = run_probe(env, url, token, "--watch", chat_id,
|
rc, out, _ = run_probe(
|
||||||
"--timeout", "330", timeout=400)
|
env, url, token, "--watch", chat_id, "--timeout", "330", timeout=400
|
||||||
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return PASS, f"one-shot cron job fired; message landed in {chat_id}"
|
return PASS, f"one-shot cron job fired; message landed in {chat_id}"
|
||||||
return FAIL, f"no message in {chat_id} within 330s (probe rc={rc})"
|
return FAIL, f"no message in {chat_id} within 330s (probe rc={rc})"
|
||||||
@@ -228,8 +249,9 @@ def s7_cron(env, url, token):
|
|||||||
else:
|
else:
|
||||||
# create succeeded but the id was not parseable: find by name.
|
# create succeeded but the id was not parseable: find by name.
|
||||||
_, list_out, _ = run_hermes(env, "cron", "list")
|
_, list_out, _ = run_hermes(env, "cron", "list")
|
||||||
m = re.search(r"(\S+) \[active\]\s*\n\s*Name:\s+" + re.escape(job_name),
|
m = re.search(
|
||||||
list_out)
|
r"(\S+) \[active\]\s*\n\s*Name:\s+" + re.escape(job_name), list_out
|
||||||
|
)
|
||||||
if m:
|
if m:
|
||||||
run_hermes(env, "cron", "remove", m.group(1))
|
run_hermes(env, "cron", "remove", m.group(1))
|
||||||
run_probe(env, url, token, "--channel-delete", chat_id)
|
run_probe(env, url, token, "--channel-delete", chat_id)
|
||||||
@@ -237,10 +259,15 @@ def s7_cron(env, url, token):
|
|||||||
|
|
||||||
def s8_search(env, url, token):
|
def s8_search(env, url, token):
|
||||||
marker = f"e2emarker{uuid.uuid4().hex[:8]}"
|
marker = f"e2emarker{uuid.uuid4().hex[:8]}"
|
||||||
rc, _, _ = run_probe(env, url, token, "--send",
|
rc, _, _ = run_probe(
|
||||||
f"Remember this marker phrase: {marker}. "
|
env,
|
||||||
"Just acknowledge it briefly.",
|
url,
|
||||||
"--timeout", "120")
|
token,
|
||||||
|
"--send",
|
||||||
|
f"Remember this marker phrase: {marker}. Just acknowledge it briefly.",
|
||||||
|
"--timeout",
|
||||||
|
"120",
|
||||||
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return FAIL, f"setup message failed (rc={rc})"
|
return FAIL, f"setup message failed (rc={rc})"
|
||||||
rc, _, _ = run_probe(env, url, token, "--send", "", "--search", marker)
|
rc, _, _ = run_probe(env, url, token, "--send", "", "--search", marker)
|
||||||
@@ -255,9 +282,17 @@ def s9_media_in(env, url, token):
|
|||||||
png = Path(f"/tmp/e2e_in_{uuid.uuid4().hex[:6]}.png")
|
png = Path(f"/tmp/e2e_in_{uuid.uuid4().hex[:6]}.png")
|
||||||
write_png(png, (30, 120, 220))
|
write_png(png, (30, 120, 220))
|
||||||
try:
|
try:
|
||||||
rc, _, _ = run_probe(env, url, token, "--upload", str(png),
|
rc, _, _ = run_probe(
|
||||||
"--send", "describe this image briefly",
|
env,
|
||||||
"--timeout", "120")
|
url,
|
||||||
|
token,
|
||||||
|
"--upload",
|
||||||
|
str(png),
|
||||||
|
"--send",
|
||||||
|
"describe this image briefly",
|
||||||
|
"--timeout",
|
||||||
|
"120",
|
||||||
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return PASS, "upload + vision reply (final message)"
|
return PASS, "upload + vision reply (final message)"
|
||||||
if rc == 8:
|
if rc == 8:
|
||||||
@@ -270,11 +305,14 @@ def s9_media_in(env, url, token):
|
|||||||
|
|
||||||
|
|
||||||
def s10_media_out(env, url, token):
|
def s10_media_out(env, url, token):
|
||||||
prompt = ("Create a 100x100 orange square PNG in /tmp with your tools. "
|
prompt = (
|
||||||
|
"Create a 100x100 orange square PNG in /tmp with your tools. "
|
||||||
"In your final reply, include the MEDIA:/absolute/path tag for "
|
"In your final reply, include the MEDIA:/absolute/path tag for "
|
||||||
"that file so it is delivered to me.")
|
"that file so it is delivered to me."
|
||||||
rc, out, _ = run_probe(env, url, token, "--send", prompt,
|
)
|
||||||
"--pull-offer", "--timeout", "150")
|
rc, out, _ = run_probe(
|
||||||
|
env, url, token, "--send", prompt, "--pull-offer", "--timeout", "150"
|
||||||
|
)
|
||||||
m = re.search(r"== pulled (\d+) bytes", out)
|
m = re.search(r"== pulled (\d+) bytes", out)
|
||||||
if rc == 0 and m and int(m.group(1)) > 0:
|
if rc == 0 and m and int(m.group(1)) > 0:
|
||||||
return PASS, f"media.offer pulled ({m.group(1)} bytes)"
|
return PASS, f"media.offer pulled ({m.group(1)} bytes)"
|
||||||
@@ -284,21 +322,24 @@ def s10_media_out(env, url, token):
|
|||||||
|
|
||||||
|
|
||||||
def s11_push(env, url, token):
|
def s11_push(env, url, token):
|
||||||
rc, out, _ = run_probe(env, url, token, "--fcm-token", "test-token-123",
|
rc, out, _ = run_probe(
|
||||||
"--fcm-reg", "--send", "")
|
env, url, token, "--fcm-token", "test-token-123", "--fcm-reg", "--send", ""
|
||||||
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return FAIL, f"probe rc={rc}"
|
return FAIL, f"probe rc={rc}"
|
||||||
if "<- error" in out:
|
if "<- error" in out:
|
||||||
return FAIL, "error frame after fcm.register"
|
return FAIL, "error frame after fcm.register"
|
||||||
return PARTIAL, ("fcm.register accepted (no error frame); "
|
return PARTIAL, (
|
||||||
"device-notification leg is manual")
|
"fcm.register accepted (no error frame); device-notification leg is manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def s12_sync(env, url, token):
|
def s12_sync(env, url, token):
|
||||||
rc, out, _ = run_probe(env, url, token, "--sync", "0")
|
rc, out, _ = run_probe(env, url, token, "--sync", "0")
|
||||||
if rc == 0 and "sync done" in out:
|
if rc == 0 and "sync done" in out:
|
||||||
return PARTIAL, ("sync replay + sync.done verified; "
|
return PARTIAL, (
|
||||||
"gateway-kill/restart leg is manual")
|
"sync replay + sync.done verified; gateway-kill/restart leg is manual"
|
||||||
|
)
|
||||||
if rc == 8:
|
if rc == 8:
|
||||||
return FAIL, "sync failed"
|
return FAIL, "sync failed"
|
||||||
return FAIL, f"probe rc={rc}"
|
return FAIL, f"probe rc={rc}"
|
||||||
@@ -309,19 +350,30 @@ def s13_http_fallback(env, url, token):
|
|||||||
health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo
|
health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo
|
||||||
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
|
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
|
||||||
u = urlparse(url)
|
u = urlparse(url)
|
||||||
scheme = "https" if u.scheme == "wss" else "http"
|
scheme = "https" if u.scheme in ("wss", "https") else "http"
|
||||||
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
|
http_port = u.port or int(os.getenv("IRIS_HTTP_PORT", "8791"))
|
||||||
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
|
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
|
||||||
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
|
rc, out, _ = run_probe(
|
||||||
"--send", "Reply with exactly: e2e http fallback OK",
|
env,
|
||||||
"--timeout", "120")
|
url,
|
||||||
|
token,
|
||||||
|
"--http",
|
||||||
|
"--http-url",
|
||||||
|
http_url,
|
||||||
|
"--send",
|
||||||
|
"Reply with exactly: e2e http fallback OK",
|
||||||
|
"--timeout",
|
||||||
|
"120",
|
||||||
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
m = re.search(r"== user echo in ([\d.]+)s", out)
|
m = re.search(r"== user echo in ([\d.]+)s", out)
|
||||||
echo = float(m.group(1)) if m else None
|
echo = float(m.group(1)) if m else None
|
||||||
if echo is not None and echo > 1.5:
|
if echo is not None and echo > 1.5:
|
||||||
return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)"
|
return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)"
|
||||||
return PASS, ("health + POST /v1/frame + SSE turn complete"
|
return PASS, (
|
||||||
+ (f"; user echo in {echo:.2f}s" if echo is not None else ""))
|
"health + POST /v1/frame + SSE turn complete"
|
||||||
|
+ (f"; user echo in {echo:.2f}s" if echo is not None else "")
|
||||||
|
)
|
||||||
if rc == 20:
|
if rc == 20:
|
||||||
return FAIL, "health check failed (HTTP leg not running?)"
|
return FAIL, "health check failed (HTTP leg not running?)"
|
||||||
if rc == 21:
|
if rc == 21:
|
||||||
@@ -352,10 +404,13 @@ def main() -> int:
|
|||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(
|
||||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
)
|
)
|
||||||
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
|
p.add_argument("--url", default=os.getenv("IRIS_HTTP_URL", DEFAULT_URL))
|
||||||
p.add_argument("--token", default="")
|
p.add_argument("--token", default="")
|
||||||
p.add_argument("--skip", default="",
|
p.add_argument(
|
||||||
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
|
"--skip",
|
||||||
|
default="",
|
||||||
|
help="comma-separated scenario numbers to skip (e.g. 3,5,7)",
|
||||||
|
)
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
token = find_token(args.token)
|
token = find_token(args.token)
|
||||||
@@ -391,10 +446,13 @@ def main() -> int:
|
|||||||
for num, name, status, reason in results:
|
for num, name, status, reason in results:
|
||||||
print(f"{num:<3} {name:<18} {status:<8} {reason}")
|
print(f"{num:<3} {name:<18} {status:<8} {reason}")
|
||||||
print("-" * 78)
|
print("-" * 78)
|
||||||
counts = {s: sum(1 for r in results if r[2] == s)
|
counts = {
|
||||||
for s in (PASS, PARTIAL, SKIP, FAIL)}
|
s: sum(1 for r in results if r[2] == s) for s in (PASS, PARTIAL, SKIP, FAIL)
|
||||||
print(f"total: {len(results)} PASS={counts[PASS]} PARTIAL={counts[PARTIAL]} "
|
}
|
||||||
f"SKIP={counts[SKIP]} FAIL={counts[FAIL]}")
|
print(
|
||||||
|
f"total: {len(results)} PASS={counts[PASS]} PARTIAL={counts[PARTIAL]} "
|
||||||
|
f"SKIP={counts[SKIP]} FAIL={counts[FAIL]}"
|
||||||
|
)
|
||||||
return 1 if counts[FAIL] else 0
|
return 1 if counts[FAIL] else 0
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
@@ -59,14 +62,17 @@ def _plugin_dir() -> Path:
|
|||||||
env = os.environ.get("IRIS_PLUGIN_DIR")
|
env = os.environ.get("IRIS_PLUGIN_DIR")
|
||||||
if env:
|
if env:
|
||||||
return Path(env)
|
return Path(env)
|
||||||
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
# Works from either copy of this file: tests/ (canonical, repo root is
|
||||||
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
|
# parents[1]) or the hermes-agent/tests/gateway/ mirror (repo root is
|
||||||
# (repo root is parents[3]).
|
# parents[3]). The plugin always lives in <repo>/gateway-plugin.
|
||||||
here = Path(__file__).resolve()
|
here = Path(__file__).resolve()
|
||||||
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
|
for candidate in (
|
||||||
|
here.parents[1] / "gateway-plugin",
|
||||||
|
here.parents[3] / "gateway-plugin",
|
||||||
|
):
|
||||||
if (candidate / "protocol.py").is_file():
|
if (candidate / "protocol.py").is_file():
|
||||||
return candidate
|
return candidate
|
||||||
return here.parents[1]
|
return here.parents[1] / "gateway-plugin"
|
||||||
|
|
||||||
|
|
||||||
def _load_plugin():
|
def _load_plugin():
|
||||||
@@ -192,7 +198,9 @@ def _frame_json(frame: dict) -> dict:
|
|||||||
return {"v": 1, **frame}
|
return {"v": 1, **frame}
|
||||||
|
|
||||||
|
|
||||||
def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str]], int]:
|
def _parse_sse(
|
||||||
|
lines: list[str],
|
||||||
|
) -> tuple[list[tuple[str | None, str | None, str]], int]:
|
||||||
"""Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count."""
|
"""Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count."""
|
||||||
events: list[tuple[str | None, str | None, str]] = []
|
events: list[tuple[str | None, str | None, str]] = []
|
||||||
comments = 0
|
comments = 0
|
||||||
@@ -220,7 +228,9 @@ def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str
|
|||||||
return events, comments
|
return events, comments
|
||||||
|
|
||||||
|
|
||||||
def _sse_open(port: int, *, cursor: int | None = None, last_event_id: str | None = None):
|
def _sse_open(
|
||||||
|
port: int, *, cursor: int | None = None, last_event_id: str | None = None
|
||||||
|
):
|
||||||
"""Open an SSE connection (blocking); returns the HTTPResponse (read
|
"""Open an SSE connection (blocking); returns the HTTPResponse (read
|
||||||
lines via ``_sse_read_lines``; close with ``resp.close()``)."""
|
lines via ``_sse_read_lines``; close with ``resp.close()``)."""
|
||||||
conn = HTTPConnection("127.0.0.1", port, timeout=30)
|
conn = HTTPConnection("127.0.0.1", port, timeout=30)
|
||||||
@@ -355,8 +365,12 @@ async def test_post_wrong_content_type_400(gw):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_post_oversize_body_413(gw):
|
async def test_post_oversize_body_413(gw):
|
||||||
big = json.dumps(_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}}))
|
big = json.dumps(
|
||||||
status, _ = await asyncio.to_thread(_request, http_port(gw), "POST", "/v1/frame", body=big)
|
_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}})
|
||||||
|
)
|
||||||
|
status, _ = await asyncio.to_thread(
|
||||||
|
_request, http_port(gw), "POST", "/v1/frame", body=big
|
||||||
|
)
|
||||||
assert status == 413
|
assert status == 413
|
||||||
|
|
||||||
|
|
||||||
@@ -367,7 +381,12 @@ async def test_post_empty_message_400(gw):
|
|||||||
_post_frame,
|
_post_frame,
|
||||||
http_port(gw),
|
http_port(gw),
|
||||||
_frame_json(
|
_frame_json(
|
||||||
{"id": 7, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": " "}}
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "message.send",
|
||||||
|
"chat_id": CHAT_ID,
|
||||||
|
"payload": {"text": " "},
|
||||||
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert status == 400
|
assert status == 400
|
||||||
@@ -666,7 +685,9 @@ def _upload(
|
|||||||
"X-Iris-Media-Ref": media_ref,
|
"X-Iris-Media-Ref": media_ref,
|
||||||
"X-Iris-Media-Kind": kind,
|
"X-Iris-Media-Kind": kind,
|
||||||
"X-Iris-Media-Filename": filename,
|
"X-Iris-Media-Filename": filename,
|
||||||
"X-Iris-Media-Sha256": sha256 if sha256 is not None else hashlib.sha256(data).hexdigest(),
|
"X-Iris-Media-Sha256": sha256
|
||||||
|
if sha256 is not None
|
||||||
|
else hashlib.sha256(data).hexdigest(),
|
||||||
}
|
}
|
||||||
status, payload = _request(
|
status, payload = _request(
|
||||||
port,
|
port,
|
||||||
@@ -772,7 +793,9 @@ async def test_media_pull_ok(gw):
|
|||||||
str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1)
|
str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1)
|
||||||
)
|
)
|
||||||
port = http_port(gw)
|
port = http_port(gw)
|
||||||
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
|
status, payload = await asyncio.to_thread(
|
||||||
|
_request, port, "GET", f"/v1/media/{entry.media_id}"
|
||||||
|
)
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert payload == PNG_1X1
|
assert payload == PNG_1X1
|
||||||
conn = HTTPConnection("127.0.0.1", port, timeout=10)
|
conn = HTTPConnection("127.0.0.1", port, timeout=10)
|
||||||
@@ -791,7 +814,9 @@ async def test_media_pull_ok(gw):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_media_pull_unknown_404(gw):
|
async def test_media_pull_unknown_404(gw):
|
||||||
port = http_port(gw)
|
port = http_port(gw)
|
||||||
status, payload = await asyncio.to_thread(_request, port, "GET", "/v1/media/md_nope")
|
status, payload = await asyncio.to_thread(
|
||||||
|
_request, port, "GET", "/v1/media/md_nope"
|
||||||
|
)
|
||||||
body = json.loads(payload)
|
body = json.loads(payload)
|
||||||
assert status == 404
|
assert status == 404
|
||||||
assert body["payload"]["code"] == "not_found"
|
assert body["payload"]["code"] == "not_found"
|
||||||
@@ -801,14 +826,147 @@ async def test_media_pull_unknown_404(gw):
|
|||||||
async def test_media_pull_denied_path_404(gw):
|
async def test_media_pull_denied_path_404(gw):
|
||||||
"""Known id, but the path fails delivery validation (denylist) — same
|
"""Known id, but the path fails delivery validation (denylist) — same
|
||||||
re-check at pull time as the WS path."""
|
re-check at pull time as the WS path."""
|
||||||
entry = gw._media.register_outbound("/etc/passwd", "document", "text/plain", "passwd", 100)
|
entry = gw._media.register_outbound(
|
||||||
|
"/etc/passwd", "document", "text/plain", "passwd", 100
|
||||||
|
)
|
||||||
port = http_port(gw)
|
port = http_port(gw)
|
||||||
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
|
status, payload = await asyncio.to_thread(
|
||||||
|
_request, port, "GET", f"/v1/media/{entry.media_id}"
|
||||||
|
)
|
||||||
body = json.loads(payload)
|
body = json.loads(payload)
|
||||||
assert status == 404
|
assert status == 404
|
||||||
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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -8,11 +8,13 @@ while building the Kotlin client.
|
|||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
hermes gateway & # with the iris plugin
|
hermes gateway & # with the iris plugin
|
||||||
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
|
python tests/ws_probe.py --token <IRIS_TOKEN> \
|
||||||
--send "hello"
|
--send "hello"
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
|
--url http(s)://host:port (default http://127.0.0.1:8791)
|
||||||
|
(legacy ws(s)://host:8790/ws URLs are still accepted and
|
||||||
|
converted to the HTTP base automatically)
|
||||||
--token IRIS_TOKEN (default: $IRIS_TOKEN)
|
--token IRIS_TOKEN (default: $IRIS_TOKEN)
|
||||||
--device device_id (default: probe-<rand>)
|
--device device_id (default: probe-<rand>)
|
||||||
--send TEXT send this message after pairing (default: "hello")
|
--send TEXT send this message after pairing (default: "hello")
|
||||||
@@ -134,9 +136,7 @@ def _print_frame(raw):
|
|||||||
f"preview={str(payload.get('preview'))[:80]!r}"
|
f"preview={str(payload.get('preview'))[:80]!r}"
|
||||||
)
|
)
|
||||||
elif ftype == "tool.progress":
|
elif ftype == "tool.progress":
|
||||||
extra = (
|
extra = f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}"
|
||||||
f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}"
|
|
||||||
)
|
|
||||||
elif ftype == "tool.end":
|
elif ftype == "tool.end":
|
||||||
extra = (
|
extra = (
|
||||||
f" idx={payload.get('index')} name={payload.get('name')!r} "
|
f" idx={payload.get('index')} name={payload.get('name')!r} "
|
||||||
@@ -145,7 +145,9 @@ def _print_frame(raw):
|
|||||||
elif ftype == "commentary":
|
elif ftype == "commentary":
|
||||||
extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}"
|
extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}"
|
||||||
elif ftype == "hello.ack":
|
elif ftype == "hello.ack":
|
||||||
extra = f" caps={payload.get('server_caps')} cursor={payload.get('sync_cursor')}"
|
extra = (
|
||||||
|
f" caps={payload.get('server_caps')} cursor={payload.get('sync_cursor')}"
|
||||||
|
)
|
||||||
elif ftype == "error":
|
elif ftype == "error":
|
||||||
extra = f" code={payload.get('code')} msg={payload.get('message')!r}"
|
extra = f" code={payload.get('code')} msg={payload.get('message')!r}"
|
||||||
elif ftype == "typing":
|
elif ftype == "typing":
|
||||||
@@ -252,34 +254,54 @@ def _evaluate_assertions(args, st: _TurnState) -> list[tuple[int, bool, str]]:
|
|||||||
if "message.start" in events and "message.stop" in events:
|
if "message.start" in events and "message.stop" in events:
|
||||||
i_start = events.index("message.start")
|
i_start = events.index("message.start")
|
||||||
i_stop = events.index("message.stop")
|
i_stop = events.index("message.stop")
|
||||||
if any(i_start < i < i_stop for i, e in enumerate(events) if e == "message.update"):
|
if any(
|
||||||
|
i_start < i < i_stop
|
||||||
|
for i, e in enumerate(events)
|
||||||
|
if e == "message.update"
|
||||||
|
):
|
||||||
ok = True
|
ok = True
|
||||||
break
|
break
|
||||||
results.append(
|
results.append(
|
||||||
(10, ok, "assert-turn: no message.start -> >=1 message.update -> message.stop")
|
(
|
||||||
|
10,
|
||||||
|
ok,
|
||||||
|
"assert-turn: no message.start -> >=1 message.update -> message.stop",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if args.assert_reasoning:
|
if args.assert_reasoning:
|
||||||
reasoning = st.final_stop_reasoning or st.final_message_reasoning
|
reasoning = st.final_stop_reasoning or st.final_message_reasoning
|
||||||
results.append(
|
results.append(
|
||||||
(11, bool(reasoning), "assert-reasoning: final message has no non-empty reasoning")
|
(
|
||||||
|
11,
|
||||||
|
bool(reasoning),
|
||||||
|
"assert-reasoning: final message has no non-empty reasoning",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if args.assert_tools:
|
if args.assert_tools:
|
||||||
ok = bool(st.tool_starts) and bool(st.tool_starts & st.tool_ends)
|
ok = bool(st.tool_starts) and bool(st.tool_starts & st.tool_ends)
|
||||||
results.append((12, ok, "assert-tools: no tool.start with a matching tool.end"))
|
results.append((12, ok, "assert-tools: no tool.start with a matching tool.end"))
|
||||||
if args.assert_commentary:
|
if args.assert_commentary:
|
||||||
results.append((13, st.commentary >= 1, "assert-commentary: no commentary frame"))
|
results.append(
|
||||||
|
(13, st.commentary >= 1, "assert-commentary: no commentary frame")
|
||||||
|
)
|
||||||
if args.assert_read_receipt:
|
if args.assert_read_receipt:
|
||||||
if st.read_receipt is None:
|
if st.read_receipt is None:
|
||||||
print("== SKIP: no read.receipt frame (M7 frame not live on this gateway)")
|
print("== SKIP: no read.receipt frame (M7 frame not live on this gateway)")
|
||||||
elif not st.read_receipt:
|
elif not st.read_receipt:
|
||||||
results.append(
|
results.append(
|
||||||
(18, False, "assert-read-receipt: read.receipt arrived before the sent message")
|
(
|
||||||
|
18,
|
||||||
|
False,
|
||||||
|
"assert-read-receipt: read.receipt arrived before the sent message",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if args.assert_status:
|
if args.assert_status:
|
||||||
if not st.status_seen:
|
if not st.status_seen:
|
||||||
print("== SKIP: no status frame (M7 frame not live on this gateway)")
|
print("== SKIP: no status frame (M7 frame not live on this gateway)")
|
||||||
elif st.status_empty:
|
elif st.status_empty:
|
||||||
results.append((19, False, "assert-status: status frame arrived with an empty payload"))
|
results.append(
|
||||||
|
(19, False, "assert-status: status frame arrived with an empty payload")
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -389,7 +411,12 @@ def run_http(args, base: str) -> int:
|
|||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
headers,
|
headers,
|
||||||
{"v": 1, "id": 1, "type": "channel.create", "payload": {"name": args.channel_create}},
|
{
|
||||||
|
"v": 1,
|
||||||
|
"id": 1,
|
||||||
|
"type": "channel.create",
|
||||||
|
"payload": {"name": args.channel_create},
|
||||||
|
},
|
||||||
"channel.created",
|
"channel.created",
|
||||||
30,
|
30,
|
||||||
)
|
)
|
||||||
@@ -475,7 +502,12 @@ def run_http(args, base: str) -> int:
|
|||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
headers,
|
headers,
|
||||||
{"v": 1, "id": 1, "type": "fcm.register", "payload": {"token": args.fcm_token}},
|
{
|
||||||
|
"v": 1,
|
||||||
|
"id": 1,
|
||||||
|
"type": "fcm.register",
|
||||||
|
"payload": {"token": args.fcm_token},
|
||||||
|
},
|
||||||
"fcm.registered",
|
"fcm.registered",
|
||||||
30,
|
30,
|
||||||
)
|
)
|
||||||
@@ -511,7 +543,10 @@ def run_http(args, base: str) -> int:
|
|||||||
if data is not None and data.get("chat_id") == args.watch:
|
if data is not None and data.get("chat_id") == args.watch:
|
||||||
ftype = data.get("type")
|
ftype = data.get("type")
|
||||||
payload = data.get("payload") or {}
|
payload = data.get("payload") or {}
|
||||||
if ftype == "message" and payload.get("role") in ("assistant", "cron"):
|
if ftype == "message" and payload.get("role") in (
|
||||||
|
"assistant",
|
||||||
|
"cron",
|
||||||
|
):
|
||||||
print(
|
print(
|
||||||
f"== message landed in {args.watch}: {str(payload.get('text'))[:120]!r}"
|
f"== message landed in {args.watch}: {str(payload.get('text'))[:120]!r}"
|
||||||
)
|
)
|
||||||
@@ -626,7 +661,11 @@ def run_http(args, base: str) -> int:
|
|||||||
got_final = True
|
got_final = True
|
||||||
if ftype == "message.stop":
|
if ftype == "message.stop":
|
||||||
seen_final_frame = True
|
seen_final_frame = True
|
||||||
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
|
if (
|
||||||
|
ftype == "typing"
|
||||||
|
and payload.get("on") is False
|
||||||
|
and seen_final_frame
|
||||||
|
):
|
||||||
got_final = True
|
got_final = True
|
||||||
cur_data = []
|
cur_data = []
|
||||||
return got_final
|
return got_final
|
||||||
@@ -666,12 +705,14 @@ def run_http(args, base: str) -> int:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
p = argparse.ArgumentParser(description=__doc__)
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
|
p.add_argument("--url", default=os.getenv("IRIS_HTTP_URL", "http://127.0.0.1:8791"))
|
||||||
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
|
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
|
||||||
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
||||||
p.add_argument("--send", default="hello")
|
p.add_argument("--send", default="hello")
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--upload", default="", help="M4: file to upload (chunked) and attach via media_refs"
|
"--upload",
|
||||||
|
default="",
|
||||||
|
help="M4: file to upload (chunked) and attach via media_refs",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--pull-offer",
|
"--pull-offer",
|
||||||
@@ -684,14 +725,18 @@ def main() -> int:
|
|||||||
default=None,
|
default=None,
|
||||||
help="M5: send sync {cursor} after pairing, print replay, exit",
|
help="M5: send sync {cursor} after pairing, print replay, exit",
|
||||||
)
|
)
|
||||||
p.add_argument("--fcm-token", default="", help="M5: FCM token to attach to the hello payload")
|
p.add_argument(
|
||||||
|
"--fcm-token", default="", help="M5: FCM token to attach to the hello payload"
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--fcm-reg",
|
"--fcm-reg",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="M5: send fcm.register after pairing (uses --fcm-token)",
|
help="M5: send fcm.register after pairing (uses --fcm-token)",
|
||||||
)
|
)
|
||||||
p.add_argument("--timeout", type=float, default=120.0)
|
p.add_argument("--timeout", type=float, default=120.0)
|
||||||
p.add_argument("--authfail", action="store_true", help="expect an auth rejection (wrong token)")
|
p.add_argument(
|
||||||
|
"--authfail", action="store_true", help="expect an auth rejection (wrong token)"
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--assert-turn",
|
"--assert-turn",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -703,9 +748,13 @@ def main() -> int:
|
|||||||
help="assert the final message.stop carries non-empty reasoning",
|
help="assert the final message.stop carries non-empty reasoning",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--assert-tools", action="store_true", help="assert >=1 tool.start with a matching tool.end"
|
"--assert-tools",
|
||||||
|
action="store_true",
|
||||||
|
help="assert >=1 tool.start with a matching tool.end",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--assert-commentary", action="store_true", help="assert >=1 commentary frame"
|
||||||
)
|
)
|
||||||
p.add_argument("--assert-commentary", action="store_true", help="assert >=1 commentary frame")
|
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--assert-read-receipt",
|
"--assert-read-receipt",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -717,10 +766,15 @@ def main() -> int:
|
|||||||
help="assert a status frame is received (SKIP if absent; M7)",
|
help="assert a status frame is received (SKIP if absent; M7)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--search", default="", help="M3: send search {query, scope, limit}, assert >=1 hit"
|
"--search",
|
||||||
|
default="",
|
||||||
|
help="M3: send search {query, scope, limit}, assert >=1 hit",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--scope", choices=("all", "chat"), default="all", help="search scope (default all)"
|
"--scope",
|
||||||
|
choices=("all", "chat"),
|
||||||
|
default="all",
|
||||||
|
help="search scope (default all)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--chat-id",
|
"--chat-id",
|
||||||
@@ -728,12 +782,20 @@ def main() -> int:
|
|||||||
help="chat_id for --scope chat (default default)",
|
help="chat_id for --scope chat (default default)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--channel-create", default="", help="M3: create a channel, print its chat_id, exit"
|
"--channel-create",
|
||||||
|
default="",
|
||||||
|
help="M3: create a channel, print its chat_id, exit",
|
||||||
)
|
)
|
||||||
p.add_argument("--channel-delete", default="", help="M3: delete (archive) a channel, exit")
|
|
||||||
p.add_argument("--channel-list", action="store_true", help="M3: list channels, exit")
|
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--watch", default="", help="wait up to --timeout for a message to land in this chat_id"
|
"--channel-delete", default="", help="M3: delete (archive) a channel, exit"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--channel-list", action="store_true", help="M3: list channels, exit"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--watch",
|
||||||
|
default="",
|
||||||
|
help="wait up to --timeout for a message to land in this chat_id",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--offer-grace",
|
"--offer-grace",
|
||||||
@@ -764,17 +826,21 @@ def main() -> int:
|
|||||||
if not args.token and not args.authfail:
|
if not args.token and not args.authfail:
|
||||||
p.error("--token (or $IRIS_TOKEN) is required")
|
p.error("--token (or $IRIS_TOKEN) is required")
|
||||||
if args.assert_read_receipt and not args.send:
|
if args.assert_read_receipt and not args.send:
|
||||||
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
|
p.error(
|
||||||
|
"--assert-read-receipt requires --send (the receipt must follow the sent message)"
|
||||||
|
)
|
||||||
# HTTP is the only transport (docs/19): derive the http(s) base from the
|
# HTTP is the only transport (docs/19): derive the http(s) base from the
|
||||||
# --url (ws://host:8790/ws -> http://host:8791) unless --http-url is given.
|
# --url (legacy ws(s)://host:8790/ws -> http(s)://host:8791) unless
|
||||||
|
# --http-url is given.
|
||||||
if args.http_url:
|
if args.http_url:
|
||||||
base = args.http_url
|
base = args.http_url
|
||||||
else:
|
else:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
u = urlparse(args.url)
|
u = urlparse(args.url)
|
||||||
scheme = "https" if u.scheme == "wss" else "http"
|
scheme = "https" if u.scheme in ("wss", "https") else "http"
|
||||||
base = f"{scheme}://{u.hostname or '127.0.0.1'}:8791"
|
port = u.port or 8791
|
||||||
|
base = f"{scheme}://{u.hostname or '127.0.0.1'}:{port}"
|
||||||
return run_http(args, base)
|
return run_http(args, base)
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user