Files
iris_x_hermes/docs/12-toolchain.md
T
ARIA 7faaf2aa1c
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s
Per-device tokens with revocation (issue #11)
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

4.3 KiB

12 — Toolchain Setup

First-time setup on a machine (verified baseline: CachyOS/Arch, pacman, uv present, ADB present, no JDK/SDK/Gradle).

12.1 JDK 17

pacman -S jdk17-openjdk
java -version   # expect 17.x

(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.)

12.2 Android SDK

# cmdline-tools
mkdir -p ~/android-sdk/cmdline-tools
cd ~/android-sdk/cmdline-tools
curl -O https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip commandlinetools-linux-*.zip && mv cmdline-tools latest
rm commandlinetools-linux-*.zip

export ANDROID_HOME=$HOME/android-sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools

sdkmanager --licenses
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"

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).

Create app/local.properties:

sdk.dir=/home/<you>/android-sdk

12.3 Gradle

No system install — use the project wrapper:

cd app
./gradlew tasks        # first run downloads the wrapper distribution

(The wrapper version is pinned in app/gradle/wrapper/gradle-wrapper.properties.)

12.4 hermes environment (for the plugin + running the gateway)

cd hermes-agent
uv sync                # creates .venv with all core deps (websockets, httpx, …)
source .venv/bin/activate
hermes --version       # sanity
  • Run the gateway with the plugin:

    # install the plugin (dev: symlink)
    mkdir -p ~/.hermes/plugins
    ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/iris
    hermes gateway status     # should list "iris"
    hermes gateway            # run
    
  • Tests use hermes's hermetic runner (never bare pytest):

    scripts/run_tests.sh tests/gateway/test_android.py
    

12.5 Firebase (FCM) — primary push

  1. Create a Firebase project (console.firebase.google.com).
  2. Add an Android app (package = androidApp applicationId, e.g. dev.iris.app). Download google-services.json → app/androidApp/.
  3. Create a service account (Project settings → Service accounts → Generate new private key) → download the JSON. Store its path in IRIS_FCM_SERVICE_ACCOUNT (in ~/.hermes/.env).
  4. The app's FirebaseMessagingService obtains the FCM token at runtime and registers it via hello / fcm.register.

Skip Firebase → the default is already ntfy: leave IRIS_PUSH_BACKEND unset (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)

Secrets (~/.hermes/.env):

IRIS_TOKEN=<64-hex>
IRIS_PUSH_BACKEND=ntfy           # default; fcm = opt-in (metadata via Google)
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# IRIS_FCM_SERVER_KEY=<legacy key>   # fallback if no service account
# NTFY_TOPIC=iris-push                  # when ntfy
# NTFY_SERVER_URL=https://ntfy.sh
# IRIS_WS_CERT=/path/cert.pem        # WSS
# IRIS_WS_KEY=/path/key.pem

Behavioral (~/.hermes/config.yaml):

gateway:
  platforms:
    iris:
      enabled: true
      extra:
        host: 127.0.0.1        # 0.0.0.0 for LAN
        port: 8790
        home_channel: default
        push_backend: fcm
        outbox_retention_hours: 72
        max_upload_bytes: 104857600   # 100 MB
display:
  platforms:
    iris:
      show_reasoning: true
      reasoning_style: code
      streaming: true
      tool_progress: all       # gateway sends full data; app controls display

12.7 Verify the stack (smoke test)

# 1. gateway up with plugin
hermes gateway status | grep -i android

# 2. a raw WS client can pair + echo
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.