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).
Tests for the iris gateway plugin.
Run via hermes's hermetic runner (never bare pytest)::
scripts/run_tests.sh tests/gateway/test_android.py
See docs/13-testing.md for the scenario list.
WS probe (ws_probe.py)
Manual test-client harness: connects to the real running gateway and
drives a turn, printing every frame. Run with the hermes venv python
(needs websockets); the gateway must already be up::
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
--token <IRIS_TOKEN> --send "hello"
Beyond the base modes (--send, --upload, --pull-offer, --sync,
--fcm-token/--fcm-reg, --authfail, --url, --token, --device,
--timeout), the probe has assertion and request modes:
--assert-turn— assert the turn producedmessage.start→ ≥1message.update→message.stop(scenario 2).--assert-reasoning— assert the finalmessage.stopcarries a non-emptyreasoningfield (scenario 3).--assert-tools— assert ≥1tool.startwith a matchingtool.end(matched byindex; scenario 4).--assert-commentary— assert ≥1commentaryframe (scenario 5).--assert-read-receipt— assert aread.receiptframe arrives after the sent message (new M7 frame; requires--send). SKIPs (exit 0, prints== SKIP: …) when the frame never arrives, e.g. against a gateway that predates the M7 frames.--assert-status— assert astatusframe is received (new M7 frame; SKIPs when absent).--search QUERY [--scope all|chat] [--chat-id C]— send asearchframe ({query, scope, limit}) and assert ≥1 hit insearch.results(scenario 8). With--send, the turn is driven first, then the search.--channel-create NAME/--channel-delete CHAT_ID/--channel-list— M3 channel directory management; create prints== channel created: <chat_id>for scripting.--watch CHAT_ID— wait up to--timeoutfor a message to land inCHAT_ID(cron delivery E2E, scenario 7).--offer-grace S— with--pull-offer, keep listening S seconds after the final message for amedia.offer(offers are emitted post-turn, right after the final; default 15).--http [--http-url http://host:port]— docs/19: drive the turn over the HTTP fallback leg instead of WS:GET /v1/health,POST /v1/frame(themessage.send), receive over SSEGET /v1/events. The same assertion flags apply. The base URL defaults to the--urlhost with schemews(s)→http(s)and port 8791 (IRIS_HTTP_PORT).
Exit codes: 0 ok (incl. SKIP for absent M7 frames), 2 connect fail,
3 no hello.ack, 4 expected hello.ack, 5 authfail expected but
acked, 6 timeout, 7 no final message, 8 upload/sync fail,
9 pull fail, 10 assert-turn fail, 11 assert-reasoning fail,
12 assert-tools fail, 13 assert-commentary fail, 14 search fail
(error or zero hits), 15 channel.create/list fail, 16 channel.delete
fail, 17 watch timeout, 18 read.receipt arrived before the sent
message, 19 status frame with empty payload, 20 --http health
check failed, 21 --http SSE open failed, 22 --http
POST /v1/frame rejected (4xx).
E2E driver (e2e.py)
Runs the docs/13-testing.md §13.4 scenarios 1–12 automated-where-
possible against the live gateway, invoking ws_probe.py (and the
hermes CLI for cron) as subprocesses. Prints PASS / PARTIAL / SKIP /
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 gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
~/.hermes/.env. The gateway must already be running (the driver never
starts or stops it). It is idempotent: channels/jobs it creates are
cleaned up even on failure, and leftover e2e-* channels/jobs from
earlier runs are removed at start.
Scenario notes: 3 (reasoning) and 5 (commentary) are model-dependent and SKIP rather than FAIL when the current model does not emit them; 11 (push) and 12 (reconnect/sync) are PARTIAL by design — the WS leg is automated, the device-notification / gateway-kill leg is manual.