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

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

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

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

- Record when a high-priority banner (cron/approval/clarify) was posted
  per lane; suppress the message frame's system notification within a
  5 s window (mirrors the gateway's push-coalesce window).
- Dedupe unread counting per lane by message id so a redelivered frame
  only counts once (recorded only when the message actually counts as
  unread).
2026-08-27 09:23:49 +02:00
ARIA b065d1783b Docs: clarify ntfy privacy — default server is public ntfy.sh
CI / Gateway plugin tests (push) Successful in 8m58s
CI / Kotlin tests (android host + desktop) (push) Failing after 13m32s
The docs claimed ntfy keeps push metadata on your own infrastructure,
but the default NTFY_SERVER_URL is the public https://ntfy.sh cloud
service. Make explicit that push metadata (topic, notification title)
passes through ntfy.sh unless you self-host ntfy.
2026-08-27 09:23:44 +02:00
ARIA fb980d12b4 Release version management: single VERSION file as source of truth
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m0s
- VERSION at repo root (0.1.2); bump it to cut a release
- App: generated AppVersion.kt (config-cache-safe Gradle task with
  VERSION as declared input) shown in Settings; sent to the gateway
  via X-Iris-App-Version header on the SSE open
- Gateway: reports its own version in hello.ack server_caps.app_version
  (read from the repo-root VERSION via the plugin symlink); stores the
  app's version in the device registry caps (merge, not overwrite, so
  an old app reconnecting without the header doesn't wipe it)
- Settings: app + gateway version rows, mismatch hint, and a best-effort
  Gitea latest-release check (ReleaseCheck) with an 'update available' hint
- Release workflow: reads VERSION from the repo (no manual input), with
  a guard against an empty file
- Docs: frames.schema.json + 04-wire-protocol.md updated for app_version
2026-08-25 14:42:39 +02:00
ARIA f3f1b37221 Fix iris setup: embed generated token in the pairing URL/QR
CI / Gateway plugin tests (push) Successful in 5m23s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m53s
interactive_setup() generated a fresh IRIS_TOKEN and saved it to .env,
but the local `token` variable was never updated, so the pairing URL and
QR payload were built with an empty token (token=). The gateway accepted
the saved token, but the app never received it, so pairing was impossible.

Assign the generated value back to `token` so the pairing URL/QR carry it.
Add a regression test (test_interactive_setup_generates_token_in_pairing_url).
2026-08-25 13:42:46 +02:00
ARIA 6f339330c5 Move gateway-plugin tests out of the installable tree; clean plugin scan
CI / Gateway plugin tests (push) Successful in 5m13s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
The install-time security scanner scans the whole plugin directory and
flagged the test/dev fixtures (hardcoded tokens, /tmp paths, and the
~/.hermes/.env literal in setup.py) as DANGEROUS, blocking installs with
"19 findings".

- Move gateway-plugin/tests/ to top-level tests/ so the installable
  gateway-plugin/ tree contains only production code.
- Update _plugin_dir() in the tests and REPO in e2e.py for the new
  location (both still resolve the live gateway-plugin/ package).
- Update all references: docs, CI-SETUP.md, Gitea workflows, .pi-lens.json.
- Build the hermes .env path at runtime in setup.py via get_hermes_home()
  so the scanner no longer matches the literal ~/.hermes/.env.

Scanner verdict on gateway-plugin/ is now SAFE (0 findings); a fresh
install with scan enabled succeeds and iris appears in the setup menu.
2026-08-25 13:26:12 +02:00
ARIA 573291fc1e threads: order topic chips newest-first beneath General
CI / Gateway plugin tests (push) Successful in 5m17s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
The gateway already stored a created timestamp per channel/thread but
never sent it over the wire. Now:

- protocol.py: _channel_payload() includes created (unix seconds)
- Protocol.kt: ChannelInfo.created (default 0.0 for legacy gateways)
- ChatScreen: topic switcher sorts threads created-desc (newest right
  after General, swipe new -> old), name as tie-break
- frames.schema.json: document the created field
- ChannelCreatedWireTest: wire deserialization + ordering tests
2026-08-25 11:22:00 +02:00
ARIA 83a67f6fb1 app: smooth streaming markdown via incremental parser + typewriter reveal
CI / Gateway plugin tests (push) Successful in 6m4s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m36s
- MarkdownText: streaming branch on the library's StreamingMarkdownState
  (rememberStreamingMarkdownPipeline) — incremental append, no full
  re-parse per snapshot; static path keeps retainState=true
- client-side reveal: buffer snapshots, reveal at a steady rate
  (streamCharsPerSecond = 60/smoothness, 300..75 c/s) with rate-relative
  catch-up (jump when backlog > 2s of reveal time)
- keep the streaming renderer alive after message.stop until the reveal
  catches up (useStreaming gate); artifact card waits for the same
- cursor drawn via annotator, never part of the parse input
- new 'Streaming speed' setting (0.2-0.8s) in Settings, persisted per
  device via SecureStore, live-updatable
- docs/05-streaming: rate mapping, catch-up, wait-for-reveal semantics
2026-08-25 11:00:22 +02:00
ARIA c7a16d51e3 gateway setup: offer self-signed TLS cert generation (no openssl needed)
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
hermes gateway setup now asks 'Set up TLS now?' when IRIS_HTTP_CERT is
not in .env (default No, Yes for an all-interfaces bind). Accepting
generates a 10-year RSA-2048 self-signed cert with SANs (advertised LAN
IP, hostname, loopback) under ~/.hermes/iris/ via hermes' existing
cryptography dependency, saves IRIS_HTTP_CERT/IRIS_HTTP_KEY, and prints
the SHA-256 fingerprint in openssl format for the app's confirm-and-pin
dialog. The pairing URL/QR printed afterwards already advertise https.

- key created 0600 from the start (no umask window)
- save_env_value inside the best-effort guard (unwritable .env warns)
- leftover cert without env var -> overwrite confirmation (protects the
  app's pinned fingerprint)
- bind wildcards (0.0.0.0 / ::) never become SANs; :: gets the same
  default-Yes as 0.0.0.0 (pairing._unroutable parity)

Tests: 5 new (cert generation incl. openssl fingerprint cross-check,
accept/decline, no re-prompt, default-follows-bind, overwrite prompt).
Docs: install.md Part 2 table + Part 4 Option B.
2026-08-24 22:46:27 +02:00
ARIA b1c9bac7d8 docs+plugin: HTTP-only transport cleanup, install guide, review fixes
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
- docs/install.md: new end-to-end guide for non-technical users
  (gateway install, app install, LAN/TLS/remote connection, push,
  options, troubleshooting); docs/setup.md now points to it
- README: new 'Install the gateway' section; pairing section updated
  for HTTP transport (8791, QR scan on Android)
- rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat
  fallback); drop dead DEFAULT_PORT=8790
- setup.py: advertise https:// in the printed/QR server URL when
  IRIS_HTTP_CERT is set
- ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env
  IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme
- plugin.yaml: IRIS_HTTP_* env names, description no longer says
  'WebSocket server'
- docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites,
  8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the
  only transport)
- AGENTS.md: symlink name android -> iris (matches actual install)
- test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy
  IRIS_WS_* names are not consulted (95/95 pass)
2026-08-24 22:22:02 +02:00
ARIA a61b47a947 docs: replace stale 'android' name mentions with 'iris'
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m59s
The plugin is named 'iris' (IrisAdapter, IRIS_HOME_CHANNEL, label Iris),
but several docs still referred to it as the android platform/plugin and
to the product as 'the Android app'. Rename name-mentions to iris/IRIS
and product-mentions to 'Iris app'; keep legitimate OS references
(androidApp, Android SDK, Android 10, androidx, test_android.py, ...).

Also includes pi-lens markdown-lint autofixes (table spacing, trailing
newlines) in the touched files.
2026-08-24 21:44:02 +02:00
ARIA 597a28050f TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14)
CI / Gateway plugin tests (push) Successful in 5m29s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m22s
docs/09 §9.4 promised a SSH-host-key-style fingerprint confirm on first
pair, but the app built a default OkHttpClient with no certificate
handling — self-signed gateway certs were simply rejected.

Build the flow:
- TlsPinning.kt: PinningTrustManager wraps the platform default trust
  manager; a rejected cert is accepted only when its SHA-256 fingerprint
  matches the user-confirmed pin, anything else fails with
  TlsFingerprintRequired (hostname verification still applies).
- SecureStore.pinnedCertFingerprint (Android EncryptedSharedPreferences +
  desktop settings.json), cleared on forget().
- GatewayClient: pinning socket factory on the shared client (all legs
  inherit it), new terminal State.TlsConfirmRequired, unwrap the nested
  TlsFingerprintRequired in connect loop / watchdog / SSE / poll /
  testHello.
- ConnectScreen: confirm dialog showing the fingerprint ("Confirm &
  pin" re-runs the connect); IrisApp routes TlsConfirmRequired there;
  ChatScreen + desktop tray handle the new state.
- Docs: §9.4 now describes the real flow (incl. SAN requirement), gap
  table item 6 → implemented, setup.md limitation note updated.
- Tests: TlsPinningTest (fingerprint vs openssl, pin accept/reject,
  live pin read, unwrap) + TlsPinningIntegrationTest (real TLS
  handshake: unpinned → confirm data, pinned → 200).

Live E2E verified on the phone: first pair against a self-signed
IRIS_HTTP_CERT gateway shows the dialog, confirm pins, chat works,
auto-reconnect after gateway restart uses the pin.
2026-08-24 21:30:42 +02:00
ARIA f90e40a3fc Fix README limit claims to match reality (issue #13)
CI / Gateway plugin tests (push) Failing after 1m4s
CI / Kotlin tests (android host + desktop) (push) Successful in 8m3s
- 'No file limit' -> 100 MB default, configurable via max_upload_bytes
- 'No character limit' -> 'No 4,096-character limit like Telegram'
  (hard frame-body cap: 1 MiB)
- Note that limits are set on the gateway side (hermes), not in the app
- Sync docs/playstore-listing.md (same overclaim)
2026-08-24 21:05:13 +02:00
Pakobbix 330e63e941 Merge pull request 'Split adapter.py monolith into focused modules; restore Ruff complexity defaults' (#15) from refactor/adapter-split into master
CI / Kotlin tests (android host + desktop) (push) Successful in 7m41s
CI / Gateway plugin tests (push) Successful in 8m46s
Reviewed-on: #15
2026-08-24 19:03:15 +00:00
ARIA b8e756c3dd Split adapter.py monolith into focused modules; restore Ruff complexity defaults (issue #12)
CI / Gateway plugin tests (pull_request) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 7m5s
adapter.py was a 3,493-line monolith. Split it into focused modules with
clear separation of responsibilities, bringing it down to ~857 lines:

- Module-level helpers: hooks, classify, pickers, commands, setup,
  defaults, secrets
- Frame-handler mixins: inbound, tool_frames, push_frames, media_frames,
  picker_frames, channel_frames, query_frames
- mixin_base: IrisAdapterBase (declaration-only base for shared attrs)
- adapter.py now holds only IrisAdapter (the composition of the 7 mixins
  + BasePlatformAdapter), register(), and test-facing re-exports

The mixins come before BasePlatformAdapter in the MRO so their methods
override the base; super() calls (e.g. send_image) still resolve to
BasePlatformAdapter. No circular imports; dispatch.py and http_server.py
(instance-method callers) are unaffected.

Ruff complexity ceilings (PLR0911/0912/0913/0915) restored to Ruff's
built-in defaults (12/50/6/5) instead of "just above the current maxima",
which ratchets the bar down as code grows. The existing genuinely-complex
functions (frame builders mirroring 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.

All 125 tests green (94 test_android + 31 test_android_http); no new ruff
errors introduced.
2026-08-24 20:55:00 +02:00
ARIA 7faaf2aa1c Per-device tokens with revocation (issue #11)
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s
Auth previously used the shared IRIS_TOKEN as the security principal:
a leaked token meant access to all devices, and a compromised device
could not be isolated.

Gateway:
- pairing.py: devices.token column (in-place migration) + revoked
  denylist table; issue_token (idempotent, 64 hex), token_for,
  reissue_token, revoke/unrevoke/is_revoked/list_revoked. The token
  never leaks into device dicts (push fan-out / listings).
- http_server.py: auth accepts the shared token (bootstrap/legacy) OR
  the device's own token (both constant-time); a revoked device_id is
  rejected with 401 before either comparison. On SSE open (pairing)
  the per-device token is minted and returned in hello.ack.
- protocol.py: hello_ack(..., device_token).
- adapter.py: setup flow (hermes gateway setup -> Iris) now offers
  'Remove a paired device?' on an existing setup: numbered select
  menu (last option = exit the removal loop), confirmation, back to
  the menu for further removals.
- tools/iris_devices.py: operator CLI (list / revoke / unrevoke /
  reissue), stdlib only.

App:
- SecureStore.deviceToken (Android: EncryptedSharedPreferences;
  Desktop: second keyring slot iris-device-token / device_token.enc).
- HelloAckPayload.deviceToken; GatewayClient stores it on hello and
  presents it instead of the shared token from then on (live provider
  in HttpGateway); savePairing/clear wipe it for re-pairing.

Docs: 09 §9.3 stretch -> implemented (revocation semantics, both
control surfaces), 04 hello.ack example, frames.schema.json, M7 row 13.

Tests: 8 new Python tests (issuance, acceptance, revocation,
isolation, unrevoke, registry unit x2, setup-flow menu) - 94/94 pass;
2 new Kotlin wire tests - green. Live-verified against a running
gateway (hello.ack token matches devices.db; revoke -> 401 even with
shared token; unrevoke -> 200; setup TUI both paths).
2026-08-24 19:37:44 +02:00
ARIA 746d809d48 Default push backend to ntfy; FCM opt-in with privacy warning (issue #10)
CI / Gateway plugin tests (push) Successful in 5m3s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m6s
- IRIS_PUSH_BACKEND now defaults to ntfy (keeps push metadata on your own
  infrastructure); FCM is opt-in via IRIS_PUSH_BACKEND=fcm
- build_push_backend(): ntfy for empty/unknown names, FCM only on explicit 'fcm'
- gateway setup: warn when FCM is chosen (metadata routed via Google's servers)
- README: privacy note + dedicated push section; new docs/playstore-listing.md
  with the FCM/ntfy privacy note for the Play Store listing
- docs: 00/02/03/08/12/16 + setup.md updated to ntfy-default wording
- tests: default-backend assertion updated (86/86 pass)
2026-08-24 19:04:40 +02:00
ARIA 70282dfb65 fix(release): use Forgejo-style /assets and /tags API routes
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m48s
The server (gitea.zephyre.one) exposes a Forgejo-compatible API:
- release attachments live at POST /releases/{id}/assets, not /attachments
- tag deletion is DELETE /tags/{tag}, not DELETE /git/refs/tags/{tag}
(verified against the live API: /attachments 404s, /assets and /tags exist)
2026-08-23 19:53:17 +02:00
ARIA 44e8c7322e fix(release): delete leftover tag on re-run; support \\n in changelog input
CI / Gateway plugin tests (push) Successful in 5m0s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
- Gitea's DELETE /releases/:id does not remove the tag, so re-running the
  workflow for the same version failed with 409 (curl exit 22). The
  re-run safety block now also deletes the tag via git/refs/tags.
- Replace curl -sf with an api() wrapper that prints Gitea's error body
  on HTTP >= 400 instead of failing silently.
- The workflow_dispatch changelog input is single-line (Gitea has no
  multiline input type); convert literal \\n to real newlines and
  document it in the input description.
2026-08-23 19:03:59 +02:00
ARIA 29d0c1a73f Fix two gateway test failures: outbox lane scoping + SSE teardown race
CI / Gateway plugin tests (push) Successful in 5m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
- outbox: delete_message/message_info now match the exact lane first
  (a flat-lane delete/lookup with thread_id=None sees only frames with
  no thread_id) and fall back to the message_id across all lanes only
  when the exact lane matches nothing. Previously lane=None meant
  'any lane' in the first pass, so a flat-lane delete also removed
  same-id frames from threads (test expected 3 removed, got 4).

- http_server: the SSE live loop skipped queued frames when stop() set
  sub.closed before the handler thread reached the loop (descheduled
  under load between the initial hello/status writes and the loop).
  The loop now drains frames queued before the close, so the
  status{restarting} teardown broadcast always reaches the client
  before EOF (test_disconnect_broadcasts_status_restarting was flaky
  ~70% under CPU load).
2026-08-23 14:45:09 +02:00
ARIA d801a18db5 fix FCM push notifications
CI / Gateway plugin tests (push) Failing after 6m27s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m2s
2026-08-23 14:32:40 +02:00
Pakobbix 560d9c19b3 Merge pull request 'feat(app): copy messages via bubble context menu + selection toolbar' (#9) from feat/message-copy-context-menu into master
CI / Kotlin tests (android host + desktop) (push) Successful in 9m0s
CI / Gateway plugin tests (push) Failing after 9m18s
Reviewed-on: #9
2026-08-23 11:10:30 +00:00
ARIA 32db7fc4e8 feat(app): copy messages via bubble context menu + selection toolbar
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 9m55s
Long-press (touch) / right-click (desktop) on a finalized message now
opens a context menu anchored to the bubble: Copy / Select messages /
Delete. The multi-select toolbar gains a Copy button that joins the
selected messages in display order. Copies raw markdown so formatting
survives pasting into other markdown apps; blank text is a no-op.
Cancelling a menu-opened delete confirm clears the staged selection so
the bubble doesn't stay highlighted.
2026-08-23 13:08:44 +02:00
92 changed files with 7919 additions and 3500 deletions

No files matched your search

+1 -1
View File
@@ -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
+37 -17
View File
@@ -3,12 +3,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
@@ -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,20 +177,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}')" \
@@ -200,7 +219,8 @@ 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"
+1 -1
View File
@@ -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": {
+6
View File
@@ -13,3 +13,9 @@ repos:
language: system language: system
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: check-version-sync
name: check gateway-plugin/plugin.yaml version == repo-root VERSION
entry: scripts/check_version_sync.sh
language: system
pass_filenames: false
always_run: true
+11 -6
View File
@@ -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
View File
@@ -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:
+75 -26
View File
@@ -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,14 +43,53 @@ 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
### Prerequisites ### Prerequisites
| Where | You need | | Where | You need |
|---|---| | --- | --- |
| Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) | | Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) |
| Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device | | Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device |
| Desktop build machine | JDK 17 only | | Desktop build machine | JDK 17 only |
@@ -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
@@ -112,7 +161,7 @@ Contributions are welcome! Before you start:
2. **Know the layout.** 2. **Know the layout.**
| Path | What | | Path | What |
|---|---| | --- | --- |
| `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth | | `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth |
| `app/shared` | KMP module with most of the client code (shared by Android + Desktop) | | `app/shared` | KMP module with most of the client code (shared by Android + Desktop) |
| `app/androidApp` | Thin Android shell (package `dev.iris.app`) | | `app/androidApp` | Thin Android shell (package `dev.iris.app`) |
@@ -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.
+1
View File
@@ -0,0 +1 @@
0.1.3
+10 -3
View File
@@ -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 {
+11 -3
View File
@@ -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)
} }
} }
+52
View File
@@ -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,17 +246,129 @@ 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)
),
loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw
// text so the bubble never goes blank between updates.
Text(text, modifier = m, color = color, fontSize = fontSize)
}, },
) )
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 ->
// First parse of a (long) message: show the text so the bubble
// never goes blank.
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
* (Telegram-style). Tapping the button copies the whole block to the clipboard. * (Telegram-style). Tapping the button copies the whole block to the clipboard.
@@ -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.
@@ -487,6 +492,22 @@ fun ChatScreen(controller: IrisController) {
var selectionMode by remember { mutableStateOf(false) } var selectionMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
// True when the confirm dialog was opened from a bubble's context menu
// (which stages selectedIds WITHOUT entering selection mode) — cancel
// must then clear the staged selection so the bubble doesn't stay
// highlighted.
var deleteConfirmFromMenu by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
// Copy raw markdown (formatting survives pasting into other markdown
// apps) with a toast, since the clipboard write itself is silent.
// Blank text (e.g. a media-only message) is a no-op: writing an empty
// string would wipe the clipboard for no reason.
fun copyText(text: String) {
if (text.isBlank()) return
clipboard.setText(AnnotatedString(text))
toastMessage = "Copied"
}
fun enterSelection(id: String) { fun enterSelection(id: String) {
selectionMode = true selectionMode = true
@@ -670,16 +691,33 @@ fun ChatScreen(controller: IrisController) {
selected = selectable && item.id in selectedIds, selected = selectable && item.id in selectedIds,
onToggleSelect = { if (selectable) toggleSelect(item.id) }, onToggleSelect = { if (selectable) toggleSelect(item.id) },
onLongPress = { onLongPress = {
if (selectable && selectionMode) toggleSelect(item.id)
},
onCopy =
if (selectable) { if (selectable) {
if (selectionMode) { { copyText(item.text) }
toggleSelect(item.id)
} else { } else {
enterSelection(item.id) null
} },
onDelete =
if (selectable) {
{
selectedIds = setOf(item.id)
deleteConfirmFromMenu = true
showDeleteConfirm = true
} }
} else {
null
},
onSelect =
if (selectable) {
{ enterSelection(item.id) }
} else {
null
}, },
runtimeFooterEnabled = runtimeFooterEnabled, runtimeFooterEnabled = runtimeFooterEnabled,
runtimeFooterFields = runtimeFooterFields, runtimeFooterFields = runtimeFooterFields,
streamSmoothness = streamSmoothness,
) )
} }
} }
@@ -913,7 +951,20 @@ fun ChatScreen(controller: IrisController) {
SelectionToolbar( SelectionToolbar(
count = selectedIds.size, count = selectedIds.size,
onCancel = { exitSelection() }, onCancel = { exitSelection() },
onDelete = { showDeleteConfirm = true }, // Multi-select copy: join the selected messages in
// display order, separated by a blank line.
onCopy = {
val text =
items
.filterIsInstance<MessageItem>()
.filter { it.id in selectedIds }
.joinToString("\n\n") { it.text }
copyText(text)
},
onDelete = {
deleteConfirmFromMenu = false
showDeleteConfirm = true
},
) )
} }
@@ -1062,8 +1113,16 @@ fun ChatScreen(controller: IrisController) {
// Message selection: confirm deleting the selected message(s). // Message selection: confirm deleting the selected message(s).
if (showDeleteConfirm) { if (showDeleteConfirm) {
val n = selectedIds.size val n = selectedIds.size
// Cancel: a menu-opened confirm staged selectedIds without
// entering selection mode — clear it so the bubble doesn't
// stay highlighted (toolbar path keeps its selection).
fun cancelDeleteConfirm() {
showDeleteConfirm = false
if (deleteConfirmFromMenu) selectedIds = emptySet()
}
AlertDialog( AlertDialog(
onDismissRequest = { showDeleteConfirm = false }, onDismissRequest = { cancelDeleteConfirm() },
title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") }, title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") },
text = { text = {
Text( Text(
@@ -1082,7 +1141,7 @@ fun ChatScreen(controller: IrisController) {
}) { Text("Delete") } }) { Text("Delete") }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } TextButton(onClick = { cancelDeleteConfirm() }) { Text("Cancel") }
}, },
) )
} }
@@ -2047,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. */
@@ -2332,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,
@@ -2352,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) }
@@ -2460,8 +2522,12 @@ private fun MessageBubble(
selected: Boolean = false, selected: Boolean = false,
onToggleSelect: () -> Unit = {}, onToggleSelect: () -> Unit = {},
onLongPress: () -> Unit = {}, onLongPress: () -> Unit = {},
onCopy: (() -> Unit)? = null,
onDelete: (() -> 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
@@ -2489,6 +2555,12 @@ private fun MessageBubble(
SelectionCheck(selected) SelectionCheck(selected)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
// Context menu (long-press / right-click): copy / select / delete.
// Anchored to the bubble itself (Box), not the full-width row, so it
// pops up next to the bubble on both sides.
val hasMenu = onCopy != null || onDelete != null || onSelect != null
var menuOpen by remember { mutableStateOf(false) }
Box {
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -2496,10 +2568,11 @@ private fun MessageBubble(
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.background(if (selected) IrisColors.chipSelected else bubbleColor) .background(if (selected) IrisColors.chipSelected else bubbleColor)
.then( .then(
// Long-press / right-click enters (or toggles within) message // Long-press / right-click opens the context menu (or
// selection; a plain tap toggles while selecting, or retries a // toggles selection while selecting); a plain tap toggles
// failed user send otherwise. Attached always so the long-press // while selecting, or retries a failed user send otherwise.
// affordance exists even outside selection mode. // Attached always so the long-press affordance exists even
// outside selection mode.
Modifier Modifier
.combinedClickable( .combinedClickable(
onClick = { onClick = {
@@ -2509,8 +2582,12 @@ private fun MessageBubble(
onRetry() onRetry()
} }
}, },
onLongClick = { onLongPress() }, onLongClick = {
).rightClick { onLongPress() }, if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
},
).rightClick {
if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
},
).padding(horizontal = 12.dp, vertical = 8.dp), ).padding(horizontal = 12.dp, vertical = 8.dp),
) { ) {
// Reasoning block above the answer (assistant, non-commentary). // Reasoning block above the answer (assistant, non-commentary).
@@ -2524,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,
@@ -2571,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,
) )
} }
} }
@@ -2628,6 +2701,32 @@ private fun MessageBubble(
} }
} }
} }
if (hasMenu) {
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
onCopy?.let {
DropdownMenuItem(text = { Text("Copy") }, onClick = {
menuOpen = false
it()
})
}
onSelect?.let {
DropdownMenuItem(
text = { Text("Select messages") },
onClick = {
menuOpen = false
it()
},
)
}
onDelete?.let {
DropdownMenuItem(text = { Text("Delete") }, onClick = {
menuOpen = false
it()
})
}
}
}
}
if (!isUser && selectionMode) { if (!isUser && selectionMode) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
SelectionCheck(selected) SelectionCheck(selected)
@@ -2699,6 +2798,7 @@ private fun SelectionCheck(selected: Boolean) {
private fun SelectionToolbar( private fun SelectionToolbar(
count: Int, count: Int,
onCancel: () -> Unit, onCancel: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
Row( Row(
@@ -2720,6 +2820,20 @@ private fun SelectionToolbar(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
// Copy the selection (secondary action → neutral chip, Delete stays
// the accent primary action).
Box(
modifier =
Modifier
.clip(RoundedCornerShape(20.dp))
.background(if (count > 0) IrisColors.chip else Color.Transparent)
.clickable(enabled = count > 0) { onCopy() }
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text("⧉ Copy", color = if (count > 0) IrisColors.textBright else IrisColors.textDim, fontSize = 14.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -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) {
+6 -6
View File
@@ -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.
@@ -40,7 +40,7 @@ Everything in the feature checklist below.
## Feature checklist → where it's handled ## Feature checklist → where it's handled
| Requirement | Gateway plugin | App | | Requirement | Gateway plugin | App |
|---|---|---| | --- | --- | --- |
| Input box, auto-grow (max height) | — | Compose `TextField` + bounded `heightIn` | | Input box, auto-grow (max height) | — | Compose `TextField` + bounded `heightIn` |
| Menu button → all slash commands | Dispatches `/…`; serves command catalog | Bottom-sheet menu + `/` autocomplete | | Menu button → all slash commands | Dispatches `/…`; serves command catalog | Bottom-sheet menu + `/` autocomplete |
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing | | Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
@@ -55,9 +55,9 @@ Everything in the feature checklist below.
## Locked decisions (from planning) ## Locked decisions (from planning)
| 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) |
@@ -74,7 +74,7 @@ Everything in the feature checklist below.
## Verified environment state (2026-08-19) ## Verified environment state (2026-08-19)
| Item | State | | Item | State |
|---|---| | --- | --- |
| OS | CachyOS (Arch-based), `pacman` present | | OS | CachyOS (Arch-based), `pacman` present |
| JDK | **Not installed** → Milestone M0 (`pacman -S jdk17-openjdk`) | | JDK | **Not installed** → Milestone M0 (`pacman -S jdk17-openjdk`) |
| Android SDK | **Not installed** → M0 (cmdline-tools + sdkmanager) | | Android SDK | **Not installed** → M0 (cmdline-tools + sdkmanager) |
+4 -4
View File
@@ -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) │
@@ -72,7 +72,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
## Key architectural decisions + rationale ## Key architectural decisions + rationale
| Decision | Rationale | | Decision | Rationale |
|---|---| | --- | --- |
| **Community-style platform plugin** (`register(ctx)` → `ctx.register_platform`) | Zero hermes-core changes; survives hermes updates; follows `ADDING_A_PLATFORM.md` "Plugin Path". | | **Community-style platform plugin** (`register(ctx)` → `ctx.register_platform`) | Zero hermes-core changes; survives hermes updates; follows `ADDING_A_PLATFORM.md` "Plugin Path". |
| **Single WS transport** for chat, streaming, tools, media, pairing, FCM-token | One connection, one auth, one dependency (`websockets`, already core). Media as chunked binary frames avoids adding an HTTP server. | | **Single WS transport** for chat, streaming, tools, media, pairing, FCM-token | One connection, one auth, one dependency (`websockets`, already core). Media as chunked binary frames avoids adding an HTTP server. |
| **`websockets` + `httpx` only** | Both are hermes *core* deps → the plugin adds **zero** new Python dependencies (respects hermes supply-chain pinning policy). | | **`websockets` + `httpx` only** | Both are hermes *core* deps → the plugin adds **zero** new Python dependencies (respects hermes supply-chain pinning policy). |
@@ -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
View File
@@ -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
View File
@@ -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).
+17 -1
View File
@@ -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.
+45
View File
@@ -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.
+2 -2
View File
@@ -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)
+45 -6
View File
@@ -1,17 +1,54 @@
# 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
- A frame targets a `chat_id` whose device is **disconnected** (WS closed) → - A frame targets a `chat_id` whose device is **disconnected** (no live
drop to **outbox** + fire **push**. SSE/long-poll subscriber) → drop to **outbox** + fire **push** — with one
refinement, *turn-aware push* (below).
- Also fire push for high-priority foreground events the user should see even if - Also fire push for high-priority foreground events the user should see even if
the app is backgrounded (approvals, clarifies, cron completions) — the app the app is backgrounded (approvals, clarifies, cron completions) — the app
decides whether to also show an in-app banner. decides whether to also show an in-app banner.
- If the device is **connected**, no push (the live frame is enough). - If the device is **connected**, no push (the live frame is enough).
### 8.1.1 Turn-aware push (one push per turn, final answer as body)
An agent turn can span minutes and emit several completed status messages
("researching X…", "found Y…", "writing findings…", final answer). Pushing each
parked message would spam an offline user with the steps in between. So while
the agent's turn is **in flight** (hermes holds the typing indicator on from
turn start until the handler's `finally` at turn end), normal-priority
`message` / `message.stop` / `media.offer` frames that park with no live
device are **held back** per chat instead of pushing; the latest one is pushed
when the turn ends (`stop_typing`), so the offline user gets **one push with
the final answer**. Details:
- Turn state is tracked per `chat_id` from the typing indicator
(`send_typing` → in flight, `stop_typing` → ended; hermes fires
`stop_typing` in the handler's `finally`, after the final send, so the
flush always sees the final frame).
- The held-back frame is still parked in the outbox — sync catch-up is
unaffected; only the push is deferred.
- **High-priority notifications** (approval/clarify/cron) push immediately,
even mid-turn — they need user action.
- If the device **reconnects mid-turn** (SSE/long-poll open), the held-back
push is dropped: the app syncs the parked frames and must not get a
duplicate push at turn end.
- If the turn ends while the device is live, nothing is pushed (the frames
were delivered live / synced).
- Turns without a typing indicator (e.g. typing disabled in config) and
non-turn deliveries (cron, standalone sends) push immediately as before.
- Best-effort: a gateway crash mid-turn loses the held-back push (the frames
remain in the outbox and sync on reconnect).
## 8.2 `PushBackend` interface (`push.py`) ## 8.2 `PushBackend` interface (`push.py`)
```python ```python
@@ -23,9 +60,10 @@ 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` (optional; metadata via Google)
### 8.2.1 `FcmBackend` (primary)
- **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
OAuth2 access token (cached, refreshed before expiry). OAuth2 access token (cached, refreshed before expiry).
@@ -42,7 +80,8 @@ 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
`httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a `httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a
+76 -29
View File
@@ -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 -1
View File
@@ -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.
+4 -4
View File
@@ -1,13 +1,13 @@
# 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.
## 11.1 What's shared vs desktop-specific ## 11.1 What's shared vs desktop-specific
| Layer | Shared (commonMain) | Desktop-specific (desktopMain) | | Layer | Shared (commonMain) | Desktop-specific (desktopMain) |
|---|---|---| | --- | --- | --- |
| Protocol / WS client | ✅ | — | | Protocol / WS client | ✅ | — |
| Repositories / state | ✅ | — | | Repositories / state | ✅ | — |
| Most Compose UI | ✅ | layout tweaks, keyboard shortcuts | | Most Compose UI | ✅ | layout tweaks, keyboard shortcuts |
@@ -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
View File
@@ -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
View File
@@ -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 →
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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.
+5 -5
View File
@@ -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)
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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`).
--- ---
+3 -3
View File
@@ -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
View File
@@ -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`.
+44
View File
@@ -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.
+3 -2
View File
@@ -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
View File
@@ -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.
+143 -2647
View File
File diff suppressed because it is too large. Load diff
+358
View File
@@ -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
# ---------------------------------------------------------------------------
+335
View File
@@ -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)
+62
View File
@@ -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
+13
View File
@@ -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"}
+1 -1
View File
@@ -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:
+352
View File
@@ -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,
)
+152 -17
View File
@@ -45,6 +45,7 @@ import contextlib
import json import json
import logging import logging
import queue import queue
import socket
import ssl import ssl
import threading import threading
import time import time
@@ -199,7 +200,11 @@ class HttpServer:
from gateway.status import acquire_scoped_lock from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}" lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("iris", lock_key): # acquire_scoped_lock returns (acquired, existing_record); the
# tuple is always truthy, so test the first element (matching
# gateway/platforms/base.py's canonical usage).
acquired, _ = acquire_scoped_lock("iris", lock_key)
if not acquired:
logger.warning( logger.warning(
"iris: HTTP port %s:%s in use by another profile; server disabled", "iris: HTTP port %s:%s in use by another profile; server disabled",
host, host,
@@ -216,7 +221,14 @@ class HttpServer:
if self._adapter.http_cert and self._adapter.http_key: if self._adapter.http_cert and self._adapter.http_key:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key) ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) # The handshake runs in the per-connection thread with a
# hard timeout (see _ThreadingHTTPD.process_request).
# Wrapping the *listening* socket here instead would make
# serve_forever's accept() block inside do_handshake() on
# a half-open connection (TCP established, client gone
# mid-handshake), wedging ALL new device connections until
# the gateway is restarted.
httpd.set_tls(ctx)
except Exception as e: except Exception as e:
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e) logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
self._release_lock() self._release_lock()
@@ -241,17 +253,35 @@ class HttpServer:
s.q.put_nowait(_STOP) s.q.put_nowait(_STOP)
httpd = self._httpd httpd = self._httpd
self._httpd = None self._httpd = None
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:
@@ -275,6 +305,13 @@ class HttpServer:
def _add_sub(self, sub: _Subscriber) -> None: def _add_sub(self, sub: _Subscriber) -> None:
with self._subs_lock: with self._subs_lock:
self._subs.setdefault(sub.device_id, []).append(sub) self._subs.setdefault(sub.device_id, []).append(sub)
# M5: a live subscriber will sync the outbox -- tell the adapter to
# drop any held-back (deferred) pushes so the turn-end flush doesn't
# duplicate what the app already shows. getattr-guard: test doubles
# may use a bare adapter stub.
on_online = getattr(self._adapter, "on_device_online", None)
if on_online is not None:
on_online()
def _remove_sub(self, sub: _Subscriber) -> None: def _remove_sub(self, sub: _Subscriber) -> None:
with self._subs_lock: with self._subs_lock:
@@ -314,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
@@ -457,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):
@@ -467,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.
@@ -492,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
@@ -506,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(
@@ -516,7 +592,20 @@ class HttpServer:
for snap in self._adapter.todo_snapshot_frames(): for snap in self._adapter.todo_snapshot_frames():
self._write_sse(handler, "frame", None, snap.to_json()) self._write_sse(handler, "frame", None, snap.to_json())
# 3. Live frames (cursor=None frames have no id). # 3. Live frames (cursor=None frames have no id).
while not sub.closed.is_set(): while True:
if sub.closed.is_set():
# stop() can land between the initial writes above and
# this loop (the handler thread is descheduled under
# load): drain the frames queued before the close — e.g.
# the status{restarting} teardown broadcast — so the
# client sees them before EOF instead of losing them to
# the closed check.
try:
item = sub.q.get_nowait()
except queue.Empty:
reason = "stopped"
break
else:
try: try:
item = sub.q.get(timeout=SSE_HEARTBEAT_S) item = sub.q.get(timeout=SSE_HEARTBEAT_S)
except queue.Empty: except queue.Empty:
@@ -555,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
@@ -720,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):
@@ -775,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"})
+255
View File
@@ -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()
+2 -2
View File
@@ -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,
+127
View File
@@ -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)
+54
View File
@@ -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
+15 -11
View File
@@ -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,
@@ -303,10 +303,11 @@ class Outbox:
frame of a streamed reply -- or ``None`` when the message is not in the frame of a streamed reply -- or ``None`` when the message is not in the
outbox (e.g. already pruned by retention). outbox (e.g. already pruned by retention).
The lane is matched exactly first; when that finds nothing the lookup The lane is matched exactly first (a flat-lane lookup, ``thread_id
falls back to the ``message_id`` alone (it is a unique uuid4), so a = None``, sees only frames with no ``thread_id``); when that finds
stale/missing ``thread_id`` on the request still resolves the row. nothing the lookup falls back to the ``message_id`` alone across all
(``lane=None`` in the scan means "any lane".) lanes (it is a unique uuid4), so a stale/missing ``thread_id`` on the
request still resolves the row.
""" """
if not message_id: if not message_id:
return None return None
@@ -315,7 +316,7 @@ class Outbox:
"SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,) "SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall() ).fetchall()
def scan(lane: str | None) -> dict[str, Any] | None: def scan(lane: str | None, exact: bool) -> dict[str, Any] | None:
msg_frame: dict[str, Any] | None = None msg_frame: dict[str, Any] | None = None
stop_frame: dict[str, Any] | None = None stop_frame: dict[str, Any] | None = None
for r in rows: for r in rows:
@@ -325,7 +326,7 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if lane is not None and _frame_thread_id(frame) != lane: if exact and _frame_thread_id(frame) != lane:
continue continue
payload = frame.get("payload") payload = frame.get("payload")
if not isinstance(payload, dict) or payload.get("message_id") != message_id: if not isinstance(payload, dict) or payload.get("message_id") != message_id:
@@ -345,7 +346,7 @@ class Outbox:
} }
return msg_frame or stop_frame return msg_frame or stop_frame
return scan(thread_id) or scan(None) return scan(thread_id, exact=True) or scan(None, exact=False)
def delete_message( def delete_message(
self, self,
@@ -376,7 +377,7 @@ class Outbox:
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,) "SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall() ).fetchall()
def cursors_for(lane: str | None) -> list[int]: def cursors_for(lane: str | None, exact: bool) -> list[int]:
out: list[int] = [] out: list[int] = []
for r in rows: for r in rows:
try: try:
@@ -385,14 +386,17 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if lane is not None and _frame_thread_id(frame) != lane: if exact and _frame_thread_id(frame) != lane:
continue continue
payload = frame.get("payload") payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id: if isinstance(payload, dict) and payload.get("message_id") == message_id:
out.append(int(r["cursor"])) out.append(int(r["cursor"]))
return out return out
cursors = cursors_for(thread_id) or cursors_for(None) # Exact lane first (a flat-lane delete must not reach into
# threads); fall back to the message_id across all lanes only
# when the exact lane matches nothing (stale/missing thread_id).
cursors = cursors_for(thread_id, exact=True) or cursors_for(thread_id, exact=False)
if not cursors: if not cursors:
return 0 return 0
# One bound-parameter delete per cursor (a message spans only a few # One bound-parameter delete per cursor (a message spans only a few
+103
View File
@@ -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"],
+265
View File
@@ -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)
+83
View File
@@ -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
View File
@@ -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
+14 -6
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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)
+199
View File
@@ -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()
+2 -2
View File
@@ -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:
+266
View File
@@ -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
View File
@@ -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"]
+3 -3
View File
@@ -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",
+29
View File
@@ -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
+557
View File
@@ -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")
+157
View File
@@ -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()
+153
View File
@@ -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))
+67
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Fail the commit if gateway-plugin/plugin.yaml's `version:` field drifts
# from the repo-root VERSION file (the single source of truth).
#
# Why: `hermes plugins install <repo>#gateway-plugin` ships ONLY the
# gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so in production
# the gateway advertises the version from plugin.yaml (see
# gateway-plugin/version.py). If the two drift, the app shows a false
# "versions differ" warning.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
version_file="$repo_root/VERSION"
plugin_yaml="$repo_root/gateway-plugin/plugin.yaml"
[ -f "$version_file" ] || {
echo "check_version_sync: missing $version_file" >&2
exit 1
}
[ -f "$plugin_yaml" ] || {
echo "check_version_sync: missing $plugin_yaml" >&2
exit 1
}
root_version="$(tr -d '[:space:]' <"$version_file")"
# Mirrors gateway-plugin/version.py's _VERSION_RE: optional single OR double
# quote, at least one captured character.
yaml_version="$(sed -n "s/^version:[[:space:]]*[\"']\{0,1\}\([^\"'[:space:]]\{1,\}\).*/\1/p" "$plugin_yaml" | head -n1)"
if [ -z "$yaml_version" ]; then
echo "check_version_sync: no top-level 'version:' field in gateway-plugin/plugin.yaml" >&2
exit 1
fi
if [ "$root_version" != "$yaml_version" ]; then
echo "check_version_sync: version drift" >&2
echo " VERSION (repo root) = $root_version" >&2
echo " gateway-plugin/plugin.yaml = $yaml_version" >&2
echo "Bump both to the same value (VERSION is the source of truth)." >&2
exit 1
fi
@@ -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
+114 -56
View File
@@ -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)