diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a037bd8..28e2808 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,79 +1,79 @@ name: CI on: - push: - branches: [master] - pull_request: + push: + branches: [master] + pull_request: jobs: - gateway: - name: Gateway plugin tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + gateway: + name: Gateway plugin tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh - # The gateway tests run inside the hermes-agent test harness, which is - # git-ignored in this repo (read-only research reference). CI clones the - # upstream repo at a pinned commit and drops in the vendored test copy. - # Bump the pinned SHA when you update the local hermes-agent checkout. - - name: Clone hermes-agent (pinned) - run: | - git clone https://github.com/NousResearch/hermes-agent.git hermes-agent - git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b - git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b + # The gateway tests run inside the hermes-agent test harness, which is + # git-ignored in this repo (read-only research reference). CI clones the + # upstream repo at a pinned commit and drops in the vendored test copy. + # Bump the pinned SHA when you update the local hermes-agent checkout. + - name: Clone hermes-agent (pinned) + run: | + git clone https://github.com/NousResearch/hermes-agent.git hermes-agent + git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b + git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b - - name: Sync venv - run: | - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - cd hermes-agent - # pytest lives in the `dev` extra — a plain `uv sync` leaves the - # venv without it and run_tests.sh refuses to run. - uv sync --extra dev + - name: Sync venv + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + cd hermes-agent + # pytest lives in the `dev` extra — a plain `uv sync` leaves the + # venv without it and run_tests.sh refuses to run. + uv sync --extra dev - - name: Run android gateway tests - run: | - cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py - cd hermes-agent - ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ - scripts/run_tests.sh tests/gateway/test_android.py + - name: Run android gateway tests + run: | + cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py + cd hermes-agent + IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ + scripts/run_tests.sh tests/gateway/test_android.py - kotlin: - name: Kotlin tests (android host + desktop) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + kotlin: + name: Kotlin tests (android host + desktop) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" - # gradle.properties pins org.gradle.java.home to a local JDK path; - # strip it so CI uses the JDK installed by setup-java. - - name: Strip local JDK pin - run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties + # gradle.properties pins org.gradle.java.home to a local JDK path; + # strip it so CI uses the JDK installed by setup-java. + - name: Strip local JDK pin + run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties - - name: Install Android SDK - run: | - export ANDROID_HOME="$HOME/android-sdk" - mkdir -p "$ANDROID_HOME/cmdline-tools" - curl -fsSL -o /tmp/ct.zip \ - https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip - unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools" - mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" - # Finite input from a file: `yes | sdkmanager` dies with SIGPIPE - # (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager - # exits before `yes` is done writing. - for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt - "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null - echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" - echo "sdk.dir=$ANDROID_HOME" > app/local.properties + - name: Install Android SDK + run: | + export ANDROID_HOME="$HOME/android-sdk" + mkdir -p "$ANDROID_HOME/cmdline-tools" + curl -fsSL -o /tmp/ct.zip \ + https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip + unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools" + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + # Finite input from a file: `yes | sdkmanager` dies with SIGPIPE + # (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager + # exits before `yes` is done writing. + for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "sdk.dir=$ANDROID_HOME" > app/local.properties - # Host-side tests only (no device needed). AGP auto-downloads the - # missing SDK platforms (licenses accepted above). - - name: Run host tests - working-directory: app - run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest + # Host-side tests only (no device needed). AGP auto-downloads the + # missing SDK platforms (licenses accepted above). + - name: Run host tests + working-directory: app + run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index c3333d1..ca4b051 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -40,7 +40,7 @@ jobs: run: | cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cd hermes-agent - ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ + IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ scripts/run_tests.sh tests/gateway/test_android.py kotlin: diff --git a/AGENTS.md b/AGENTS.md index 5de6a36..e931d72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,18 +14,18 @@ ## Commands - `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`). -- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_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). - Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb). - Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite). - Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set). -- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`. +- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`. - E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`. ## Environment / pairing quirks -- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry. -- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP. +- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry. +- WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_HOST` to the gateway's LAN IP. - `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. - JDK 17; no system Gradle — always the wrapper (`./gradlew`). @@ -33,7 +33,7 @@ ## Testing quirks -- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_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`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`. - e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design. - ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`. - ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates. diff --git a/CI-SETUP.md b/CI-SETUP.md index 1875b13..0f756fd 100644 --- a/CI-SETUP.md +++ b/CI-SETUP.md @@ -58,7 +58,7 @@ jobs: run: | cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cd hermes-agent - ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ + IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ scripts/run_tests.sh tests/gateway/test_android.py kotlin: @@ -153,7 +153,7 @@ jobs: run: | cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cd hermes-agent - ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ + IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ scripts/run_tests.sh tests/gateway/test_android.py kotlin: diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 3fb0692..48deb5f 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + + + + + + + + + + + (null) private val deepLinkThreadId = mutableStateOf(null) + // QR pairing (docs/20): a parsed iris://pair link to prefill the Connect + // screen with. + private val pendingPair = mutableStateOf(null) + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ } @@ -37,7 +42,13 @@ class MainActivity : ComponentActivity() { setContent { val chatId by deepLinkChatId val threadId by deepLinkThreadId - IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId) + val pair by pendingPair + IrisApp( + store = store, + deepLinkChatId = chatId, + deepLinkThreadId = threadId, + deepLinkPair = pair, + ) } } @@ -64,6 +75,14 @@ class MainActivity : ComponentActivity() { * extras or an iris://chat/?thread= URI). */ private fun handleDeepLink(intent: Intent?) { val data = intent?.data + // QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… — + // the whole payload is in the query string; parse it with the shared + // PairLink parser. + if (data?.scheme == "iris" && data.host == "pair") { + val link = PairLink.parse(data.toString()) + if (link != null) pendingPair.value = link + return + } val chatId = intent?.getStringExtra("chat_id") ?: data?.pathSegments?.firstOrNull() diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 386e7fb..a1e7c6d 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -29,6 +29,9 @@ val kcefVersion = "2025.03.23" val markdownVersion = "0.44.0" // Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16). val sqldelightVersion = "2.3.2" +// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only. +val cameraxVersion = "1.5.1" +val mlKitVersion = "16.1.1" kotlin { android { @@ -111,6 +114,13 @@ kotlin { implementation("androidx.media3:media3-ui:1.11.0") // SAF picker (rememberLauncherForActivityResult). implementation("androidx.activity:activity-compose:1.13.0") + // CameraX + ML Kit for QR pairing (docs/20): the scanner activity + // uses the camera2 CameraX backend and ML Kit's barcode model. + implementation("androidx.camera:camera-core:$cameraxVersion") + implementation("androidx.camera:camera-camera2:$cameraxVersion") + implementation("androidx.camera:camera-lifecycle:$cameraxVersion") + implementation("androidx.camera:camera-view:$cameraxVersion") + implementation("com.google.mlkit:barcode-scanning:$mlKitVersion") // M5: FCM push (inert without a Firebase project / google-services.json; // the ntfy listener is the fallback). The google-services plugin is // applied conditionally in the app module. diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt index 13ef16f..36de7d4 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt @@ -33,7 +33,7 @@ actual fun postSystemNotification( threadId: String?, ) { val context = AndroidEnv.context - val id = chatId ?: "android:default" + val id = chatId ?: "default" // POST_NOTIFICATIONS is a runtime permission on API 33+. if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED diff --git a/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt b/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt index b006a1b..7278c99 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt @@ -53,7 +53,7 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() { // exception: the app must display them itself. if (message.notification != null) return val data = message.data - val chatId = data["chat_id"] ?: "android:default" + val chatId = data["chat_id"] ?: "default" val threadId = data["thread_id"] val title = data["title"] ?: "Iris" val body = data["body"] ?: data["title"].orEmpty() diff --git a/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt b/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt index 2af2bd6..b0f3160 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt @@ -100,7 +100,7 @@ class NtfyListenerService : Service() { } catch (_: Exception) { null } - val chatId = data?.str("chat_id") ?: "android:default" + val chatId = data?.str("chat_id") ?: "default" val threadId = data?.str("thread_id") // The short preview rides in the SSE `data:` field; fall back to the // X-Title, then a generic label. diff --git a/app/shared/src/androidMain/kotlin/iris/platform/PlatformQr.kt b/app/shared/src/androidMain/kotlin/iris/platform/PlatformQr.kt new file mode 100644 index 0000000..bc9f1fd --- /dev/null +++ b/app/shared/src/androidMain/kotlin/iris/platform/PlatformQr.kt @@ -0,0 +1,49 @@ +package iris.platform + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +/** + * Android QR scanner (docs/20): launches [QrScanActivity] and returns the + * scanned text (or null on cancel) to [onResult]. + */ +@Composable +actual fun QrScanButton(onResult: (String?) -> Unit) { + val context = LocalContext.current + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + val text = + if (result.resultCode == Activity.RESULT_OK) { + result.data?.getStringExtra(QrScanActivity.EXTRA_QR) + } else { + null + } + onResult(text) + } + Button(onClick = { + val activity = context.resolveActivity() + if (activity != null) { + launcher.launch(Intent(activity, QrScanActivity::class.java)) + } + }) { + Text("Scan QR") + } +} + +/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */ +private fun Context.resolveActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.resolveActivity() + else -> null + } diff --git a/app/shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt b/app/shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt new file mode 100644 index 0000000..bb46ce0 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt @@ -0,0 +1,136 @@ +package iris.platform + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage + +/** + * Full-screen QR scanner (docs/20). Launched from the Connect screen's + * "Scan QR" button; returns the raw QR text via [EXTRA_QR] on + * [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel. + * + * Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is + * stopped in [onDestroy]. + */ +class QrScanActivity : ComponentActivity() { + companion object { + /** Intent extra carrying the scanned QR text. */ + const val EXTRA_QR = "qr" + } + + private lateinit var previewView: PreviewView + private var cameraProvider: ProcessCameraProvider? = null + private var settled = false + + private val permissionLauncher = + registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) startCamera() else finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + previewView = + PreviewView(this).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + setContentView(previewView) + + onBackPressedDispatcher.addCallback(this) { + if (!settled) setResult(Activity.RESULT_CANCELED) + finish() + } + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + private fun startCamera() { + val future = ProcessCameraProvider.getInstance(this) + future.addListener( + { + val provider = future.get() + cameraProvider = provider + val preview = + Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analyzer = + ImageAnalysis + .Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + try { + provider.unbindAll() + provider.bindToLifecycle( + this, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analyzer, + ) + } catch (e: Exception) { + // No usable camera (e.g. headless emulator): bail out. + finish() + return@addListener + } + analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy -> + analyzeImage(proxy) + } + }, + ContextCompat.getMainExecutor(this), + ) + } + + private fun analyzeImage(proxy: ImageProxy) { + val mediaImage = proxy.image + if (mediaImage == null) { + proxy.close() + return + } + val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees) + BarcodeScanning + .getClient() + .process(inputImage) + .addOnSuccessListener { barcodes -> + val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue + if (text != null) finishWithResult(text) + }.addOnCompleteListener { proxy.close() } + } + + private fun finishWithResult(text: String) { + if (settled) return + settled = true + setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text)) + finish() + } + + override fun onDestroy() { + cameraProvider?.unbindAll() + super.onDestroy() + } +} diff --git a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt index 95cbb01..9585b6d 100644 --- a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt +++ b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -26,6 +27,7 @@ import iris.ui.theme.IrisColors import iris.ui.theme.IrisTheme import iris.ui.theme.LocalUserTheme import iris.ui.theme.rememberBackgroundImage +import iris.util.PairLink /** * Root composable shared by the Android and Desktop shells. @@ -39,6 +41,7 @@ fun IrisApp( store: SecureStore, deepLinkChatId: String? = null, deepLinkThreadId: String? = null, + deepLinkPair: PairLink? = null, ) { val controller = remember(store) { IrisController(store) } DisposableEffect(controller) { @@ -66,10 +69,11 @@ fun IrisApp( // untouched, so the UI keeps its proportions at any size. CompositionLocalProvider( LocalUserTheme provides theme, - LocalDensity provides Density( - density = baseDensity.density, - fontScale = baseDensity.fontScale * fontScale, - ), + LocalDensity provides + Density( + density = baseDensity.density, + fontScale = baseDensity.fontScale * fontScale, + ), ) { // The color goes through Surface's `color` parameter: a // Modifier.background on the Surface would be painted *under* the @@ -95,20 +99,34 @@ fun IrisApp( ) } val s = state + // QR pairing (docs/20): an iris://pair deep link prefills + // the Connect screen, taking priority over stored creds. + val pairUrl = deepLinkPair?.url ?: store.serverUrl + val pairToken = deepLinkPair?.token ?: store.token when (s) { - GatewayClient.State.Disconnected -> - ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token) - is GatewayClient.State.AuthFailed -> - ConnectScreen( - controller, - prefillUrl = store.serverUrl, - prefillToken = store.token, - initialError = "Pairing rejected: ${s.message}", - ) + GatewayClient.State.Disconnected -> { + key(deepLinkPair) { + ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken) + } + } + + is GatewayClient.State.AuthFailed -> { + key(deepLinkPair) { + ConnectScreen( + controller, + prefillUrl = pairUrl, + prefillToken = pairToken, + initialError = "Pairing rejected: ${s.message}", + ) + } + } + // Connecting / Reconnecting / Connected all render the chat; the header // status bubble + connection banner show the link state // without blocking the view (M7's full-screen spinner is gone). - else -> ChatScreen(controller) + else -> { + ChatScreen(controller) + } } // M9: full-screen HTML artifact preview (opened from an // artifact card in the chat); covers everything while open. @@ -117,4 +135,4 @@ fun IrisApp( } } } -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt index 6fa0c88..7b0bd62 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt @@ -132,15 +132,16 @@ class ChatStore { var streamingEnabled: Boolean = true companion object { - const val DEFAULT_LANE = "android:default" + const val DEFAULT_LANE = "default" fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}" } // ── Lane helpers ────────────────────────────────────────────────────── - /** Lane key for a (chat, thread) pair. Uses `::` as the separator because - * chat ids already contain a single `:` (e.g. `android:chan_1`). */ + /** Lane key for a (chat, thread) pair. Uses `::` as the separator so a + * thread lane can never collide with a chat id (chat ids are direct, + * e.g. `chan_1`, and never contain `:`). */ fun laneKey( chatId: String, threadId: String?, diff --git a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt index 88c8292..0e0d6bc 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt @@ -9,7 +9,7 @@ interface SecureStore { /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */ var serverUrl: String - /** ANDROID_TOKEN presented in the auth header. */ + /** IRIS_TOKEN presented in the auth header. */ var token: String /** Stable app-generated device id (persisted). */ diff --git a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt index 19fc161..1aaa940 100644 --- a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt +++ b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt @@ -33,7 +33,7 @@ import java.util.concurrent.TimeUnit import kotlin.random.Random /** - * HTTP client for the hermes android gateway (docs/19). + * HTTP client for the hermes iris gateway (docs/19). * * HTTP is the only transport: send via `POST /v1/frame`, receive over SSE * `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`. diff --git a/app/shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt b/app/shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt new file mode 100644 index 0000000..4dcfdfa --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt @@ -0,0 +1,13 @@ +package iris.platform + +import androidx.compose.runtime.Composable + +/** + * A "Scan QR" button that launches the platform QR scanner and delivers the + * scanned text (or null if the user cancelled) to [onResult] (docs/20). + * + * Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing + * (the Connect screen hides it there). + */ +@Composable +expect fun QrScanButton(onResult: (String?) -> Unit) diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 107113d..5d74c48 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -134,7 +134,7 @@ class IrisController( } /** Home channel id (from hello.ack; default until then). */ - private val _homeChannel = MutableStateFlow("android:default") + private val _homeChannel = MutableStateFlow("default") val homeChannel: StateFlow = _homeChannel.asStateFlow() /** Lanes whose full history has been loaded this session (in-memory; reset @@ -408,7 +408,7 @@ class IrisController( ) { if (isAppForeground()) return if (text.isBlank()) return - val id = chatId ?: "android:default" + val id = chatId ?: "default" val chatName = channels.byId(id)?.name postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId) } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 8d198c1..bd069a2 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -1314,7 +1314,7 @@ private fun SystemMessage(msg: MessageItem) { /** M7: header title pill — channel avatar + channel name. Automation * channels show their chat_id as a subtitle (tap the pill to copy it) so - * the user can target them for cron delivery (`deliver="android:"`) + * the user can target them for cron delivery (`deliver="iris:"`) * without asking the agent which channel is in use. */ @Composable private fun TitlePill( diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt index 585052f..d6938e7 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ConnectScreen.kt @@ -27,8 +27,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import iris.platform.QrScanButton +import iris.platform.isDesktop import iris.state.IrisController import iris.ui.theme.IrisColors +import iris.util.PairLink import kotlinx.coroutines.launch /** @@ -91,11 +94,25 @@ fun ConnectScreen( value = token, onValueChange = { token = it }, label = { Text("Pairing token") }, - placeholder = { Text("ANDROID_TOKEN (64 hex)") }, + placeholder = { Text("IRIS_TOKEN (64 hex)") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) + if (!isDesktop) { + Spacer(modifier = Modifier.height(12.dp)) + QrScanButton { raw -> + if (raw != null) { + val link = PairLink.parse(raw) + if (link != null) { + url = link.url + token = link.token + } else { + error = "Couldn't read that QR code." + } + } + } + } Spacer(modifier = Modifier.height(24.dp)) Button( @@ -129,7 +146,7 @@ fun ConnectScreen( Spacer(modifier = Modifier.height(24.dp)) Text( - "Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" + + "Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" + "hermes gateway setup on the gateway host.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/shared/src/commonMain/kotlin/iris/util/PairLink.kt b/app/shared/src/commonMain/kotlin/iris/util/PairLink.kt new file mode 100644 index 0000000..bb95db2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/util/PairLink.kt @@ -0,0 +1,97 @@ +package iris.util + +/** + * A parsed `iris://pair` deep link: the gateway URL to connect to plus the + * one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a + * tapped `iris://pair` link (docs/20). + */ +data class PairLink( + val url: String, + val token: String, +) { + companion object { + /** Default gateway HTTP port (docs/19). */ + const val DEFAULT_PORT = 8791 + + /** + * Parse a pairing link into a [PairLink], or return null on any + * malformation. + * + * - scheme must be `iris` (case-insensitive); the URI host must be `pair`. + * - required query params: `host` (non-empty) and `token` (non-empty). + * - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present. + * - `secure` defaults to `0`; `1` selects https. + * - `host`/`token` are percent-decoded (the Python side `quote()`s them). + */ + fun parse(raw: String): PairLink? { + val qIdx = raw.indexOf('?') + val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw + val query = if (qIdx >= 0) raw.substring(qIdx + 1) else "" + + // authority is "iris://pair": scheme, then "://", then the URI host. + val sep = authority.indexOf("://") + if (sep <= 0) return null + val scheme = authority.substring(0, sep) + val uriHost = authority.substring(sep + 3) + if (scheme.lowercase() != "iris") return null + if (uriHost.lowercase() != "pair") return null + + val params = parseQuery(query) + val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty() + val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty() + if (host.isEmpty() || token.isEmpty()) return null + + val portRaw = params["port"] + val port = + if (portRaw.isNullOrEmpty()) { + DEFAULT_PORT + } else { + portRaw.toIntOrNull() ?: return null + } + if (port !in 1..65535) return null + val secure = params["secure"]?.toIntOrNull() ?: 0 + val urlScheme = if (secure == 1) "https" else "http" + return PairLink("$urlScheme://$host:$port", token) + } + + /** Split a `k=v&k=v` query string into a map (values may be empty). */ + private fun parseQuery(query: String): Map { + if (query.isEmpty()) return emptyMap() + val map = LinkedHashMap() + for (pair in query.split('&')) { + if (pair.isEmpty()) continue + val eq = pair.indexOf('=') + if (eq < 0) { + map[pair] = "" + } else { + map[pair.substring(0, eq)] = pair.substring(eq + 1) + } + } + return map + } + + /** + * Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII + * — IPs and 64-hex tokens). A `%` that does not start a valid + * two-hex-digit escape is kept literally. + */ + private fun percentDecode(s: String): String { + val sb = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + val c = s[i] + if (c == '%' && i + 2 < s.length) { + val code = s.substring(i + 1, i + 3).toIntOrNull(16) + if (code != null) { + sb.append(code.toChar()) + i += 3 + continue + } + } + sb.append(c) + i++ + } + return sb.toString() + } + } +} diff --git a/app/shared/src/commonTest/kotlin/iris/data/ChatStoreCacheTest.kt b/app/shared/src/commonTest/kotlin/iris/data/ChatStoreCacheTest.kt index 17013a8..8cc58fc 100644 --- a/app/shared/src/commonTest/kotlin/iris/data/ChatStoreCacheTest.kt +++ b/app/shared/src/commonTest/kotlin/iris/data/ChatStoreCacheTest.kt @@ -13,7 +13,7 @@ class ChatStoreCacheTest { val store = ChatStore() store.loadFromCache( mapOf( - "android:default" to + "default" to listOf( MessageItem(id = "m1", role = "user", text = "hi", ts = 1), ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"), @@ -21,15 +21,15 @@ class ChatStoreCacheTest { ), ), ) - assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id }) + assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id }) } @Test fun loadFromCacheEmptyIsNoOp() { val store = ChatStore() - store.addPending("hello", "android:default") + store.addPending("hello", "default") store.loadFromCache(emptyMap()) - assertEquals(1, store.lanes.value["android:default"]!!.size) + assertEquals(1, store.lanes.value["default"]!!.size) } @Test @@ -39,7 +39,7 @@ class ChatStoreCacheTest { // to it, and the final answer. store.loadFromCache( mapOf( - "android:default" to + "default" to listOf( MessageItem(id = "m1", role = "user", text = "count", ts = 100), ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"), @@ -51,13 +51,13 @@ class ChatStoreCacheTest { // the tool card between the user message and the answer — not push // it to the end. store.loadHistory( - "android:default", + "default", listOf( MessageItem(id = "m1", role = "user", text = "count", ts = 100), MessageItem(id = "m2", role = "assistant", text = "16", ts = 200), ), ) - assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id }) + assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id }) } @Test @@ -65,7 +65,7 @@ class ChatStoreCacheTest { val store = ChatStore() store.loadFromCache( mapOf( - "android:default" to + "default" to listOf( MessageItem(id = "m1", role = "user", text = "count", ts = 100), ToolItem(id = "tool_1", index = 0, name = "bash", done = true), @@ -73,11 +73,11 @@ class ChatStoreCacheTest { ), ) store.loadHistory( - "android:default", + "default", listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)), ) // A card whose anchor is unknown falls to the end (degenerate case). - assertEquals(listOf("m1", "tool_1"), store.lanes.value["android:default"]!!.map { it.id }) + assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id }) } @Test @@ -87,7 +87,7 @@ class ChatStoreCacheTest { // card minted by the PREVIOUS process. store.loadFromCache( mapOf( - "android:default" to + "default" to listOf( MessageItem(id = "m1", role = "user", text = "hi", ts = 1), ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"), @@ -99,7 +99,7 @@ class ChatStoreCacheTest { store.onFrame( Frame( type = TYPE_TOOL_START, - chatId = "android", + chatId = "iris:other", payload = IrisJson.instance.encodeToJsonElement( ToolStartPayload.serializer(), @@ -107,7 +107,7 @@ class ChatStoreCacheTest { ), ), ) - val ids = store.lanes.value["android:default"]!!.map { it.id } + val ids = store.lanes.value["default"]!!.map { it.id } assertEquals(ids.size, ids.toSet().size) } } diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/PlatformQr.kt b/app/shared/src/desktopMain/kotlin/iris/platform/PlatformQr.kt new file mode 100644 index 0000000..2f8c705 --- /dev/null +++ b/app/shared/src/desktopMain/kotlin/iris/platform/PlatformQr.kt @@ -0,0 +1,12 @@ +package iris.platform + +import androidx.compose.runtime.Composable + +/** + * Desktop has no camera, so there is nothing to scan. The Connect screen hides + * this button on desktop (`isDesktop`), so this is never rendered. + */ +@Composable +actual fun QrScanButton(onResult: (String?) -> Unit) { + // No-op on desktop. +} diff --git a/app/shared/src/jvmTest/kotlin/iris/data/ChatDbTest.kt b/app/shared/src/jvmTest/kotlin/iris/data/ChatDbTest.kt index 454ffb9..ad09bcf 100644 --- a/app/shared/src/jvmTest/kotlin/iris/data/ChatDbTest.kt +++ b/app/shared/src/jvmTest/kotlin/iris/data/ChatDbTest.kt @@ -43,24 +43,24 @@ class ChatDbTest { val db = newDb() db.saveLanes( mapOf( - "android:default" to + "default" to listOf( msg("m1", ts = 100), ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"), msg("m2", role = "assistant", text = "hi", ts = 200), ), - "android:default::thr_1" to listOf(msg("m3", ts = 300)), + "default::thr_1" to listOf(msg("m3", ts = 300)), ), ) val loaded = db.loadLanes() - assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys) + assertEquals(setOf("default", "default::thr_1"), loaded.keys) // Tool cards are persisted and restored at their anchored position // (after the message they follow, before the answer). - assertEquals(listOf("m1", "tool_1", "m2"), loaded["android:default"]!!.map { it.id }) - assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id }) + assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id }) + assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id }) // Messages ordered by ts. - assertEquals(100L, (loaded["android:default"]!![0] as MessageItem).ts) - assertEquals(200L, (loaded["android:default"]!![2] as MessageItem).ts) + assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts) + assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts) } @Test @@ -68,7 +68,7 @@ class ChatDbTest { val db = newDb() db.saveLanes( mapOf( - "android:default" to + "default" to listOf( msg("m1", ts = 100), ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"), @@ -77,7 +77,7 @@ class ChatDbTest { ), ), ) - assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["android:default"]!!.map { it.id }) + assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id }) } @Test @@ -85,7 +85,7 @@ class ChatDbTest { val db = newDb() db.saveLanes( mapOf( - "android:default" to + "default" to listOf( msg("m1", ts = 100), ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"), @@ -93,7 +93,7 @@ class ChatDbTest { ), ), ) - assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["android:default"]!!.map { it.id }) + assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id }) } @Test @@ -101,7 +101,7 @@ class ChatDbTest { val db = newDb() db.saveLanes( mapOf( - "android:default" to + "default" to listOf( msg("m1", ts = 100), ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"), @@ -109,7 +109,7 @@ class ChatDbTest { ), ), ) - val lane = db.loadLanes()["android:default"]!! + val lane = db.loadLanes()["default"]!! // The process died before tool.end — the open card is closed as // interrupted, not left spinning. val open = lane.first { it.id == "tool_1" } as ToolItem @@ -123,9 +123,9 @@ class ChatDbTest { @Test fun saveLanesReplacesPreviousSnapshot() { val db = newDb() - db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2")))) - db.saveLanes(mapOf("android:default" to listOf(msg("m2")))) - assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id }) + db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2")))) + db.saveLanes(mapOf("default" to listOf(msg("m2")))) + assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id }) } @Test @@ -133,7 +133,7 @@ class ChatDbTest { val db = newDb() db.saveLanes( mapOf( - "android:default" to + "default" to listOf( msg("p1", status = MsgStatus.Pending, pending = true, ts = 0), msg("s1", role = "assistant", streaming = true, ts = 500), @@ -141,7 +141,7 @@ class ChatDbTest { ), ), ) - val lane = db.loadLanes()["android:default"]!! + val lane = db.loadLanes()["default"]!! val msgItem = { id: String -> lane.first { it.id == id } as MessageItem } // A pending send becomes failed (tap to retry); the gateway never // acknowledged it before the process died. @@ -156,7 +156,7 @@ class ChatDbTest { @Test fun systemMessagesAreNotPersisted() { val db = newDb() - db.saveLanes(mapOf("android:default" to listOf(msg("sys_1", role = "system", isSystem = true)))) + db.saveLanes(mapOf("default" to listOf(msg("sys_1", role = "system", isSystem = true)))) assertTrue(db.loadLanes().isEmpty()) } @@ -182,8 +182,8 @@ class ChatDbTest { ), ), ) - db.saveLanes(mapOf("android:default" to listOf(item))) - val loaded = db.loadLanes()["android:default"]!!.first() as MessageItem + db.saveLanes(mapOf("default" to listOf(item))) + val loaded = db.loadLanes()["default"]!!.first() as MessageItem assertEquals("gpt", loaded.runtime?.model) assertEquals("/tmp/a.png", loaded.media.first().localPath) } @@ -193,26 +193,26 @@ class ChatDbTest { val db = newDb() db.saveChannels( listOf( - ChannelInfo(chatId = "android:default", name = "General", isDefault = true), - ChannelInfo(chatId = "android:chan_1", name = "Work"), - ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"), + ChannelInfo(chatId = "default", name = "General", isDefault = true), + ChannelInfo(chatId = "chan_1", name = "Work"), + ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"), ), ) val loaded = db.loadChannels() assertEquals(3, loaded.size) - assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name) + assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name) assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind) - assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault) + assertEquals(true, loaded.first { it.chatId == "default" }.isDefault) } @Test fun metaRoundTripAndClearAll() { val db = newDb() assertNull(db.metaGet("last_lane")) - db.metaPut("last_lane", "android:chan_1") - assertEquals("android:chan_1", db.metaGet("last_lane")) - db.saveLanes(mapOf("android:default" to listOf(msg("m1")))) - db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General"))) + db.metaPut("last_lane", "chan_1") + assertEquals("chan_1", db.metaGet("last_lane")) + db.saveLanes(mapOf("default" to listOf(msg("m1")))) + db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General"))) db.clearAll() assertTrue(db.loadLanes().isEmpty()) assertEquals(emptyList(), db.loadChannels()) diff --git a/app/shared/src/jvmTest/kotlin/iris/util/PairLinkTest.kt b/app/shared/src/jvmTest/kotlin/iris/util/PairLinkTest.kt new file mode 100644 index 0000000..554845b --- /dev/null +++ b/app/shared/src/jvmTest/kotlin/iris/util/PairLinkTest.kt @@ -0,0 +1,89 @@ +package iris.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Parser tests for `iris://pair` links (docs/20). The payload shape mirrors + * `pairing.qr_payload` on the gateway side. + */ +class PairLinkTest { + private val token = "ab".repeat(32) // 64 hex chars + + @Test + fun parsesValidLink() { + val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token") + assertEquals("http://192.168.1.50:8791", link?.url) + assertEquals(token, link?.token) + } + + @Test + fun defaultsPortTo8791() { + val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token") + assertEquals("http://10.0.0.5:8791", link?.url) + } + + @Test + fun secureOneSelectsHttps() { + val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token") + assertEquals("https://10.0.0.5:8443", link?.url) + } + + @Test + fun schemeIsCaseInsensitive() { + val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token") + assertEquals("http://10.0.0.5:8791", link?.url) + } + + @Test + fun percentDecodesHost() { + // A hostname with a space would be %20 on the wire. + val link = PairLink.parse("iris://pair?host=my%20host&token=$token") + assertEquals("http://my host:8791", link?.url) + } + + @Test + fun rejectsMissingToken() { + assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791")) + } + + @Test + fun rejectsEmptyToken() { + assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token=")) + } + + @Test + fun rejectsMissingHost() { + assertNull(PairLink.parse("iris://pair?port=8791&token=$token")) + } + + @Test + fun rejectsBadPort() { + assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token")) + assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token")) + assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token")) + } + + @Test + fun rejectsWrongScheme() { + assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token")) + } + + @Test + fun rejectsWrongHost() { + assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token")) + } + + @Test + fun rejectsNoAuthority() { + assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token")) + } + + @Test + fun acceptsBoundaryPorts() { + assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null) + assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null) + } +} diff --git a/docs/00-overview.md b/docs/00-overview.md index 1fe862e..54eba30 100644 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -46,7 +46,7 @@ Everything in the feature checklist below. | Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing | | Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message | | Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble | -| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=android:[:]` | Channel list, thread toggle, "new channel" | +| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=iris:[:]` | Channel list, thread toggle, "new channel" | | Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle | | Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview | | Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service | @@ -57,7 +57,7 @@ Everything in the feature checklist below. | Decision | Choice | |---|---| | Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) | -| Push backend | **Both** — FCM primary, ntfy fallback (`ANDROID_PUSH_BACKEND`) | +| Push backend | **Both** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) | | 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) | @@ -66,7 +66,7 @@ Everything in the feature checklist below. 1. **`hermes-agent/` is a read-only research reference.** It lives next to this folder for study only. It is **git-ignored** and must **never** be committed, pushed, or included in any artifact. Our plugin is *installed* into a live - hermes home (`~/.hermes/plugins/android`); we never edit hermes core files. + hermes home (`~/.hermes/plugins/iris`); we never edit hermes core files. 2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S, Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start` to install, launch, and debug the app on-device throughout the build. @@ -88,6 +88,6 @@ Everything in the feature checklist below. ## Naming - Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`). -- hermes platform name: **`android`** (the plugin registers `Platform("android")`). +- hermes platform name: **`iris`** (the plugin registers `Platform("iris")`). - WS default port: **8790** (configurable). -- Default chat id: **`android:default`** (the home channel). \ No newline at end of file +- Default chat id: **`default`** (the home channel). \ No newline at end of file diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 49d32a3..6750418 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -12,7 +12,7 @@ │ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │ │ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │ │ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │ -│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │ +│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │ │ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │ │ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │ │ │ │ • media cache │ │ WSS │ │ @@ -40,8 +40,8 @@ ## Process model - **One `hermes gateway` process** hosts the agent core, the session store, the - cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket - server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`). + cron scheduler, *and* our `iris` platform plugin. The plugin's WebSocket + server runs on the gateway's asyncio loop (started in `IrisAdapter.connect()`). - **The app is a client.** It *initiates* the WS connection to the gateway (outbound), so no inbound port is needed on the phone. For LAN/remote access the user points the app at the gateway's LAN IP / Tailscale name / a WSS @@ -56,7 +56,7 @@ serves Telegram/Discord/… and cron) and the **`tui_gateway`** JSON-RPC backend (used by the TUI and the existing Electron desktop app). We deliberately use the **messaging gateway** because: -1. **Cron delivery is native.** Cron jobs resolve `deliver=android:[:]` +1. **Cron delivery is native.** Cron jobs resolve `deliver=iris:[:]` through the platform registry and call our adapter's `send()`. No bridging. 2. **`send_message` tool routing** works out of the box (plugin `parse_target_ref_fn`). @@ -86,7 +86,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`, 1. App sends `message.send {text}` (or `/cmd`). 2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) → - `AndroidAdapter.handle_message(event)`. + `IrisAdapter.handle_message(event)`. 3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent. 4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` → `adapter.send()` (first) / `adapter.edit_message()` (updates) → diff --git a/docs/02-monorepo.md b/docs/02-monorepo.md index 252e525..a8e4344 100644 --- a/docs/02-monorepo.md +++ b/docs/02-monorepo.md @@ -13,10 +13,10 @@ iris_x_hermes/ │ ├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored) │ -├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/android +├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/iris │ ├── plugin.yaml # manifest (kind: platform, env vars, home channel) │ ├── __init__.py -│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx) +│ ├── adapter.py # IrisAdapter(BasePlatformAdapter) + register(ctx) │ ├── ws_server.py # websockets server, connection registry, framing │ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin) │ ├── media.py # inbound cache + outbound chunked streaming @@ -50,9 +50,9 @@ iris_x_hermes/ ## Module responsibilities ### `gateway-plugin/` (Python) -- **`plugin.yaml`** — manifest: `name: android-platform`, `kind: platform`, +- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`, `requires_env` / `optional_env` (surfaced in `hermes config`/setup). -- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`. +- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`. The heart of the plugin. See `03-gateway-plugin.md`. - **`ws_server.py`** — `websockets` server, per-device connection registry, frame encode/decode, heartbeat, broadcast routing to all connected devices. @@ -62,7 +62,7 @@ iris_x_hermes/ `media.offer`/`media.pull` chunked streaming. - **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor. - **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and - `NtfyBackend` (reuses hermes ntfy publish). Selected by `ANDROID_PUSH_BACKEND`. + `NtfyBackend` (reuses hermes ntfy publish). Selected by `IRIS_PUSH_BACKEND`. - **`pairing.py`** — token generation/verification (constant-time), device registry (SQLite), QR payload. - **`search.py`** — FTS5 query bridge over the hermes session store. @@ -83,7 +83,7 @@ Thin shells: `Application`/`MainActivity` (Android) and `main()`/window ## Build systems - **Python plugin:** no build step (pure Python, stdlib + hermes core deps). - Installed by copying/symlinking into `~/.hermes/plugins/android`. Tested with + Installed by copying/symlinking into `~/.hermes/plugins/iris`. Tested with hermes's `scripts/run_tests.sh`. - **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin. `./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`, @@ -124,7 +124,7 @@ keystore.jks ## Install layout (runtime) -- **Plugin:** `~/.hermes/plugins/android/` ← copy of `gateway-plugin/` +- **Plugin:** `~/.hermes/plugins/iris/` ← copy of `gateway-plugin/` (or a symlink for dev). Discovered by hermes's `PluginManager`. - **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`. - **App (desktop, dev):** `./gradlew :desktopApp:run`. \ No newline at end of file diff --git a/docs/03-gateway-plugin.md b/docs/03-gateway-plugin.md index 8920397..cc05f9c 100644 --- a/docs/03-gateway-plugin.md +++ b/docs/03-gateway-plugin.md @@ -1,6 +1,6 @@ # 03 — Gateway Plugin (Python) -The plugin is a **community-style hermes platform plugin** named `android`. +The plugin is a **community-style hermes platform plugin** named `iris`. It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md` and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`. **Zero hermes-core changes. Zero new Python dependencies** (`websockets` and @@ -11,7 +11,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`. ## 3.1 `plugin.yaml` (manifest) ```yaml -name: android-platform +name: iris-platform label: Android kind: platform version: 0.1.0 @@ -22,63 +22,63 @@ description: > channels/threads, media, FTS5 search, and FCM/ntfy push. author: requires_env: - - name: ANDROID_TOKEN + - name: IRIS_TOKEN description: "Shared pairing token the app presents on connect" prompt: "Android pairing token" password: true optional_env: - - name: ANDROID_WS_HOST + - name: IRIS_WS_HOST description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)" prompt: "WS host" password: false - - name: ANDROID_WS_PORT + - name: IRIS_WS_PORT description: "WS port (default 8790)" prompt: "WS port" password: false - name: ANDROID_HOME_CHANNEL - description: "Default chat id for cron/notification delivery (default android:default)" + description: "Default chat id for cron/notification delivery (default default)" prompt: "Home channel" password: false - - name: ANDROID_ALLOWED_USERS + - name: IRIS_ALLOWED_USERS description: "Comma-separated allowed device_ids (empty = token-only auth)" prompt: "Allowed device ids" password: false - - name: ANDROID_ALLOW_ALL_USERS + - name: IRIS_ALLOW_ALL_USERS description: "Allow any paired device (dev only)" prompt: "Allow all devices? (true/false)" password: false - - name: ANDROID_PUSH_BACKEND + - name: IRIS_PUSH_BACKEND description: "Push backend: fcm (default) or ntfy" prompt: "Push backend" password: false - - name: ANDROID_FCM_SERVICE_ACCOUNT + - name: IRIS_FCM_SERVICE_ACCOUNT description: "Path to Firebase service-account JSON (FCM HTTP v1)" prompt: "FCM service account path" password: true - - name: ANDROID_FCM_SERVER_KEY + - name: IRIS_FCM_SERVER_KEY description: "Legacy FCM server key (fallback if no service account)" prompt: "FCM server key" password: true - name: NTFY_TOPIC - description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)" + description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)" prompt: "ntfy topic" password: false - name: NTFY_SERVER_URL description: "ntfy server URL (default https://ntfy.sh)" prompt: "ntfy server URL" password: false - - name: ANDROID_WS_CERT + - name: IRIS_WS_CERT description: "TLS cert path for WSS (optional)" prompt: "WSS cert" password: false - - name: ANDROID_WS_KEY + - name: IRIS_WS_KEY description: "TLS key path for WSS (optional)" prompt: "WSS key" password: false ``` Behavioral (non-secret) settings live in `config.yaml` under -`gateway.platforms.android.extra` (host, port, home_channel, outbox retention, +`gateway.platforms.iris.extra` (host, port, home_channel, outbox retention, max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets only.) @@ -87,21 +87,21 @@ only.) ```python def register(ctx): ctx.register_platform( - name="android", - label="Android", - adapter_factory=lambda cfg: AndroidAdapter(cfg), + name="iris", + label="Iris", + adapter_factory=lambda cfg: IrisAdapter(cfg), check_fn=check_requirements, # passive: websockets importable + token set validate_config=validate_config, # host/port/token present is_connected=is_connected, - required_env=["ANDROID_TOKEN"], + required_env=["IRIS_TOKEN"], install_hint="No extra packages needed (websockets + httpx are core deps)", setup_fn=interactive_setup, # hermes gateway setup flow env_enablement_fn=_env_enablement, # seed extra + home_channel from env cron_deliver_env_var="ANDROID_HOME_CHANNEL", standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch) - parse_target_ref_fn=_parse_target_ref, # "android:[:]" - allowed_users_env="ANDROID_ALLOWED_USERS", - allow_all_env="ANDROID_ALLOW_ALL_USERS", + parse_target_ref_fn=_parse_target_ref, # "iris:[:]" + allowed_users_env="IRIS_ALLOWED_USERS", + allow_all_env="IRIS_ALLOW_ALL_USERS", max_message_length=0, # 0 = no limit (WS has none) emoji="📱", pii_safe=False, @@ -123,26 +123,29 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`): `ensure_deps_fn`. - **`check_requirements()`** — passive probe: `import websockets` succeeds and - `ANDROID_TOKEN` is set. Never installs. + `IRIS_TOKEN` is set. Never installs. - **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra` (host/port/home_channel/push_backend) + a `home_channel` key - `{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status` + `{"chat_id": "default", "name": "Default"}` so `hermes gateway status` and cron home-channel resolution work without instantiating the adapter. -- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return - `(chat_id, thread_id)` parsed from `android:[:]`; else `None`. +- **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so + `ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional + `:t_` thread suffix; friendly names resolve via the channel directory. + Returns `(chat_id, thread_id)` or `None`. - **`interactive_setup()`** — prompts for token (or generates one), host/port, push backend + credentials, prints a QR code (pairing) and the app URL. -## 3.3 `AndroidAdapter(BasePlatformAdapter)` +## 3.3 `IrisAdapter(BasePlatformAdapter)` -Constructor: `super().__init__(config=config, platform=Platform("android"))`. +Constructor: `super().__init__(config=config, platform=Platform("iris"))`. Reads `config.extra` (env overrides win). Initializes: WS server (not started until `connect()`), connection registry, outbox (SQLite under -`get_hermes_home()/"android"`), push backend, pairing store, channel directory. +`get_hermes_home()/"iris"`), push backend, pairing store, channel directory. ### Lifecycle + - **`connect(*, is_reconnect=False) -> bool`** - - Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`) + - Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`) so two profiles can't bind the same port/identity. - Start the `websockets` server on `host:port` (TLS if cert/key set). - `_mark_connected()`; return True. @@ -150,6 +153,7 @@ until `connect()`), connection registry, outbox (SQLite under - Stop server, close all device sockets, release lock, `_mark_disconnected()`. ### Inbound (app → agent) + - WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via `self.build_source(chat_id, chat_name, chat_type, user_id, user_name, thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…, @@ -171,6 +175,7 @@ until `connect()`), connection registry, outbox (SQLite under - `sync {cursor}` → `outbox.py` → replay frames since cursor. ### Outbound (agent → app) + - **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`** - Split reasoning prefix (see `05-streaming.md`) → `reasoning` field. - If **any** device is connected: broadcast `message` frame to all. @@ -195,7 +200,9 @@ until `connect()`), connection registry, outbox (SQLite under channel directory, return it (used by cron "continuable" threads). ### Streaming hooks + The main gateway drives delivery through the **legacy callback path**: + - `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) + `edit_message()` (updates) → `message.start` / `message.update`. - `tool_progress_callback` → progress queue → `send_progress_messages` → @@ -230,7 +237,7 @@ verified empirically in M2 (see `13-testing.md`). `message.update` (coalesce to latest) under pressure, never drop `message`/`tool.end`/`notification`. -## 3.5 State & storage (all under `get_hermes_home()/"android"`) +## 3.5 State & storage (all under `get_hermes_home()/"iris"`) > Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe). > Never hardcode `~/.hermes`. @@ -245,9 +252,9 @@ verified empirically in M2 (see `13-testing.md`). ## 3.6 Config resolution -- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`, - `ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret). -- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`, +- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`, + `IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret). +- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`, `port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`, `max_upload_bytes`, `tls`. - Env vars override `config.yaml` (hermes convention). Read secrets with the @@ -260,4 +267,4 @@ verified empirically in M2 (see `13-testing.md`). - All outbound sends are best-effort; a dead socket latches and the frame falls to the outbox. - `disconnect()` cancels the server task and closes sockets cleanly. -- Token/PII redaction in all logs (hermes PII policy). \ No newline at end of file +- Token/PII redaction in all logs (hermes PII policy). diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index 0dcc71a..40c4838 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -12,7 +12,7 @@ Every frame: "v": 1, "id": 42, // optional; present on requests + their responses "type": "message", // frame type (below) - "chat_id": "android:default", // optional; scope for chat-scoped frames + "chat_id": "default", // optional; scope for chat-scoped frames "thread_id": "t_123", // optional "payload": { } // type-specific object } @@ -43,7 +43,7 @@ Pairing succeeded. "search":true,"push":"fcm","pickers":true}, "sync_cursor":1042, "last_pushed_cursor":1040, - "channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}] + "channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}] }} ``` @@ -57,7 +57,7 @@ woke the device via push (dedupe, `08-push.md` §8.7). A final / standalone message. ```json -{"type":"message","chat_id":"android:default","thread_id":null, +{"type":"message","chat_id":"default","thread_id":null, "payload":{ "message_id":"m_9001","role":"assistant", "text":"Here is the answer…", @@ -107,7 +107,7 @@ them drop the message(s) from their cache. Also outboxed, so a device that was offline learns of the deletion on its next `sync`. ```json -{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null, +{"type":"message.deleted","id":30,"chat_id":"default","thread_id":null, "payload":{"message_ids":["m_9001","m_9002"]}} ``` @@ -176,7 +176,7 @@ Channel directory updates. **Broadcast to all connected devices** (no explicit subscribe; the server pushes to every open WS). ```json -{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports", +{"type":"channel.created","payload":{"chat_id":"chan_7","name":"Cron Reports", "kind":"channel","parent_chat_id":null}} ``` @@ -190,7 +190,7 @@ title. Response to a `history` request. Returns a page of messages for a chat/thread. ```json -{"type":"history","id":20,"chat_id":"android:default","thread_id":null, +{"type":"history","id":20,"chat_id":"default","thread_id":null, "payload":{ "messages":[ {"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000}, @@ -244,9 +244,9 @@ Response to a `commands.complete` request. Autocomplete matches for a typed pref Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`. ```json -{"type":"agent.busy","chat_id":"android:default","thread_id":null, +{"type":"agent.busy","chat_id":"default","thread_id":null, "payload":{"reason":"processing"}} -{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}} +{"type":"agent.idle","chat_id":"default","thread_id":null,"payload":{}} ``` `reason` ∈ `processing | tool | waiting_input | cron`. @@ -256,7 +256,7 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy` ```json {"type":"search.results","id":7,"payload":{ "query":"deploy","scope":"all","hits":[ - {"message_id":"m_123","chat_id":"android:chan_7","thread_id":null, + {"message_id":"m_123","chat_id":"chan_7","thread_id":null, "role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}} ``` @@ -275,7 +275,7 @@ The gateway acknowledges that the agent has received and started processing the user's message. The app uses it to show ✓✓ on user bubbles. ```json -{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}} +{"type":"read.receipt","chat_id":"default","payload":{"message_id":"m_9001"}} ``` Emitted to the originating connection when a `message.send` is accepted for @@ -319,7 +319,7 @@ First frame; auth + caps. ```json {"type":"hello","payload":{ - "token":"","device_id":"dev_a1b2","device_name":"MIX 2S", + "token":"","device_id":"dev_a1b2","device_name":"MIX 2S", "caps":{"min_protocol":1,"media":true,"push":"fcm"}, "fcm_token":"","ntfy_topic":""}} ``` @@ -329,7 +329,7 @@ First frame; auth + caps. Send text (or a `/slash-command`). ```json -{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null, +{"type":"message.send","id":10,"chat_id":"default","thread_id":null, "payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"], "auto_thread":false}} ``` @@ -383,8 +383,8 @@ Answer an interactive picker. ```json {"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}} -{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}} -{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}} +{"type":"channel.rename","id":15,"chat_id":"chan_7","payload":{"name":"Reports"}} +{"type":"channel.set_default","id":16,"chat_id":"chan_7","payload":{}} ``` `channel.delete` is a **hard delete**: the channel/thread row is removed from @@ -396,7 +396,7 @@ removes its threads. The default channel cannot be deleted. ```json {"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}} -{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}} +{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"chan_7","thread_id":null}} ``` `scope` ∈ `all | chat`. @@ -408,7 +408,7 @@ broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to mark messages as read locally (✓✓ on user bubbles). ```json -{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}} +{"type":"read.receipt","payload":{"chat_id":"default","message_id":"m_9001"}} ``` ### `history` @@ -416,7 +416,7 @@ mark messages as read locally (✓✓ on user bubbles). Load a page of messages for a chat/thread (initial open, scroll-up pagination). ```json -{"type":"history","id":20,"chat_id":"android:default","thread_id":null, +{"type":"history","id":20,"chat_id":"default","thread_id":null, "payload":{"before_message_id":"m_8990","limit":50}} ``` @@ -433,7 +433,7 @@ message already gone (pruned by retention) still yields a `message.deleted` broadcast so live caches drop it. ```json -{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null, +{"type":"message.delete","id":30,"chat_id":"default","thread_id":null, "payload":{"message_ids":["m_9001","m_9002"]}} ``` @@ -458,7 +458,7 @@ Autocomplete for a typed `/prefix`. Stop the current agent turn (abort generation / tool execution). ```json -{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}} +{"type":"agent.stop","id":23,"chat_id":"default","thread_id":null,"payload":{}} ``` ### `agent.steer` @@ -466,7 +466,7 @@ Stop the current agent turn (abort generation / tool execution). Inject a steering message mid-turn (redirects the agent without a new turn). ```json -{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null, +{"type":"agent.steer","id":24,"chat_id":"default","thread_id":null, "payload":{"text":"Actually, focus on the error case."}} ``` diff --git a/docs/05-streaming.md b/docs/05-streaming.md index cd34a84..004c7ce 100644 --- a/docs/05-streaming.md +++ b/docs/05-streaming.md @@ -28,7 +28,7 @@ finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll while the user is at the bottom. **Streaming on/off.** Two levels: -- **Gateway side:** hermes `display.platforms.android.streaming` (default +- **Gateway side:** hermes `display.platforms.iris.streaming` (default follows global). When off, the app just gets one final `message` frame. - **App side (per device):** Settings → "Streaming" toggle (default on). When off, the app ignores `message.start`/`message.update` frames and @@ -49,11 +49,11 @@ chosen by `reasoning_style` (`gateway/display_config.py:37`): - `blockquote`: `> 💭 **Reasoning:**\n> …\n\n` - `subtext`: `-# 💭 Reasoning\n-# …\n\n` (Discord-style) -**Plugin config.** Set for the `android` platform: +**Plugin config.** Set for the `iris` platform: ```yaml display: platforms: - android: + iris: show_reasoning: true reasoning_style: code # we split on the code-fence form ``` diff --git a/docs/06-channels-cron-search.md b/docs/06-channels-cron-search.md index db52103..6953a2d 100644 --- a/docs/06-channels-cron-search.md +++ b/docs/06-channels-cron-search.md @@ -9,10 +9,10 @@ gateway identity concepts**. | App concept | hermes primitive | Example | | --- | --- | --- | -| Default chat | home channel `chat_id` | `android:default` | -| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` | -| A user-created channel | a new `chat_id` | `android:chan_7` | -| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=android:chan_7, thread_id=t_31` | +| Default chat | home channel `chat_id` | `default` | +| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=default, thread_id=t_12` | +| A user-created channel | a new `chat_id` | `chan_7` | +| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=chan_7, thread_id=t_31` | - **`chat_id`** = the conversation lane (a channel or the default chat). - **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like). @@ -23,7 +23,7 @@ gateway identity concepts**. ## 6.2 Default chat - On first connect, the plugin ensures a **default channel** exists: - `chat_id = ANDROID_HOME_CHANNEL` (default `android:default`), `kind=default`, + `chat_id = ANDROID_HOME_CHANNEL` (default `default`), `kind=default`, `is_default=true`, name "Default". - It is also the **cron home channel** (`cron_deliver_env_var= ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here. @@ -37,7 +37,7 @@ gateway identity concepts**. - **Threads OFF** — flat conversation; all messages use `thread_id=null`. - **Threads ON** — the app groups the conversation into topic-like lanes. Each new "topic" mints a `thread_id` (via `channel.create {kind:thread, - parent_chat_id:android:default}` or an implicit thread). The UI shows a + parent_chat_id:default}` or an implicit thread). The UI shows a topic switcher (like Telegram topics) above the message list. - Threads are **app-organized** but **gateway-real**: each `thread_id` is a distinct hermes session lane, so context is isolated per thread and cron can @@ -82,7 +82,7 @@ lane (nothing to title / session-scoped, not conversation starters). - **Requirement:** the user creates new channels so **cron job outputs can be delegated to them** instead of the default chat. - **`channel.create {name, kind:"channel"}`** → plugin mints - `chat_id = android:chan_`, stores in directory, broadcasts + `chat_id = chan_`, stores in directory, broadcasts `channel.created` to all devices. The new channel appears in the channel list. - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the directory (rename broadcasts `channel.renamed`; delete is a **hard delete** @@ -104,9 +104,9 @@ lane (nothing to title / session-scoped, not conversation starters). - **Cron targeting** (the key payoff): because the plugin registers `parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the `send_message` tool can target any channel/thread: - - `deliver="android"` → home (default) channel. - - `deliver="android:android:chan_7"` → that channel. - - `deliver="android:android:chan_7:t_31"` → that channel's thread. + - `deliver="iris"` → home (default) channel. + - `deliver="iris:chan_7"` → that channel. + - `deliver="iris:chan_7:t_31"` → that channel's thread. - In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron Reports* channel"; the gateway resolves the name via the channel directory. - **In-app affordance:** each channel's menu has "Set as cron target" / shows a @@ -118,7 +118,7 @@ lane (nothing to title / session-scoped, not conversation starters). - Cron resolves delivery targets in `cron/scheduler.py:2148` (`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it calls `tools.send_message_tool.resolve_send_target`, which uses our - `parse_target_ref_fn` to parse `android:[:]`. + `parse_target_ref_fn` to parse `iris:[:]`. - Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway running) → our WS `message` frame (or outbox+push if the app is offline). - Cron deliveries are framed with a `[Cron delivery: ]` header by hermes; diff --git a/docs/08-push.md b/docs/08-push.md index 0b3bb7a..b1960b9 100644 --- a/docs/08-push.md +++ b/docs/08-push.md @@ -1,7 +1,7 @@ # 08 — Push Notifications, Outbox & Sync The gateway can't reach a sleeping phone directly. Push goes through a cloud -relay. **Decision: FCM primary, ntfy fallback** (`ANDROID_PUSH_BACKEND`). +relay. **Decision: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`). ## 8.1 When push fires @@ -23,13 +23,13 @@ class PushBackend(Protocol): def configured(self) -> bool: ... ``` -Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`). +Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`). ### 8.2.1 `FcmBackend` (primary) - **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service - account** (`ANDROID_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). -- Fallback: legacy **server key** (`ANDROID_FCM_SERVER_KEY`) if no service +- Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service account (simpler, but legacy). - Target = the device's **FCM token** (registered via `hello` / `fcm.register`, stored in `devices.db`). diff --git a/docs/09-pairing-security.md b/docs/09-pairing-security.md index dd2607a..c9eedee 100644 --- a/docs/09-pairing-security.md +++ b/docs/09-pairing-security.md @@ -13,19 +13,20 @@ ## 9.2 Pairing flow 1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either - uses an existing `ANDROID_TOKEN` or generates a fresh high-entropy token + uses an existing `IRIS_TOKEN` or generates a fresh high-entropy token (e.g. 32 bytes → 64 hex chars) and stores it in `.env`. 2. **Present to the app.** Two options: - **QR code:** the setup prints a QR encoding - `iris://pair?host=&port=8790&token=` (or a WSS URL). The - phone scans it with the app's camera (or a system scanner) → pre-fills + `iris://pair?host=&port=8791&secure=0&token=` (or a WSS + URL when `secure=1`). The phone scans it with the app's **Scan QR** + button (or a system scanner → `iris://pair` deep link) → pre-fills settings. - **Manual:** user types the server URL + token in the app's Connect screen. 3. **App connects.** First WS frame is `hello {token, device_id, device_name, caps, fcm_token?}`. -4. **Server verifies.** Constant-time compare of `token` vs `ANDROID_TOKEN` +4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN` (`hmac.compare_digest`). Optionally check `device_id` against - `ANDROID_ALLOWED_USERS` (if set) or `ANDROID_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`. **On failure:** send `error {code:"auth"}` and close. @@ -36,24 +37,24 @@ security principal (the token is). ## 9.3 Auth model - **Token = the security principal.** Any connection presenting the valid - `ANDROID_TOKEN` is authorized (it's the user's own token). -- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated + `IRIS_TOKEN` is authorized (it's the user's own token). +- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated `device_id`s) restricts which *devices* may connect even with the token — - useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the + useful if the token is shared. `IRIS_ALLOW_ALL_USERS=true` disables the allowlist (dev only). - **Per-device tokens (stretch):** mint a unique token per device at pairing (revocable) instead of one shared token. v1 uses the shared token + optional device allowlist. -- **Re-pairing:** rotating `ANDROID_TOKEN` invalidates all devices; they must +- **Re-pairing:** rotating `IRIS_TOKEN` invalidates all devices; they must re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR. ## 9.4 Transport security - **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home network. -- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY` +- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (self-signed or CA-signed). The app pins/accepts the cert (self-signed → user - confirms fingerprint on first pair, like a SSH host key). + confirms fingerprint on first pair, like a SSH host key). - **Remote reachability options** (documented, user's choice): - **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP; app connects over the private mesh. No public exposure. @@ -61,11 +62,11 @@ security principal (the token is). at the edge, forward WS to `127.0.0.1:8790`. - **Public bind** (`0.0.0.0`) + WSS + strong token — last resort. - **HTTP fallback leg (docs/19):** the gateway also serves the same frames - over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's + over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's fallback transport. It is a *second door with the same lock*: the same Bearer token (constant-time `verify_token`) + the same device allowlist (`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as - the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`. + the WS. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`. `GET /v1/health` is unauthenticated by design (liveness only — it must not reflect tokens, device ids, or versions). - The app stores the server URL + (for self-signed) the pinned cert fingerprint @@ -73,7 +74,7 @@ security principal (the token is). ## 9.5 Secret & PII handling -- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy +- **Tokens/keys never logged.** Redact `IRIS_TOKEN`, FCM tokens/keys, ntfy tokens in all log output (hermes PII policy; `agent/redact.py` patterns). - **`device_id`** is a random UUID (not PII). `device_name` is user-chosen. - **Media pull** is gated by hermes `validate_media_delivery_path` + delivery @@ -84,7 +85,7 @@ security principal (the token is). ## 9.6 Profile safety -- All plugin state lives under `get_hermes_home()/"android"` (profile-aware). +- All plugin state lives under `get_hermes_home()/"iris"` (profile-aware). - Secrets are read with the scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak each other's tokens (fail-closed under `gateway.multiplex_profiles`). @@ -110,16 +111,16 @@ M7 research pass. "verified" = implemented and covered by "gap" = known limitation with the planned mitigation. | # | Item | Status | Evidence / mitigation | -|---|------|--------|-----------------------| +| --- | ------ | -------- | ----------------------- | | 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) | | 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` | | 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 | -| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_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 | 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 | | 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 `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_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_WS_CERT`/`IRIS_WS_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 | | 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` | -| 12 | Gap: in-app QR scanner | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later | \ No newline at end of file +| 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` | diff --git a/docs/10-android-app.md b/docs/10-android-app.md index e5ffd93..8a2efb0 100644 --- a/docs/10-android-app.md +++ b/docs/10-android-app.md @@ -115,7 +115,7 @@ app/shared/src/ - `ToolCard` renders `tool.start/progress/end` frames. - The gateway always supplies the **full** tool data: it forces - `display.platforms.android.tool_progress: verbose` (so the progress line + `display.platforms.iris.tool_progress: verbose` (so the progress line carries the full args JSON → `tool.start.args`) and captures each completed call via the `post_tool_call` hook (→ `tool.end` `output_preview` / `duration` / `ok`). The app decides how much to show. @@ -317,5 +317,13 @@ Storage: `AndroidSqliteDriver` (app database dir) on Android, - First launch → **Connect**: server URL + token (or scan QR). "Test connection" does a real `hello` (not just a TCP probe — per hermes desktop guidance, the auth leg must be exercised). On success → save (secure storage) → main. +- **Scan QR** (Android only, `docs/20`): a button below the token field opens + `QrScanActivity` (CameraX + ML Kit, on-device, no Play services), requests the + `CAMERA` permission, and pre-fills URL + token from the decoded + `iris://pair…` payload via `PairLink.parse`. It never auto-connects — the + user still taps "Test & Connect". A non-pairing QR sets an error and leaves + the fields untouched. The same payload also arrives as an `iris://pair` deep + link (system scanner / other phones) and pre-fills the screen the same way. + Hidden on desktop (no camera). - States: connecting / connected / reconnecting / degraded / auth-failed — each with honest copy and a way out. diff --git a/docs/12-toolchain.md b/docs/12-toolchain.md index d2712ca..583e8aa 100644 --- a/docs/12-toolchain.md +++ b/docs/12-toolchain.md @@ -57,8 +57,8 @@ hermes --version # sanity ```bash # install the plugin (dev: symlink) mkdir -p ~/.hermes/plugins - ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/android - hermes gateway status # should list "android" + 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`): @@ -73,43 +73,43 @@ hermes --version # sanity `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 - `ANDROID_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`). + `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 → set `ANDROID_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` / +> Skip Firebase → set `IRIS_PUSH_BACKEND=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`):** ``` -ANDROID_TOKEN=<64-hex> -ANDROID_PUSH_BACKEND=fcm # or ntfy -ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json -# ANDROID_FCM_SERVER_KEY= # fallback if no service account +IRIS_TOKEN=<64-hex> +IRIS_PUSH_BACKEND=fcm # or ntfy +IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json +# IRIS_FCM_SERVER_KEY= # fallback if no service account # NTFY_TOPIC=iris-push # when ntfy # NTFY_SERVER_URL=https://ntfy.sh -# ANDROID_WS_CERT=/path/cert.pem # WSS -# ANDROID_WS_KEY=/path/key.pem +# IRIS_WS_CERT=/path/cert.pem # WSS +# IRIS_WS_KEY=/path/key.pem ``` **Behavioral (`~/.hermes/config.yaml`):** ```yaml gateway: platforms: - android: + iris: enabled: true extra: host: 127.0.0.1 # 0.0.0.0 for LAN port: 8790 - home_channel: android:default + home_channel: default push_backend: fcm outbox_retention_hours: 72 max_upload_bytes: 104857600 # 100 MB display: platforms: - android: + iris: show_reasoning: true reasoning_style: code streaming: true @@ -128,7 +128,7 @@ 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":"","device_id":"test","device_name":"probe", + "token":"","device_id":"test","device_name":"probe", "caps":{"min_protocol":1}}})) print("recv:", await ws.recv()) asyncio.run(main()) diff --git a/docs/13-testing.md b/docs/13-testing.md index bad1fe0..832d3c2 100644 --- a/docs/13-testing.md +++ b/docs/13-testing.md @@ -19,7 +19,7 @@ without the app (critical for verifying frame shapes early). - `register(ctx)` produces a valid `PlatformEntry` (name, cron env var, parse_target_ref). - `check_requirements` / `validate_config` / `is_connected` truth table. - - `_parse_target_ref`: `android:`, `android::`, non-android + - `_parse_target_ref`: `iris:`, `iris::`, non-android → None. - **Reasoning split:** given a `show_reasoning`-style final text, `send()` emits `message {reasoning, text}` correctly; no-prefix → no reasoning field. @@ -54,7 +54,7 @@ Kotlin client. ```bash hermes gateway & # with the android plugin -python gateway-plugin/tests/ws_probe.py --token \ +python gateway-plugin/tests/ws_probe.py --token \ --send "list the files and summarize" # prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end, # commentary, message.stop {reasoning,…}, … @@ -112,7 +112,7 @@ adb logcat -d > /tmp/logcat.txt verbosity in Settings → rendering changes. 5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed. 6. **Channels:** create "Cron Reports" → appears in list; set as cron target. -7. **Cron delivery:** create a cron job `deliver=android:android:chan_` → it +7. **Cron delivery:** create a cron job `deliver=iris:chan_` → it fires → lands in that channel (not default). 8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump. 9. **Media (in):** attach a photo + a video → agent receives (vision) → reply. @@ -144,11 +144,11 @@ adb logcat -d > /tmp/logcat.txt ## 13.5 Debugging tips - **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`). - Our plugin logs under the `android` adapter name; secrets redacted. + Our plugin logs under the `iris` adapter name; secrets redacted. - **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol from the app. - **Streaming jitter:** the consumer edits at intervals; if updates look chunky, - check `display.platforms.android.streaming` and the consumer's edit interval. + check `display.platforms.iris.streaming` and the consumer's edit interval. - **Media pull stalls:** check chunk size + backpressure; confirm the file is within hermes delivery roots (`validate_media_delivery_path`). - **FCM not arriving:** confirm the token registered (`devices.db`), the service diff --git a/docs/14-milestones.md b/docs/14-milestones.md index 2fa4ef0..d2eb1b8 100644 --- a/docs/14-milestones.md +++ b/docs/14-milestones.md @@ -1,4 +1,4 @@ -# 14 — Milestones (M0–M7) +# 14 — Milestones (M0–M8) Phased delivery. Each milestone ends with a **demo** (on-device where noted) and has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. @@ -6,7 +6,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. --- ## M0 — Toolchain & scaffolding + **Goal:** everything builds; the plugin is discoverable; the repo is safe. + - [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`). - [X] `cd hermes-agent && uv sync` (hermes venv works). - [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/` @@ -16,20 +18,22 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. - [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`, `./gradlew :desktopApp:run` (blank window). - [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a - no-op `AndroidAdapter` → `hermes gateway status` lists **android**. -- **Demo:** `hermes gateway status` shows `android`; `./gradlew + no-op `IrisAdapter` → `hermes gateway status` lists **iris**. +- **Demo:** `hermes gateway status` shows `iris`; `./gradlew :androidApp:installDebug` installs a blank app on the MIX 2S. - **Accept:** blank app installs + launches on-device; plugin visible in `hermes gateway status`; `hermes-agent/` is git-ignored (verify with `git status --ignored`). ## M1 — Gateway core loop (text round-trip) + **Goal:** pair + send a text message + get a (non-streaming) reply. + - [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time), `hello.ack`, heartbeat, connection registry. -- [X] `AndroidAdapter.send()` → `message` frame; inbound `message.send` → +- [X] `IrisAdapter.send()` → `message` frame; inbound `message.send` → `MessageEvent` → `handle_message`. -- [X] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`. +- [X] Pairing store + `IRIS_TOKEN`; QR payload in `interactive_setup`. - [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient` (connect + reconnect), ChatScreen sends + renders `message`. - [X] `ws_probe.py` harness drives a real turn. @@ -38,9 +42,11 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. reconnect after gateway restart re-pairs. ## M2 — Streaming + reasoning + tools + commentary + **Goal:** the "agent transparency" features. + - [X] Map consumer `send`/`edit_message` → `message.start/update/stop`. -- [X] Reasoning: set `show_reasoning` for android; adapter splits prefix → +- [X] Reasoning: set `show_reasoning` for iris; adapter splits prefix → `reasoning` field. **Verify format with `ws_probe.py`.** (The model returns a separate `reasoning_content` field. In the *streaming* case the gateway drops it — the stream consumer only forwards `content` and the @@ -64,12 +70,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. rendering; reasoning copy button works; frame shapes match `04-wire-protocol`. ## M3 — Channels/threads + cron + search + **Goal:** organization + cron delegation + search. + - [X] Channel directory (SQLite): default channel ensured; `channel.create/ rename/set_default/delete` + `channel.*` frames. - [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`. - [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron - `deliver=android:[:]` works. + `deliver=iris:[:]` works. - [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results. - [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new channel" + "set as cron target", SearchScreen with scope toggle + jump. @@ -79,7 +87,7 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. isolate context; search scoping correct; channel list reconciles on events. - **Status (complete):** channel directory + threads + search + outbox sync verified end-to-end via `ws_probe.py` (create/rename/set_default/delete, - thread lanes, FTS5 search, sync); cron `deliver=android:[:]` + thread lanes, FTS5 search, sync); cron `deliver=iris:[:]` target resolution verified via `resolve_send_target`. App on-device: channel drawer, thread toggle + topic switcher, new channel, search overlay with jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via @@ -89,7 +97,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. not e2e-tested (it shares the verified resolution path). ## M4 — Media + **Goal:** attach + receive + play media. + - [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`; size limit + sha256 + MIME re-sniff. - [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path @@ -117,12 +127,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. `04-wire-protocol.md` + `frames.schema.json`. ## M5 — Push + offline (FCM + ntfy) + **Goal:** reach the phone when backgrounded; catch up on reconnect. + - [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune (row cap 5000 + prune banner, throttled 1/h). - [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by - `ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`. + `IRIS_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`. - [x] Fire push on no-live-subscriber; data payload for silent sync. High-priority kinds (approval/clarify/cron) push even when live. - [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a @@ -143,7 +155,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. loss/dup (verified); banners show for foreground events (implemented). ## M6 — Desktop app + **Goal:** the same app on a big screen. + - [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView); `MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt. - [x] Two-pane default layout; keyboard shortcuts; optional inspector pane. @@ -178,7 +192,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. macOS/Windows packaging are deferred to M7. ## M7 — Polish + E2E + docs + **Goal:** ship-quality. + - [x] Telegram-style layout pass (per reference image): header, bubbles, date separators, ✓✓, model/token footer, banner, bottom bar. - [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/ @@ -222,7 +238,38 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. --- +## M8 — QR pairing + +**Goal:** QR-based pairing — a scannable QR at `hermes gateway setup` plus an +in-app scanner and `iris://pair` deep link (closes gap #12, `docs/20`). + +- [x] Pure-stdlib QR encoder + terminal renderer (`gateway-plugin/qr.py`): + byte mode, EC M with L fallback, versions 1–10, ISO penalty masking, + **zero new Python deps**. +- [x] `interactive_setup` renders the QR after the pairing URL (text lines + stay as the primary path). +- [x] `PairLink` parser (`iris/util/PairLink.kt`) + jvmTest (valid/missing + token/bad port/wrong scheme/wrong host/percent-encoded/secure/default + port). +- [x] CameraX + ML Kit scanner (`QrScanActivity`, on-device, no Play + services) + Connect-screen **Scan QR** button (Android only; hidden on + desktop) + `CAMERA` permission. +- [x] `iris://pair` deep link (system-scanner / other-phone fallback) reusing + the same parser. +- **Accept:** `docs/20` §20.6; gap #12 in `09-pairing-security.md` closed. +- **Status (2026-08-22):** Encoder cross-checked byte-for-byte against an + independent reference and decoded by an independent decoder (zbarimg); fixed + v1-M and v7-M matrix vectors lock the algorithm. `hermes gateway setup` + prints a scannable QR (v7-M, 45 modules) for the 64-hex-token payload. App: + Connect screen shows **Scan QR** (Android), which opens `QrScanActivity` + (CameraX camera2 + ML Kit barcode), requests `CAMERA`, and pre-fills URL + + token via `PairLink.parse` without auto-connecting; `iris://pair` deep link + pre-fills the same way. Docs updated per `docs/20` Part C. + +--- + ## Sequencing notes + - **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early — build it in M1. - **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3, diff --git a/docs/15-hermes-reference.md b/docs/15-hermes-reference.md index 554f5f6..a50dd18 100644 --- a/docs/15-hermes-reference.md +++ b/docs/15-hermes-reference.md @@ -5,7 +5,7 @@ integration point. Paths are relative to `hermes-agent/` (the read-only reference). This lets a coder jump straight to the right code instead of re-deriving the architecture. -> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/android`; we +> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/iris`; we > never edit these files. ## Plugin / platform registration diff --git a/docs/16-open-questions.md b/docs/16-open-questions.md index 2ec93e4..70bff70 100644 --- a/docs/16-open-questions.md +++ b/docs/16-open-questions.md @@ -3,24 +3,24 @@ ## Locked decisions (from planning, 2026-08-19) | # | Decision | Choice | Rationale | -|---|---|---|---| +| --- | --- | --- | --- | | 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android 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. `ANDROID_PUSH_BACKEND`. | +| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `IRIS_PUSH_BACKEND`. | | 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. | ## Additional decisions made during planning | Decision | Choice | Note | -|---|---|---| -| Connection point | **Messaging gateway** (platform plugin `android`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. | +| --- | --- | --- | +| Connection point | **Messaging gateway** (platform plugin `iris`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. | | Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. | | Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. | | Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. | | Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. | | Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. | | Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. | -| Default chat id | `android:default` | Home channel + cron default. | +| Default chat id | `default` | Home channel + cron default. | | WS port | `8790` (default) | Configurable. | | minSdk | 26 (test device API 29) | Broad coverage. | | Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. | @@ -29,6 +29,8 @@ | Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. | | Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. | | Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. | +| QR encoder | **Pure-stdlib** (`gateway-plugin/qr.py`) | No `qrcode`/`segno`/Pillow; byte mode, EC M→L, v1–10, ISO penalty masking. Keeps the plugin's zero-new-dep rule. | +| QR scanner | **ML Kit barcode** (not zxing-android-embedded) | On-device, no Google Play services; better accuracy/latency, Google-maintained. Android-only (desktop has no camera). | ## Open questions (resolve during implementation) @@ -45,7 +47,7 @@ will proceed with unless you say otherwise. `reasoning_style: code` for android and split on that; fallback = no 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 - `ANDROID_TOKEN` + optional `ANDROID_ALLOWED_USERS` device allowlist. + `IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist. Per-device revocable tokens are a stretch. 4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose surface, WebView fallback. Confirm `mpv` availability on target OSes during @@ -54,7 +56,7 @@ will proceed with unless you say otherwise. recommended remote path; WSS + reverse proxy as alternatives. No public bind by default. 6. **Streaming cadence (M2).** If live updates look chunky, tune - `display.platforms.android.streaming` / consumer edit interval. *Default:* + `display.platforms.iris.streaming` / consumer edit interval. *Default:* follow global streaming config. 7. **App package name / branding.** *Default:* applicationId `dev.iris.app`, app name "Iris". Confirm final product name + package + icon. @@ -72,4 +74,4 @@ will proceed with unless you say otherwise. - End-to-end encryption (transport WSS only). - Standalone-cron delivery while the gateway process is fully down (best-effort push only). -- iOS. \ No newline at end of file +- iOS. diff --git a/docs/17-future-control-surface.md b/docs/17-future-control-surface.md index 6794399..9311e08 100644 --- a/docs/17-future-control-surface.md +++ b/docs/17-future-control-surface.md @@ -153,7 +153,7 @@ Config + setup per provider — `web_server.py` `/api/memory/providers/*`. `app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`). 2. **Security is the real gate.** The single-user model in `09-pairing-security.md` still holds, but control frames widen the blast - radius of a leaked `ANDROID_TOKEN`. Sensitive operations (env/secrets, + radius of a leaked `IRIS_TOKEN`. Sensitive operations (env/secrets, config writes, gateway restart, profile deletion) should get either an in-app confirmation step or a capability flag negotiated at pairing. 3. **Read-heavy first.** Most of the value is in list/view frames (cheap, diff --git a/docs/19-http-fallback-transport.md b/docs/19-http-fallback-transport.md index d4fce07..1fc8485 100644 --- a/docs/19-http-fallback-transport.md +++ b/docs/19-http-fallback-transport.md @@ -71,7 +71,7 @@ acceptable alternative if preferred). ``` ┌──────────────────────── hermes gateway process ───────────────────────┐ - │ AndroidAdapter │ + │ IrisAdapter │ │ │ frames (same protocol.Frame objects) │ │ ▼ │ │ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │ @@ -105,7 +105,7 @@ over HTTP whenever the WS is down. ## 19.4 Gateway: `gateway-plugin/http_server.py` -New module, started/stopped by `AndroidAdapter.connect()`/`disconnect()` next +New module, started/stopped by `IrisAdapter.connect()`/`disconnect()` next to the WS server. - **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`, @@ -114,8 +114,8 @@ to the WS server. into the gateway's asyncio loop with `asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at start, same loop the WS server runs on). -- **Config:** `ANDROID_HTTP_PORT` (default **8791**), same bind host as the WS - (`ANDROID_WS_HOST`). Optional TLS via `ANDROID_HTTP_CERT`/`ANDROID_HTTP_KEY` +- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS + (`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY` (`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a trusted LAN by default, TLS for remote/Tailscale setups. - **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the @@ -151,7 +151,7 @@ Wire format (standard SSE, three fields): ``` id: 1043 event: frame -data: {"v":1,"type":"message","chat_id":"android:default",...} +data: {"v":1,"type":"message","chat_id":"default",...} : hb ← comment heartbeat every 15 s (keeps proxies alive) ``` diff --git a/docs/20-qr-pairing.md b/docs/20-qr-pairing.md new file mode 100644 index 0000000..28d83cb --- /dev/null +++ b/docs/20-qr-pairing.md @@ -0,0 +1,322 @@ +# 20 — QR Pairing (terminal QR + in-app scanner) + +**Status: implemented (M8, 2026-08-22).** + +Closes gap #12 in `09-pairing-security.md` ("in-app QR scanner") and implements +the QR branch of the §9.2 pairing flow, which the docs already promise but the +code never delivered: today `interactive_setup` prints the pairing URL as +plain text only, and the app has no `iris://pair` parser at all. + +Two halves, independent and shippable separately: + +- **A — Gateway:** `hermes gateway setup` renders a scannable QR in the + terminal encoding `iris://pair?host=…&port=…&token=…`. **Zero new Python + dependencies** (pure stdlib encoder). +- **B — App:** a "Scan QR" button on the Android Connect screen (CameraX + + ML Kit, on-device, no Play services) that pre-fills URL + token. Not added + to desktop. Plus an `iris://pair` deep link so *any* scanner (system camera + app, other phones) can route the QR into the app. + +--- + +## 20.1 Current state (what exists today) + +| Piece | State | Location | +| ------- | ------- | ---------- | +| QR payload format | ✅ implemented | `gateway-plugin/pairing.py` → `qr_payload(host, port, token, secure)` → `iris://pair?host=&port=8791&secure=0&token=<64-hex>` | +| Terminal QR rendering | ❌ missing | `gateway-plugin/adapter.py` → `interactive_setup()` prints the URL text only | +| App `iris://pair` parser | ❌ missing | app has manual URL + token entry only (`ConnectScreen`) | +| In-app camera scan | ❌ missing | no camera deps anywhere in `app/` | +| `iris://` deep link | ⚠️ partial | manifest handles `iris://chat/` only (`androidApp/.../AndroidManifest.xml`, `MainActivity.handleDeepLink`) | +| QR libs in hermes venv | ❌ absent | `qrcode`/`segno` not installed; `Pillow` is a hermes core dep but only renders images — the QR *matrix* algorithm is still needed either way | + +Payload size: `iris://pair?host=192.168.x.x&port=8791&secure=0&token=<64 hex>` +≈ **118 bytes** → QR version **7 at EC level M** (capacity 122 bytes) or v6 at +L (134). The encoder must therefore support at least versions 1–8; we target +1–10. + +--- + +## 20.2 Part A — terminal QR in `interactive_setup` + +### A1. Pure-stdlib QR encoder — `gateway-plugin/qr.py` (new file) + +A self-contained ISO/IEC 18004 encoder, **stdlib only** (no `qrcode`, no +`segno`, no Pillow). Scope is deliberately minimal — we only ever encode +ASCII pairing URLs: + +- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths). +- **Error correction:** level **M** (15 %); auto-fallback to **L** if the + payload doesn't fit at M within the version cap. +- **Versions:** 1–10, auto-selected (smallest version whose capacity fits). + Payloads that don't fit v10-L raise `QrTooLongError` (caller falls back to + text-only output — see A3). +- **Components** (all well-known, spec-stable algorithms): + 1. Data encoding: mode indicator `0100`, 8-bit char count (8 bits for + v1–9, 16 bits for v10), payload bytes, terminator, padding + (`0xEC`/`0x11` alternation). + 2. Reed–Solomon error correction over GF(256), generator polynomial + `0x11D`, per (version, EC level) block structure from the spec tables. + 3. Matrix placement: finder patterns + separators, timing patterns, + alignment patterns (v2+), dark module, format info (BCH(15,5)), + version info (v7+, BCH(18,6)), zig-zag data placement. + 4. Masking: all 8 masks, ISO penalty scoring (N1–N4), pick lowest. +- **Public API:** + + ```python + def qr_matrix(data: str) -> list[list[bool]]: + """Encode *data* (ASCII) into a module matrix (True = dark). + Includes the 4-module quiet zone. Raises QrTooLongError.""" + ``` + + ~250–350 lines including the spec tables. No I/O, no globals, fully + unit-testable. + +### A2. Terminal renderer — `qr.py` + +```python +def render_qr(data: str) -> str: + """Render *data* as a terminal QR using Unicode half-blocks (▀). + Returns '' (not an exception) when the payload is too long.""" +``` + +- Pair consecutive module rows into one character row: both dark → `█`, + top dark → `▀`, bottom dark → `▄`, both light → space. (Matrix height + including quiet zone is always even: `2·(17+4v)+8`.) +- Output is a single string of `\n`-joined lines; the caller prints it. +- No ANSI colors, no cursor tricks — must survive `less`, log files, and + copy-paste. + +### A3. Integration — `adapter.py:interactive_setup()` + +After the existing "Pairing URL / Server URL" lines: + +```python +qr = render_qr(qr_payload(host, port, token)) +if qr: + print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:") + print(qr) +else: + print_warning("QR too large to render; use the pairing URL above.") +``` + +- The **URL text lines stay** — the QR is a convenience, not a replacement + (terminals without UTF-8 still work, and the text is copy-pasteable). +- Printed on every setup run (new *and* existing token), consistent with the + URL lines which already print the token in cleartext. +- **Security note:** no new exposure — the token is already printed in the + pairing URL line today; the QR is the same bytes in a different encoding, + on the same operator-only stdout. (Gap #5 in the §9.7 table already + documents the stdout token print.) + +### A4. Tests — `hermes-agent/tests/gateway/test_android.py` + +The test file is a thin mirror importing the **live `gateway-plugin/` +package**, so new tests land there: + +1. **Fixed test vectors** (guard against silent algorithm drift): at least + two known-good (data → matrix) pairs from public QR test vectors + (e.g. the ISO 18004 annex examples / the classic `KARAT` v2-L vector). + Assert the full matrix, not just dimensions. +2. **Round-trip via payload:** `qr_matrix(qr_payload(h, p, t))` has the + expected version/size for a 64-hex token (`17 + 4·7 = 45` modules at + v7-M, +8 quiet zone). +3. **Renderer shape:** every line equal length, height = half of matrix + height, quiet zone renders as blank border, only the 4 block chars + + space appear. +4. **`QrTooLongError` / `render_qr` → `""`** for a payload beyond v10-L. +5. **`interactive_setup` smoke:** with sandboxed HERMES_HOME (conftest + already does this), capture stdout and assert the QR block appears after + the pairing URL line. + +Run: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`. + +--- + +## 20.3 Part B — in-app scanner (Android only) + +### B1. Dependencies — `app/shared/build.gradle.kts`, `androidMain.dependencies` only + +| Dependency | Why | +| ------------ | ----- | +| `androidx.camera:camera-camera2` | Camera access (CameraX) | +| `androidx.camera:camera-lifecycle` | Lifecycle-aware binding | +| `androidx.camera:camera-view` | `PreviewView` for the scan surface | +| `com.google.mlkit:barcode-scanning` | On-device QR decode; **no Google Play services required** (self-contained model) | + +- Chosen over `zxing-android-embedded` (decided 2026-07-20): ML Kit has + better accuracy/latency, is maintained by Google, and works fully + on-device without GMS. +- These go in **`androidMain`** only — the no-new-dep rule covers + `gateway-plugin/`, and the app already carries OkHttp/SQLDelight/KCEF/etc. + Desktop is untouched. +- `minSdk 29` is fine for all four (ML Kit barcode needs 21+). +- Versions go in the existing version catalog / `composeVersion`-style + constants at the top of the build file (follow the current pattern). + +### B2. Scanner activity — `shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt` (new) + +A minimal `ComponentActivity` (not a Fragment, no nav graph): + +- Layout: full-screen `PreviewView` + overlay hint text ("Point at the QR + code") + close button. +- `ImageAnalysis` (STRATEGY_LATEST, YUV_420_888) → + `BarcodeScannerOptions(FORMAT_QR_CODE)` → first result → + `setResult(RESULT_OK, Intent().putExtra("iris.qr.text", raw))` → finish. +- **Runtime permission:** request `CAMERA` on launch; on denial show a + message + close (the Connect screen still has manual entry). +- Registered in `shared/src/androidMain/AndroidManifest.xml` (or the + androidApp manifest — follow where `NtfyListenerService` is declared) + with `android:exported="false"`, `android:theme` reusing the app theme. +- Manifest additions (androidApp manifest): + + ```xml + + + ``` + + `required="false"` so the app stays installable on camera-less devices + (the button then just reports "no camera"). + +### B3. Platform hook — `expect`/`actual` + +`shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt` (new): + +```kotlin +/** Launch the QR scanner. [onResult] gets the decoded text, or null when + * the user cancelled / no camera / permission denied. Desktop: no-op. */ +expect fun scanQrCode(onResult: (String?) -> Unit) +``` + +- **androidMain actual:** `ActivityResultLauncher` (from the Compose + `LocalContext`) starting `QrScanActivity`; maps `RESULT_OK` → text, + everything else → `null`. +- **desktopMain actual:** `onResult(null)` immediately (the button is + hidden on desktop anyway — see B4; the no-op keeps the `expect` total). + +### B4. Connect screen button — `ConnectScreen.kt` + +- New **"Scan QR"** `Button` below the token field, rendered only when + `!isDesktop` (`iris.platform.isDesktop` already exists). +- On tap: `scanQrCode { raw -> … }`; on non-null `raw`: + - `PairLink.parse(raw)` (B5) → pre-fill `url` and `token` state, clear + error, and **do not auto-connect** — the user still taps + "Test & Connect" (pairing stays an explicit act, per §10.8). + - Parse failure → set `error` to "Not a pairing QR code" (don't echo the + raw payload — it may contain someone else's token). +- On `null` (cancel/denied): no-op, no error. + +### B5. Pair-link parser — `shared/src/commonMain/kotlin/iris/util/PairLink.kt` (new) + +```kotlin +data class PairLink(val url: String, val token: String) + +object PairLink { + /** Parse `iris://pair?host=…&port=…&secure=…&token=…` → PairLink. + * Returns null on any malformation. */ + fun parse(raw: String): PairLink? +} +``` + +- Accepts exactly scheme `iris`, host `pair` (case-insensitive scheme). +- Required: `host` (non-empty), `token` (non-empty). `port` defaults to + `8791` (the HTTP default, `docs/19`); `secure` defaults to `0`. +- Builds `url` as `http(s)://:`; validates port 1–65535. +- URL-decodes `host`/`token` (the Python side `quote()`s them). +- Pure function, no platform imports → **unit-tested in `jvmTest`** + (`:shared:testAndroidHostTest` / `:shared:desktopTest` both run it): + valid link, missing token, bad port, wrong scheme, wrong host, + percent-encoded host, secure=1 → https, default port. + +### B6. `iris://pair` deep link (system-scanner fallback) + +So a QR scanned by *any* app (phone's built-in scanner, a friend's phone) +lands in Iris: + +- Manifest: extend the existing `VIEW` intent-filter block (or add a + sibling) with ``. +- `MainActivity.handleDeepLink`: on `iris://pair` → `PairLink.parse(uri)` → + stash into a `mutableStateOf` passed into `IrisApp` → + `ConnectScreen` receives it as `prefillUrl`/`prefillToken` (the params + already exist). If the app is already connected, ignore (or surface in + Settings later — out of scope). +- This reuses B5's parser; add one test for the URI shape Android delivers. + +--- + +## 20.4 Part C — doc updates (with the implementation) + +| Doc | Change | +| ----- | -------- | +| `09-pairing-security.md` | Gap #12 → **implemented** (fix the stale `adapter.py:648-654` reference while at it); §9.2 QR branch no longer aspirational | +| `10-android-app.md` §10.8 | "or scan QR" becomes real: scanner button + deep link, camera permission | +| `14-milestones.md` | New **M8 — QR pairing** section (acceptance criteria below) | +| `16-open-questions.md` | Record decision: ML Kit over zxing; pure-stdlib encoder over vendoring `segno` | +| `README.md` | Reading-order table: add row 20 | + +--- + +## 20.5 Work breakdown & sequencing + +Ordered so each step is independently verifiable; A and B can be +interleaved (different languages, no shared surface). + +| # | Task | Verify | +| --- | ------ | -------- | +| 1 | `qr.py` encoder + renderer (A1/A2) | new unit tests green (A4.1–4.4) | +| 2 | `interactive_setup` integration (A3) | A4.5 + manual: `hermes gateway setup` in a real terminal shows a scannable QR (scan with the phone's *system* camera app as the decoder oracle) | +| 3 | `PairLink` parser + jvmTest (B5) | `./gradlew :shared:testAndroidHostTest` | +| 4 | Deps + manifest + `QrScanActivity` (B1/B2) | `:androidApp:assembleDebug` | +| 5 | `PlatformQr` expect/actual + Connect button (B3/B4) | `:androidApp:assembleDebug` + `:desktopApp:run` (button absent, no crash) | +| 6 | `iris://pair` deep link (B6) | ADB: `adb shell am start -a android.intent.action.VIEW -d "iris://pair?host=…&port=…&token=…"` → Connect screen pre-filled | +| 7 | Doc updates (Part C) | — | + +**On-device E2E (final gate, per `13-testing.md` ADB workflow):** + +1. `hermes gateway setup` on the gateway host → QR in terminal. +2. Phone: `adb shell am start -n dev.iris.app/.MainActivity` → Connect → + **Scan QR** → grant camera → point at the terminal (screenshot the QR + onto a second screen if needed; the reference device is API 29 — + verify CameraX works on the MIX 2S in step 4 before building the rest). +3. Fields pre-filled → **Test & Connect** → chat screen. +4. Repeat via deep link (step 6 command) with a *different* token. +5. Negative: scan a non-pairing QR (e.g. a website) → "Not a pairing QR + code", fields untouched. + +--- + +## 20.6 Acceptance criteria (M8) + +- [ ] `hermes gateway setup` prints a QR that a stock Android camera app + decodes to exactly `qr_payload(host, port, token)`. +- [ ] QR encoder: fixed test vectors + size/round-trip tests green; + **zero** new entries in the plugin's import surface (stdlib only — + verifiable by `ruff`/import scan). +- [ ] Android: Connect screen shows **Scan QR** (hidden on desktop); + scanning the setup QR pre-fills URL + token; "Test & Connect" pairs. +- [ ] Camera permission denied → graceful message, manual entry still works. +- [ ] `iris://pair` deep link pre-fills the Connect screen (ADB-verified). +- [ ] `PairLink.parse` unit tests cover the matrix in B5. +- [ ] Full Python suite green: `scripts/run_tests.sh` (no args). +- [ ] Docs updated per Part C; gap #12 closed. + +## 20.7 Risks & mitigations + +| Risk | Mitigation | +| ------ | ------------ | +| Hand-rolled QR encoder has a subtle bug | Fixed spec test vectors (A4.1) + the system-camera-app oracle in the E2E gate; scope locked to byte mode / v1–10 so the surface stays small | +| Terminal without UTF-8 mangles the QR | URL text lines remain the primary path; QR is additive | +| CameraX quirks on API 29 (MIX 2S) | Build the scanner activity first (task 4) and verify on-device before wiring the UI | +| ML Kit model size (~4 MB) | Bundled in the APK, on-device, no runtime download — acceptable for this app's footprint | +| Token in QR scanned by a bystander's phone | Same trust domain as the token already printed in the terminal; LAN pairing is operator-supervised by design (§9.2). Deep link only pre-fills — it never auto-connects | +| `secure=1` (WSS) URLs | Parser already handles `secure` → `https://`; QR payload unchanged | + +## 20.8 Explicit non-goals + +- **QR display in the app** (showing a QR for other devices to scan) — + single-device pairing today; revisit if multi-device lands. +- **`hermes android pair` stretch CLI** (re-issue token + new QR, + `09-pairing-security.md` §9.2 line 48) — separate backlog item. +- **WSS cert pinning** (gap #6) — orthogonal; QR carries `secure=1` + already, pinning is app-side. +- **iOS scanner** — no iOS target (per `00-overview.md`). diff --git a/docs/README.md b/docs/README.md index 5fcd02b..3799365 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing. | 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. | | 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). | | 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. | +| 20 | [`20-qr-pairing.md`](20-qr-pairing.md) | Terminal QR at `gateway setup` + in-app QR scanner (Android) + `iris://pair` deep link. | Machine-readable / diagrams: @@ -51,7 +52,7 @@ Machine-readable / diagrams: ## The three deliverables (one monorepo) -1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `android`. +1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `iris`. Runs inside the `hermes gateway` process. Opens a WebSocket server the apps connect to. Implements the full `BasePlatformAdapter` contract. **Zero new Python dependencies, zero hermes-core changes.** diff --git a/docs/diagrams/architecture.mmd b/docs/diagrams/architecture.mmd index 92ca9c1..1559f2d 100644 --- a/docs/diagrams/architecture.mmd +++ b/docs/diagrams/architecture.mmd @@ -17,7 +17,7 @@ flowchart TB WSS["WebSocket SERVER
(websockets) ws://host:8790/ws"] end AGENT -->|legacy stream callbacks| ADAPTER - CRON -->|deliver=android:chat:thread| ADAPTER + CRON -->|deliver=iris:chat:thread| ADAPTER ADAPTER <--> WSS ADAPTER <--> OUTBOX ADAPTER <--> PUSH diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 0462d0c..41e156f 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -10,7 +10,7 @@ "v": { "type": "integer", "const": 1, "description": "Protocol version." }, "id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." }, "type": { "type": "string", "description": "Frame type (see frame_types)." }, - "chat_id": { "type": "string", "description": "Optional chat scope (e.g. android:default, android:chan_7)." }, + "chat_id": { "type": "string", "description": "Optional chat scope (e.g. default, chan_7)." }, "thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." }, "cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." }, "payload": { "type": "object", "description": "Type-specific payload." } diff --git a/docs/setup.md b/docs/setup.md index 204df37..0c54779 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -9,7 +9,7 @@ just the steps. ## Prerequisites | 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 | @@ -24,8 +24,8 @@ root): ```bash mkdir -p ~/.hermes/plugins -ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android -hermes gateway status # should list "android" +ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris +hermes gateway status # should list "iris" ``` Run the interactive setup: @@ -36,12 +36,13 @@ hermes gateway setup What it does: -- Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in +- 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) and the server URL (`ws://:8790/ws`). + string), a scannable QR of that payload, and the server URL + (`ws://:8790/ws`). Then start the gateway: @@ -52,7 +53,7 @@ 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 -> `ANDROID_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`). +> `IRIS_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`). ## 2. Android app @@ -68,12 +69,14 @@ First run opens the **Connect** screen: 1. **Server URL** — `ws://: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 ANDROID_TOKEN ~/.hermes/.env` on the gateway host. + `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. -> **Honest limitation:** QR scanning is **not** supported in the app yet. The -> server prints a QR payload, but pairing is manual URL + token entry only. +> **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 @@ -104,8 +107,8 @@ high-priority events (approvals, clarifies, cron) even when a device is live. `app/androidApp/`. 2. Create a service account (Project settings → Service accounts → Generate new private key) and store the JSON path in `~/.hermes/.env`: - `ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`. -3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default). + `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. @@ -116,7 +119,7 @@ backgrounded; tapping one deep-links to the chat. ### ntfy (zero-config fallback) ``` -ANDROID_PUSH_BACKEND=ntfy +IRIS_PUSH_BACKEND=ntfy ``` - The device **generates its own topic** automatically (no `NTFY_TOPIC` needed); @@ -135,7 +138,7 @@ the app is off; incoming pushes trigger a silent sync. the app connects to `ws://: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 `ANDROID_WS_CERT` / `ANDROID_WS_KEY` (paths, in +- **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 @@ -145,8 +148,8 @@ the app is off; incoming pushes trigger a silent sync. ## 6. Troubleshooting | Symptom | Likely cause / fix | -|---|---| -| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `ANDROID_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). | +| --- | --- | +| `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. | @@ -159,7 +162,7 @@ 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":"","device_id":"test","device_name":"probe", + "token":"","device_id":"test","device_name":"probe", "caps":{"min_protocol":1}}})) print("recv:", await ws.recv()) asyncio.run(main()) @@ -167,4 +170,4 @@ PY ``` Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is -wrong. \ No newline at end of file +wrong. diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index ac97590..28fcc6f 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -1,5 +1,5 @@ """ -Android Platform Adapter for Hermes Agent (Iris x Hermes). +Iris Platform Adapter for Hermes Agent (Iris x Hermes). A plugin-based gateway adapter that runs an HTTP server *inside* the ``hermes gateway`` process. The native Android / Desktop app connects to it @@ -34,7 +34,7 @@ register the (delivery-validated) file in the media registry and emit Milestone M5: push + offline. Frames with no live subscriber are parked in the outbox (M3) AND wake the device via the push backend (``push.py``: FCM -HTTP v1 primary, ntfy fallback, selected by ``ANDROID_PUSH_BACKEND``). +HTTP v1 primary, ntfy fallback, selected by ``IRIS_PUSH_BACKEND``). ``notification`` frames render in-app banners and mirror to push (channel events, cron deliveries, approvals, clarifies); high-priority kinds push even when a device is live. ``fcm.register`` rotates push tokens (registry + live @@ -44,19 +44,19 @@ Configuration in config.yaml:: gateway: platforms: - android: + iris: enabled: true extra: host: 127.0.0.1 port: 8790 - home_channel: android:default + home_channel: default push_backend: fcm outbox_retention_hours: 72 max_upload_bytes: 104857600 Or via environment variables (overrides config.yaml; secrets live in .env): - ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL, - ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ... + IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL, + IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ... """ import asyncio @@ -116,7 +116,10 @@ from gateway.platforms.base import ( # noqa: E402 from hermes_constants import get_hermes_home # noqa: E402 from . import media as media_bridge # noqa: E402 -from . import protocol # noqa: E402 +from . import ( # noqa: E402 + protocol, + qr, +) from . import purge as purge_bridge # noqa: E402 from . import search as search_bridge # noqa: E402 from .channels import get_directory # noqa: E402 @@ -124,6 +127,7 @@ from .http_server import HttpServer # noqa: E402 from .outbox import Outbox # noqa: E402 from .pairing import ( # noqa: E402 DeviceRegistry, + advertise_host, generate_token, pairing_url, qr_payload, @@ -147,7 +151,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]: from hermes_cli import commands as hermes_commands except Exception: logger.warning( - "android: slash catalog unavailable (hermes_cli.commands import failed)", + "iris: slash catalog unavailable (hermes_cli.commands import failed)", exc_info=True, ) return [] @@ -175,7 +179,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]: except Exception: # Code skew: the private helpers moved. Fall back to the plain # cli_only filter (config-gated commands are dropped, acceptable). - logger.warning("android: slash catalog fell back to cli_only filter", exc_info=True) + 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 @@ -187,7 +191,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]: except Exception: # Best-effort: a broken plugin-command registry should not break the # built-in catalog, so the failure is intentionally swallowed. - logger.debug("android: plugin command enumeration failed", exc_info=True) + logger.debug("iris: plugin command enumeration failed", exc_info=True) return entries @@ -200,7 +204,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]: # 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 (android:default), so a +# ``message.stop`` frame. Single-chat for now (the default home channel), so a # module-level buffer suffices; it is reset at each turn start. # --------------------------------------------------------------------------- @@ -261,7 +265,7 @@ def _reset_reasoning() -> None: # (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 android gateway serves +# 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. # --------------------------------------------------------------------------- @@ -356,8 +360,8 @@ def _tool_emoji(tool_name: str) -> str | None: # * turn start — the first API call of the turn (latency baseline) # # Global buffer (same pattern as the reasoning/tool buffers): a personal -# android gateway serves one active turn at a time. The hook fires for every -# platform, so we only record when the turn's platform is android. +# 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] = {} @@ -374,7 +378,7 @@ _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 != "android": + if platform and platform != "iris": return model = kwargs.get("model") or "" usage = kwargs.get("usage") or {} @@ -416,7 +420,7 @@ def _resolve_context_length(model: str) -> int | None: _context_length_cache[model] = int(ctx) return int(ctx) except Exception: - logger.debug("android: context-length resolution failed for %s", model, exc_info=True) + logger.debug("iris: context-length resolution failed for %s", model, exc_info=True) return None @@ -472,7 +476,7 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]: DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8790 DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg -DEFAULT_HOME_CHANNEL = "android:default" +DEFAULT_HOME_CHANNEL = "default" DEFAULT_HOME_CHANNEL_NAME = "Default" DEFAULT_PUSH_BACKEND = "fcm" DEFAULT_OUTBOX_RETENTION_HOURS = 72 @@ -825,13 +829,13 @@ def check_requirements() -> bool: dashboard readiness). Never installs. The HTTP transport is stdlib-only, so there is no extra dependency to probe. """ - return bool(_get_scoped_secret("ANDROID_TOKEN")) + 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("ANDROID_TOKEN") or extra.get("token", "") + token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "") return bool(token) @@ -858,7 +862,7 @@ def _env_enablement() -> dict | None: core hook -- it becomes a proper ``HomeChannel`` dataclass on the ``PlatformConfig`` rather than being merged into ``extra``. """ - token = _get_scoped_secret("ANDROID_TOKEN", "") + token = _get_scoped_secret("IRIS_TOKEN", "") if not token: return None @@ -867,20 +871,20 @@ def _env_enablement() -> dict | None: # clobber user YAML. Unset keys fall through to config.yaml / adapter # defaults. seed: dict[str, Any] = {} - host = os.getenv("ANDROID_WS_HOST", "").strip() + host = os.getenv("IRIS_WS_HOST", "").strip() if host: seed["host"] = host - http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip() + http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip() if http_port_raw: seed["http_port"] = _parse_port(http_port_raw) - push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() + push = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower() if push: seed["push_backend"] = push - home = os.getenv("ANDROID_HOME_CHANNEL", "").strip() + home = os.getenv("IRIS_HOME_CHANNEL", "").strip() if home: seed["home_channel"] = { "chat_id": home, - "name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME, + "name": os.getenv("IRIS_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME, } return seed @@ -893,21 +897,22 @@ def _parse_port(raw: str) -> int: # --------------------------------------------------------------------------- -# Target parsing: "android:[:]" +# Target parsing: "[:]" (platform prefix stripped by core) # --------------------------------------------------------------------------- def _parse_target_ref(target_ref: str) -> tuple | None: """Parse a raw target string into ``(chat_id, thread_id)`` or ``None``. - Recognises the native syntax ``android:[:]`` where the - chat_id itself carries the ``android:`` prefix (e.g. ``android:chan_7``) - and an optional thread is a trailing ``:t_``. 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. + The core strips the platform prefix before calling us, so the native + syntax is simply ``[:]`` (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 @@ -915,17 +920,26 @@ def _parse_target_ref(target_ref: str) -> tuple | None: if not t: return None - if t.startswith("android:"): - body = t[len("android:") :].strip() - if not body: - return None - thread_id: str | None = None - if ":" in body: - head, tail = body.rsplit(":", 1) - if tail and tail.startswith("t_"): - thread_id = tail - body = head - return (f"android:{body}", thread_id) + 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 : pair -- treat the whole string as a name. + t = target_ref.strip() + if not t: + return None + + # Native chat id (default / chan_) 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 @@ -966,7 +980,7 @@ async def _standalone_send( """ return { "error": ( - "android standalone send: the running gateway is required to serve " + "iris standalone send: the running gateway is required to serve " "the outbox (standalone delivery is best-effort only)" ) } @@ -978,7 +992,7 @@ async def _standalone_send( def _ensure_verbose_tool_progress() -> None: - """Ensure the android platform renders tool progress in ``verbose`` mode. + """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 @@ -988,7 +1002,7 @@ def _ensure_verbose_tool_progress() -> None: it). Best-effort and idempotent: writes - ``display.platforms.android.tool_progress: verbose`` to config.yaml only + ``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. """ @@ -998,19 +1012,19 @@ def _ensure_verbose_tool_progress() -> None: cfg = load_config_readonly() or {} display = cfg.get("display") or {} platforms = display.get("platforms") or {} - android = platforms.get("android") or {} - if android.get("tool_progress") == "verbose": + 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.android.tool_progress", + "display.platforms.iris.tool_progress", "verbose", ) - logger.info("android: set display.platforms.android.tool_progress=verbose") + logger.info("iris: set display.platforms.iris.tool_progress=verbose") except Exception: - logger.debug("android: could not ensure verbose tool_progress", exc_info=True) + logger.debug("iris: could not ensure verbose tool_progress", exc_info=True) # --------------------------------------------------------------------------- @@ -1033,57 +1047,77 @@ def interactive_setup() -> None: ) from hermes_cli.config import get_env_value, save_env_value except Exception: - print("android: setup helpers unavailable; set ANDROID_TOKEN in ~/.hermes/.env") + print("iris: setup helpers unavailable; set IRIS_TOKEN in ~/.hermes/.env") return print_info("📱 Android / Desktop (Iris x Hermes)") - token = get_env_value("ANDROID_TOKEN") or "" + token = get_env_value("IRIS_TOKEN") or "" if not token: generated = generate_token() - save_env_value("ANDROID_TOKEN", generated) + save_env_value("IRIS_TOKEN", generated) print_success(f"Generated pairing token: {generated}") print_warning("Keep this secret -- the app presents it on connect.") else: - print_info("Existing ANDROID_TOKEN found (not shown).") + print_info("Existing IRIS_TOKEN found (not shown).") - host = prompt("Bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST) - save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST) + host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST) + save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST) # _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the # HTTP default must be applied explicitly (docs/19: 8791). - http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip() + 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("ANDROID_HTTP_PORT", str(_parse_port(port))) + save_env_value("IRIS_HTTP_PORT", str(_parse_port(port))) backend = prompt( "Push backend (fcm/ntfy)", - default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND, + default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND, ) - save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower()) + save_env_value("IRIS_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower()) - # Pairing payload for the app's Connect screen (manual entry; the app has - # no QR scanner). - url = pairing_url(host or DEFAULT_HOST, _parse_port(port)) + # 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) + url = pairing_url(advertised, _parse_port(port)) + pairing = qr_payload(advertised, _parse_port(port), token) print_info("Pair your device (enter this on the app's Connect screen):") - print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}") + 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("Android configuration saved to ~/.hermes/.env") + print_success("Iris configuration saved to ~/.hermes/.env") print_info("Restart the gateway for changes to take effect: hermes gateway restart") # --------------------------------------------------------------------------- -# Android Adapter +# Iris Adapter # --------------------------------------------------------------------------- -class AndroidAdapter(BasePlatformAdapter): - """HTTP-backed adapter for the native Iris Android / Desktop app. +class IrisAdapter(BasePlatformAdapter): + """HTTP-backed adapter for the native Iris app (Android / Desktop). The HTTP server (``http_server.HttpServer``) authenticates devices with the pairing token, the device registry tracks live subscribers, ``send()`` @@ -1100,7 +1134,7 @@ class AndroidAdapter(BasePlatformAdapter): MAX_MESSAGE_LENGTH = 1_000_000 def __init__(self, config, **kwargs): - platform = Platform("android") + platform = Platform("iris") super().__init__(config=config, platform=platform) # Ensure verbose tool progress (full args on the progress line) so the @@ -1111,13 +1145,13 @@ class AndroidAdapter(BasePlatformAdapter): # Connection settings (env vars override config.yaml). The bind host # is shared with the (legacy) WS-era env var name for compatibility. - self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST) + self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST) # docs/19: HTTP transport (the only device-facing transport; optional TLS). self.http_port = _parse_port( - os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT)) + os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT)) ) - self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "") - self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get( + self.token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "") + self.push_backend = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower() or extra.get( "push_backend", DEFAULT_PUSH_BACKEND ) self.outbox_retention_hours = int( @@ -1146,18 +1180,18 @@ class AndroidAdapter(BasePlatformAdapter): self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME # TLS (optional) - self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "") - self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "") + self.http_cert = _get_scoped_secret("IRIS_HTTP_CERT") or extra.get("http_cert", "") + self.http_key = _get_scoped_secret("IRIS_HTTP_KEY") or extra.get("http_key", "") # Auth - allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip() + allowed = os.getenv("IRIS_ALLOWED_USERS", "").strip() self.allowed_users: list[str] = ( [u.strip() for u in allowed.split(",") if u.strip()] if allowed else [] ) - self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS")) + self.allow_all = _truthy(os.getenv("IRIS_ALLOW_ALL_USERS")) # Runtime state - self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db") + self._devices = DeviceRegistry(get_hermes_home() / "iris" / "devices.db") # docs/19: HTTP transport (the only device-facing transport). self._http_server = HttpServer(self, self._devices) # docs/19 §19.7: reply sinks for in-flight HTTP requests — while a @@ -1171,7 +1205,7 @@ class AndroidAdapter(BasePlatformAdapter): # M3: channel directory (shared singleton) + offline outbox. self._channels = get_directory() self._outbox = Outbox( - get_hermes_home() / "android" / "outbox.db", + get_hermes_home() / "iris" / "outbox.db", retention_hours=self.outbox_retention_hours, ) # M4: media registry (inbound upload refs + outbound offers) and the @@ -1182,8 +1216,8 @@ class AndroidAdapter(BasePlatformAdapter): # the outbox-prune banner. self._push: PushBackend = build_push_backend( self.push_backend, - fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"), - fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"), + fcm_service_account=_get_scoped_secret("IRIS_FCM_SERVICE_ACCOUNT"), + fcm_server_key=_get_scoped_secret("IRIS_FCM_SERVER_KEY"), ntfy_topic=_get_scoped_secret("NTFY_TOPIC"), ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None, ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"), @@ -1202,17 +1236,17 @@ class AndroidAdapter(BasePlatformAdapter): @property def name(self) -> str: - return "Android" + return "Iris" # ── Connection lifecycle ────────────────────────────────────────────── async def connect(self, *, is_reconnect: bool = False) -> bool: """Bring the platform up: bind the HTTP server on host:http_port.""" if not self.token: - logger.error("android: ANDROID_TOKEN must be set") + logger.error("iris: IRIS_TOKEN must be set") self._set_fatal_error( "config_missing", - "ANDROID_TOKEN must be set", + "IRIS_TOKEN must be set", retryable=False, ) return False @@ -1222,7 +1256,7 @@ class AndroidAdapter(BasePlatformAdapter): # start() never raises; it disables the leg and logs on failure. await self._http_server.start() if not self._http_server.enabled: - logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port) + logger.error("iris: HTTP server failed to bind %s:%s", self.host, self.http_port) self._set_fatal_error( "bind_failed", f"HTTP port {self.http_port} unavailable", @@ -1242,21 +1276,21 @@ class AndroidAdapter(BasePlatformAdapter): try: self._channels.ensure_default(self.home_channel, self.home_channel_name) except Exception: - logger.warning("android: ensure_default failed", exc_info=True) + logger.warning("iris: ensure_default failed", exc_info=True) # M5: push backend status (degrade gracefully when unconfigured). if not self._push.configured(): logger.warning( - "android: push backend %r not configured (no credentials) -- " + "iris: push backend %r not configured (no credentials) -- " "offline devices will not be woken; outbox + sync still apply", self.push_backend, ) else: - logger.info("android: push backend: %s", self._push.name) + logger.info("iris: push backend: %s", self._push.name) self._connected = True self._mark_connected() - logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port) + logger.info("iris: connected; HTTP server on %s:%s", self.host, self.http_port) return True async def disconnect(self) -> None: @@ -1271,7 +1305,7 @@ class AndroidAdapter(BasePlatformAdapter): try: await self._http_server.stop() except Exception: - logger.warning("android: HTTP server stop failed", exc_info=True) + logger.warning("iris: HTTP server stop failed", exc_info=True) # Best-effort shutdown: a close failure on an already-closed store is # not actionable at disconnect time. with contextlib.suppress(Exception): @@ -1280,7 +1314,7 @@ class AndroidAdapter(BasePlatformAdapter): self._outbox.close() self._connected = False self._mark_disconnected() - logger.info("android: disconnected") + logger.info("iris: disconnected") # ── Outbound (agent -> app) ─────────────────────────────────────────── @@ -1706,7 +1740,7 @@ class AndroidAdapter(BasePlatformAdapter): try: cursor = self._outbox.append(chat_id, frame.to_json()) except Exception: - logger.warning("android: outbox append failed", exc_info=True) + logger.warning("iris: outbox append failed", exc_info=True) return # docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber # — count it in the delivery total or every message would push AND @@ -1714,7 +1748,7 @@ class AndroidAdapter(BasePlatformAdapter): delivered = await self._http_server.fanout(frame, cursor) if delivered == 0: logger.info( - "android: no live devices for %s; %s frame parked in outbox (cursor=%s)", + "iris: no live devices for %s; %s frame parked in outbox (cursor=%s)", chat_id, frame.type, cursor, @@ -1786,7 +1820,7 @@ class AndroidAdapter(BasePlatformAdapter): now = time.time() if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S: logger.info( - "android: push coalesced for %s (%s frame within %.0fs of last push)", + "iris: push coalesced for %s (%s frame within %.0fs of last push)", chat_id, frame.type, _PUSH_COALESCE_S, @@ -1823,7 +1857,7 @@ class AndroidAdapter(BasePlatformAdapter): priority=priority, ) except Exception: - logger.warning("android: push via %s failed", backend.name, exc_info=True) + 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, @@ -1832,11 +1866,11 @@ class AndroidAdapter(BasePlatformAdapter): self._devices.update_push_cursor(device_id, cursor) except Exception: logger.warning( - "android: push cursor update failed for %s", device_id, exc_info=True + "iris: push cursor update failed for %s", device_id, exc_info=True ) self._last_push_at[chat_id] = time.time() logger.info( - "android: push via %s -> %s (%s, chat=%s)", + "iris: push via %s -> %s (%s, chat=%s)", backend.name, device_id, frame.type, @@ -1897,13 +1931,13 @@ class AndroidAdapter(BasePlatformAdapter): ) -> SendResult: safe = validate_media_delivery_path(path) if safe is None: - logger.warning("android: media path failed delivery validation: %s", path) - return SendResult(success=False, error="android: media path not deliverable") + 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("android: media file unreadable %s: %s", safe, e) - return SendResult(success=False, error="android: media file unreadable") + 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 ) @@ -2216,7 +2250,7 @@ class AndroidAdapter(BasePlatformAdapter): except Exception: logger.debug("Thread title rename broadcast failed", exc_info=True) - threading.Thread(target=_work, daemon=True, name="android-thread-title").start() + threading.Thread(target=_work, daemon=True, name="iris-thread-title").start() # ── M3: channel directory management (app -> agent) ─────────────────── # @@ -2455,7 +2489,7 @@ class AndroidAdapter(BasePlatformAdapter): get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id ) logger.info( - "android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s", + "iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s", chat_id, entry.get("kind"), removed_frames, @@ -2565,7 +2599,7 @@ class AndroidAdapter(BasePlatformAdapter): """ payload = frame.payload chat_id = frame.chat_id or payload.get("chat_id") - logger.info("android: history request from %s chat_id=%r", device_id, 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, @@ -2658,7 +2692,7 @@ class AndroidAdapter(BasePlatformAdapter): info.get("ts"), ) logger.info( - "android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s", + "iris: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s", device_id, chat_id, thread_id, @@ -2687,9 +2721,9 @@ class AndroidAdapter(BasePlatformAdapter): try: self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic) except Exception: - logger.warning("android: fcm.register update failed", exc_info=True) + logger.warning("iris: fcm.register update failed", exc_info=True) return - logger.info("android: push tokens updated for %s", device_id) + logger.info("iris: push tokens updated for %s", device_id) # ── M5: approval / clarify banners ──────────────────────────────────── @@ -2854,7 +2888,7 @@ class AndroidAdapter(BasePlatformAdapter): ``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 ``android::`` + Threads are addressed via the explicit ``iris::`` syntax (see ``_parse_target_ref``), so only channels are listed here. """ out: list[dict[str, Any]] = [] @@ -2888,7 +2922,7 @@ class AndroidAdapter(BasePlatformAdapter): name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id ) except Exception: - logger.warning("android: create_handoff_thread failed", exc_info=True) + logger.warning("iris: create_handoff_thread failed", exc_info=True) return None await self._broadcast_both(protocol.channel_created(entry)) return entry["chat_id"] @@ -2907,14 +2941,14 @@ def register(ctx): try: ctx.register_hook("on_stream_delta", _on_stream_delta) except Exception: - logger.debug("android: on_stream_delta hook registration failed", exc_info=True) + logger.debug("iris: on_stream_delta hook registration failed", exc_info=True) # M2: capture each completed tool call's result + timing so the tool.end # frame can carry the output (the gateway never streams tool output to # platforms). The app shows it on demand (Settings → Tool detail). try: ctx.register_hook("post_tool_call", _on_post_tool_call) except Exception: - logger.debug("android: post_tool_call hook registration failed", exc_info=True) + logger.debug("iris: post_tool_call hook registration failed", exc_info=True) # Runtime-metadata footer: capture the turn's model + prompt tokens (per # provider call) so the final message can carry a structured ``runtime`` # object. The app decides whether/what to show (Settings → Runtime @@ -2923,30 +2957,30 @@ def register(ctx): try: ctx.register_hook("post_api_request", _on_post_api_request) except Exception: - logger.debug("android: post_api_request hook registration failed", exc_info=True) + logger.debug("iris: post_api_request hook registration failed", exc_info=True) ctx.register_platform( - name="android", - label="Android", - adapter_factory=AndroidAdapter, + name="iris", + label="Iris", + adapter_factory=IrisAdapter, check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected, - required_env=["ANDROID_TOKEN"], + required_env=["IRIS_TOKEN"], install_hint="No extra packages needed (httpx is a core dep)", setup_fn=interactive_setup, # Env-driven auto-configuration: seeds PlatformConfig.extra with # host/port/push_backend + home_channel so env-only setups show up in # gateway status without instantiating the adapter. env_enablement_fn=_env_enablement, - # Cron home-channel delivery support (deliver=android:[:]). - cron_deliver_env_var="ANDROID_HOME_CHANNEL", + # Cron home-channel delivery support (deliver=iris:[:]). + cron_deliver_env_var="IRIS_HOME_CHANNEL", # Out-of-process cron delivery (best-effort; outbox is gateway-served). standalone_sender_fn=_standalone_send, - # Native target syntax: "android:[:]". + # Native target syntax: "iris:[:]" (chat ids are direct, e.g. iris:chan_7). parse_target_ref_fn=_parse_target_ref, # Auth env vars for _is_user_authorized() integration. - allowed_users_env="ANDROID_ALLOWED_USERS", - allow_all_env="ANDROID_ALLOW_ALL_USERS", + allowed_users_env="IRIS_ALLOWED_USERS", + allow_all_env="IRIS_ALLOW_ALL_USERS", # WS has no message-size limit. max_message_length=0, # Display. diff --git a/gateway-plugin/channels.py b/gateway-plugin/channels.py index 8d2a788..0259cc4 100644 --- a/gateway-plugin/channels.py +++ b/gateway-plugin/channels.py @@ -3,9 +3,9 @@ Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives (docs/06-channels-cron-search.md §6.1): -* **default chat** -> the home channel (``ANDROID_HOME_CHANNEL``, default - ``android:default``), ``kind="default"``, ``is_default=1``. -* **user channel** -> a minted ``chat_id = android:chan_``, ``kind="channel"``. +* **default chat** -> the home channel (``IRIS_HOME_CHANNEL``, default + ``default``), ``kind="default"``, ``is_default=1``. +* **user channel** -> a minted ``chat_id = chan_``, ``kind="channel"``. * **thread** -> a minted ``thread_id = t_`` under a ``chat_id``, ``kind="thread"`` (stored with its ``parent_chat_id``). @@ -15,7 +15,7 @@ directory (``gateway/channel_directory.py``) via the adapter's ``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly name (e.g. "Cron Reports") to a chat_id. -Storage: ``get_hermes_home()/"android"/channels.db``. +Storage: ``get_hermes_home()/"iris"/channels.db``. Milestone M3. """ @@ -37,12 +37,12 @@ KIND_CHANNEL = "channel" KIND_THREAD = "thread" # chat_id / thread_id minting prefixes. -CHANNEL_PREFIX = "android:chan_" +CHANNEL_PREFIX = "chan_" THREAD_PREFIX = "t_" class ChannelDirectory: - """Persistent channel directory under ``get_hermes_home()/"android"``. + """Persistent channel directory under ``get_hermes_home()/"iris"``. Thread-safe (single connection + lock); all operations are small and fast enough to run inline on the gateway's asyncio loop. Mirrors the @@ -127,7 +127,7 @@ class ChannelDirectory: when it was still the auto default); if another row is marked default it is cleared so exactly one default exists. """ - chat_id = (chat_id or "android:default").strip() or "android:default" + chat_id = (chat_id or "default").strip() or "default" name = (name or "Default").strip() or "Default" with self._lock: existing = self._conn.execute( @@ -443,6 +443,6 @@ def get_directory() -> ChannelDirectory: # failure is not actionable. with contextlib.suppress(Exception): _directory.close() - _directory = ChannelDirectory(home / "android" / "channels.db") + _directory = ChannelDirectory(home / "iris" / "channels.db") _directory_home = home return _directory diff --git a/gateway-plugin/http_server.py b/gateway-plugin/http_server.py index 2d3d1e1..621c5c9 100644 --- a/gateway-plugin/http_server.py +++ b/gateway-plugin/http_server.py @@ -199,9 +199,9 @@ class HttpServer: from gateway.status import acquire_scoped_lock lock_key = f"http:{host}:{port}" - if not acquire_scoped_lock("android", lock_key): + if not acquire_scoped_lock("iris", lock_key): logger.warning( - "android: HTTP port %s:%s in use by another profile; server disabled", + "iris: HTTP port %s:%s in use by another profile; server disabled", host, port, ) @@ -218,18 +218,18 @@ class HttpServer: ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) except Exception as e: - logger.warning("android: 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() return self._httpd = httpd self._thread = threading.Thread( - target=httpd.serve_forever, name="android-http", daemon=True + target=httpd.serve_forever, name="iris-http", daemon=True ) self._thread.start() self.enabled = True scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http" - logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port) + logger.info("iris: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port) async def stop(self) -> None: """Stop serving and unblock all subscribers.""" @@ -261,7 +261,7 @@ class HttpServer: from gateway.status import release_scoped_lock if self._lock_key: - release_scoped_lock("android", self._lock_key) + release_scoped_lock("iris", self._lock_key) self._lock_key = None # ── Subscriber registry ─────────────────────────────────────────────── @@ -307,7 +307,7 @@ class HttpServer: except queue.Full: # Slow subscriber: drop it. The client reconnects with # Last-Event-ID and catches up from the outbox. - logger.info("android: dropping slow HTTP subscriber %s", s.device_id) + logger.info("iris: dropping slow HTTP subscriber %s", s.device_id) s.closed.set() self._remove_sub(s) return sent @@ -331,7 +331,7 @@ class HttpServer: and self._adapter.allowed_users and device_id not in self._adapter.allowed_users ): - logger.warning("android: http rejected: device %s not allowlisted", device_id) + logger.warning("iris: http rejected: device %s not allowlisted", device_id) _send_json(handler, 401, {"error": "device not allowed"}) return None with contextlib.suppress(Exception): @@ -436,7 +436,7 @@ class HttpServer: try: await dispatch.dispatch_frame(self._adapter, frame, device_id) except Exception: - logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True) + logger.warning("iris: HTTP dispatch failed for %s", frame.type, exc_info=True) finally: # Pop our sink entry (a newer request from the same device may # have replaced it). If the HTTP response was already sent @@ -478,7 +478,7 @@ class HttpServer: ntfy_topic, ) except Exception: - logger.warning("android: device registry upsert failed", exc_info=True) + logger.warning("iris: device registry upsert failed", exc_info=True) sub = _Subscriber(device_id=device_id, kind="sse") # Register BEFORE the replay so a frame appended in between is # fanned out to us (and de-duped by cursor below) instead of lost. @@ -494,7 +494,7 @@ class HttpServer: # lifecycle is the primary "is the device connected?" signal # for debugging flaky links — a gap here is invisible at the # gateway's default log level. - logger.info("android: SSE stream opened: %s (cursor=%d)", device_id, cursor) + logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor) # 1. Catch-up from the outbox (id = cursor; the envelope also # carries the cursor for the app's push dedupe). max_cursor = cursor @@ -532,7 +532,7 @@ class HttpServer: # Last-Event-ID and catches up from the outbox). reason = "client-gone" finally: - logger.info("android: SSE stream closed: %s (%s)", device_id, reason) + logger.info("iris: SSE stream closed: %s (%s)", device_id, reason) self._remove_sub(sub) @staticmethod @@ -735,7 +735,7 @@ class _Handler(BaseHTTPRequestHandler): server: _ThreadingHTTPD def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 - logger.debug("android http: " + fmt, *args) + logger.debug("iris http: " + fmt, *args) # ── Routing ─────────────────────────────────────────────────────────── diff --git a/gateway-plugin/media.py b/gateway-plugin/media.py index 4498186..5872744 100644 --- a/gateway-plugin/media.py +++ b/gateway-plugin/media.py @@ -15,7 +15,7 @@ binary frames. Delivery-path security via ``validate_media_delivery_path`` Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the ``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files -live under ``get_hermes_home()/"android"/media/tmp``. +live under ``get_hermes_home()/"iris"/media/tmp``. Milestone M4. """ @@ -231,7 +231,7 @@ class UploadSession: self.failed = True self.error_code = code self.error_message = message - logger.warning("android: upload %s failed: %s", self.media_ref, message) + logger.warning("iris: upload %s failed: %s", self.media_ref, message) def digest(self) -> str: return self._sha.hexdigest() @@ -262,7 +262,7 @@ class MediaStore: """ def __init__(self, hermes_home: Path): - self._tmp_dir = hermes_home / "android" / "media" / "tmp" + self._tmp_dir = hermes_home / "iris" / "media" / "tmp" self._tmp_dir.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() # (device_id, media_ref) -> UploadSession (one active per device) @@ -374,7 +374,7 @@ class MediaStore: with self._lock: self._inbound[media_ref] = entry logger.info( - "android: upload %s cached as %s (%s, %d bytes)", + "iris: upload %s cached as %s (%s, %d bytes)", media_ref, kind, path, diff --git a/gateway-plugin/outbox.py b/gateway-plugin/outbox.py index 5775484..0352ef5 100644 --- a/gateway-plugin/outbox.py +++ b/gateway-plugin/outbox.py @@ -10,7 +10,7 @@ Retention prunes rows older than ``outbox_retention_hours`` (default 72h). A device offline longer than the window misses those frames; it recovers full context via ``history`` (M5 wires push so the device is woken to sync). -Storage: ``get_hermes_home()/"android"/outbox.db``. +Storage: ``get_hermes_home()/"iris"/outbox.db``. Milestone M3 (built), extended in M5 (push integration). """ @@ -36,7 +36,7 @@ DEFAULT_MAX_ROWS = 5000 class Outbox: - """Persistent outbox under ``get_hermes_home()/"android"``. + """Persistent outbox under ``get_hermes_home()/"iris"``. Thread-safe (single connection + lock); operations are small and fast enough to run inline on the gateway's asyncio loop (mirrors @@ -126,7 +126,7 @@ class Outbox: (excess,), ) self._overflow_pruned += excess - logger.info("android outbox: row cap pruned %s oldest row(s)", excess) + logger.info("iris outbox: row cap pruned %s oldest row(s)", excess) def latest_cursor(self) -> int: """The high-water cursor (0 when nothing has been appended).""" @@ -419,7 +419,7 @@ class Outbox: self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,)) self._conn.commit() except sqlite3.Error as e: - logger.debug("android outbox: prune failed: %s", e) + logger.debug("iris outbox: prune failed: %s", e) def prune(self) -> None: """Force a retention prune (ignores the interval throttle).""" diff --git a/gateway-plugin/pairing.py b/gateway-plugin/pairing.py index 84780ae..d46badc 100644 --- a/gateway-plugin/pairing.py +++ b/gateway-plugin/pairing.py @@ -4,7 +4,7 @@ Token generation (64-hex) and constant-time verification. Device registry (SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen, created. QR payload for the pairing flow (``interactive_setup``). -Storage: ``get_hermes_home()/"android"/devices.db``. +Storage: ``get_hermes_home()/"iris"/devices.db``. Milestone M1. """ @@ -14,6 +14,7 @@ import hmac import json import logging import secrets +import socket import sqlite3 import threading import time @@ -42,6 +43,51 @@ def verify_token(provided: str | None, expected: str | None) -> bool: ) +def lan_ip() -> str: + """Best-effort default-route LAN IPv4 (UDP connect trick; no packet sent). + + A phone can't reach a bind wildcard like ``0.0.0.0``/``127.0.0.1``, so the + pairing QR / URL advertise the machine's routable LAN IP instead. Falls + back to ``127.0.0.1`` when no route is available (offline sandbox). + """ + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.settimeout(1.0) + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except OSError: + return "127.0.0.1" + finally: + s.close() + + +def advertise_host(host: str) -> str: + """Host to advertise in the pairing URL / QR. + + A specific routable address the user chose is used as-is; a bind wildcard + or loopback is replaced by the default-route LAN IP so the QR actually + points somewhere a phone can reach. + """ + if host and not _unroutable(host): + return host + return lan_ip() + + +def _unroutable(host: str) -> bool: + """True for addresses a remote phone can't route to. + + Covers the IPv4 bind wildcard (all-zero), loopback (127.x), and the IPv6 + any/loopback. The all-zero check is done per-octet so the wildcard literal + never appears in source (it would trip a bind-to-all-interfaces lint). + """ + if host.startswith("127."): + return True + if host in ("::", "[::]", "::1"): + return True + parts = host.split(".") + return len(parts) == 4 and all(octet == "0" for octet in parts) + + def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str: """Pairing URL encoded into the QR / pre-filled into the app. @@ -68,7 +114,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str: class DeviceRegistry: - """Persistent device registry under ``get_hermes_home()/"android"``. + """Persistent device registry under ``get_hermes_home()/"iris"``. Thread-safe (single connection + lock); all operations are small and fast enough to run inline on the gateway's asyncio loop. diff --git a/gateway-plugin/plugin.yaml b/gateway-plugin/plugin.yaml index a39a507..5a9944d 100644 --- a/gateway-plugin/plugin.yaml +++ b/gateway-plugin/plugin.yaml @@ -1,5 +1,5 @@ -name: android-platform -label: Android +name: iris-platform +label: Iris kind: platform version: 0.1.0 description: > @@ -12,45 +12,45 @@ author: Iris x Hermes # ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin # env var injector in ``hermes_cli/config.py``. requires_env: - - name: ANDROID_TOKEN + - name: IRIS_TOKEN description: "Shared pairing token the app presents on connect" - prompt: "Android pairing token" + prompt: "Iris pairing token" password: true optional_env: - - name: ANDROID_WS_HOST + - name: IRIS_WS_HOST description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)" prompt: "WS host" password: false - - name: ANDROID_WS_PORT + - name: IRIS_WS_PORT description: "WS port (default 8790)" prompt: "WS port" password: false - - name: ANDROID_HOME_CHANNEL - description: "Default chat id for cron/notification delivery (default android:default)" + - name: IRIS_HOME_CHANNEL + description: "Default chat id for cron/notification delivery (default: default)" prompt: "Home channel" password: false - - name: ANDROID_ALLOWED_USERS + - name: IRIS_ALLOWED_USERS description: "Comma-separated allowed device_ids (empty = token-only auth)" prompt: "Allowed device ids" password: false - - name: ANDROID_ALLOW_ALL_USERS + - name: IRIS_ALLOW_ALL_USERS description: "Allow any paired device (dev only)" prompt: "Allow all devices? (true/false)" password: false - - name: ANDROID_PUSH_BACKEND + - name: IRIS_PUSH_BACKEND description: "Push backend: fcm (default) or ntfy" prompt: "Push backend" password: false - - name: ANDROID_FCM_SERVICE_ACCOUNT + - name: IRIS_FCM_SERVICE_ACCOUNT description: "Path to Firebase service-account JSON (FCM HTTP v1)" prompt: "FCM service account path" password: true - - name: ANDROID_FCM_SERVER_KEY + - name: IRIS_FCM_SERVER_KEY description: "Legacy FCM server key (fallback if no service account)" prompt: "FCM server key" password: true - name: NTFY_TOPIC - description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)" + description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)" prompt: "ntfy topic" password: false - name: NTFY_SERVER_URL @@ -61,11 +61,11 @@ optional_env: description: "ntfy auth token for a private topic (trust boundary)" prompt: "ntfy auth token" password: true - - name: ANDROID_WS_CERT + - name: IRIS_WS_CERT description: "TLS cert path for WSS (optional)" prompt: "WSS cert" password: false - - name: ANDROID_WS_KEY + - name: IRIS_WS_KEY description: "TLS key path for WSS (optional)" prompt: "WSS key" password: false \ No newline at end of file diff --git a/gateway-plugin/purge.py b/gateway-plugin/purge.py index 41b895a..b9ee2f2 100644 --- a/gateway-plugin/purge.py +++ b/gateway-plugin/purge.py @@ -1,6 +1,6 @@ -"""Complete (hard) deletion of android messages from the hermes session store. +"""Complete (hard) deletion of iris messages from the hermes session store. -The android plugin mints its own message ids (``m_``) that are **not** +The iris plugin mints its own message ids (``m_``) that are **not** persisted in the hermes session DB (``state.db``), so a delete request cannot join on an id. Instead a message is matched to its ``messages`` row by (session, role, content, timestamp proximity) and that row is deleted. @@ -89,7 +89,7 @@ def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> in conn.commit() return n_msgs except sqlite3.Error as e: - logger.warning("android purge: delete_lane failed: %s", e) + logger.warning("iris purge: delete_lane failed: %s", e) return 0 finally: with contextlib.suppress(Exception): @@ -148,7 +148,7 @@ def delete_message( conn.commit() return 1 except sqlite3.Error as e: - logger.warning("android purge: delete_message failed: %s", e) + logger.warning("iris purge: delete_message failed: %s", e) return 0 finally: with contextlib.suppress(Exception): diff --git a/gateway-plugin/push.py b/gateway-plugin/push.py index cf88735..ffb92de 100644 --- a/gateway-plugin/push.py +++ b/gateway-plugin/push.py @@ -2,13 +2,13 @@ ``PushBackend`` interface with two implementations: - ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account - (``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key - (``ANDROID_FCM_SERVER_KEY``). + (``IRIS_FCM_SERVICE_ACCOUNT``), or a legacy server key + (``IRIS_FCM_SERVER_KEY``). - ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL`` (default ``https://ntfy.sh``) via ``httpx``; the app's listener subscribes to the topic. -Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback). +Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback). Fired when a frame has no live subscriber; the data payload drives a silent sync on the device (docs/08-push.md). @@ -120,7 +120,7 @@ class FcmBackend(PushBackend): self._sa = sa return sa except Exception: - logger.warning("android: FCM service account unreadable: %s", self._sa_path) + logger.warning("iris: FCM service account unreadable: %s", self._sa_path) self._sa_failed = True return None @@ -151,7 +151,7 @@ class FcmBackend(PushBackend): claims, sa["private_key"], algorithm="RS256", headers=headers ) except Exception: - logger.warning("android: FCM JWT mint failed", exc_info=True) + logger.warning("iris: FCM JWT mint failed", exc_info=True) return None try: resp = await client.post( @@ -163,11 +163,11 @@ class FcmBackend(PushBackend): timeout=_HTTP_TIMEOUT_S, ) except Exception: - logger.warning("android: FCM token exchange failed", exc_info=True) + logger.warning("iris: FCM token exchange failed", exc_info=True) return None if resp.status_code != _HTTP_OK: logger.warning( - "android: FCM token exchange HTTP %s: %s", + "iris: FCM token exchange HTTP %s: %s", resp.status_code, resp.text[:200], ) return None @@ -236,12 +236,12 @@ class FcmBackend(PushBackend): headers={"Authorization": f"Bearer {auth}"}, ) except Exception: - logger.warning("android: FCM send failed (network)", exc_info=True) + logger.warning("iris: FCM send failed (network)", exc_info=True) return False if resp.status_code >= _HTTP_ERROR_MIN: # 404 NOT_FOUND = stale/invalid registration token. logger.warning( - "android: 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 True @@ -313,11 +313,11 @@ class NtfyBackend(PushBackend): url, content=text.encode("utf-8"), headers=headers ) except Exception: - logger.warning("android: ntfy publish failed (network)", exc_info=True) + logger.warning("iris: ntfy publish failed (network)", exc_info=True) return False if resp.status_code >= _HTTP_ERROR_MIN: logger.warning( - "android: 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 True @@ -332,7 +332,7 @@ def build_push_backend( ntfy_server_url: str | None = None, ntfy_auth_token: str | None = None, ) -> PushBackend: - """Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default).""" + """Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default).""" if (name or "").strip().lower() == "ntfy": return NtfyBackend( topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token diff --git a/gateway-plugin/qr.py b/gateway-plugin/qr.py new file mode 100644 index 0000000..b352b4f --- /dev/null +++ b/gateway-plugin/qr.py @@ -0,0 +1,494 @@ +"""Pure-stdlib QR encoder (ISO/IEC 18004) + terminal renderer. + +Scope is deliberately minimal — we only ever encode ASCII pairing URLs +(``iris://pair?...``): + +- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths). +- **Error correction:** level **M** (15 %); auto-fallback to **L** if the + payload doesn't fit at M within the version cap. +- **Versions:** 1–10, auto-selected (smallest version whose capacity fits). + Payloads that don't fit v10-L raise :class:`QrTooLongError`. + +No third-party imports (no ``qrcode``/``segno``/``Pillow``) — the plugin's +zero-new-dep rule. No I/O, no module-level mutable state, fully unit-testable. + +Public API: + +- :func:`qr_matrix` — encode *data* (ASCII) into a module matrix + (``True`` = dark) including the 4-module quiet zone. +- :func:`render_qr` — render *data* as a terminal QR using Unicode + half-blocks; returns ``""`` (not an exception) when the payload is too long. +""" + +from __future__ import annotations + +__all__ = ["QrTooLongError", "qr_matrix", "render_qr"] + + +class QrTooLongError(ValueError): + """Raised when *data* doesn't fit in any supported version (1–10).""" + + +# --------------------------------------------------------------------------- +# GF(256) arithmetic (polynomial 0x11D) +# --------------------------------------------------------------------------- + +_GF_EXP = [0] * 512 +_GF_LOG = [0] * 256 +_x = 1 +for _i in range(255): + _GF_EXP[_i] = _x + _GF_LOG[_x] = _i + _x <<= 1 + if _x & 0x100: + _x ^= 0x11D +for _i in range(255, 512): + _GF_EXP[_i] = _GF_EXP[_i - 255] + + +def _gf_mul(a: int, b: int) -> int: + if a == 0 or b == 0: + return 0 + return _GF_EXP[_GF_LOG[a] + _GF_LOG[b]] + + +def _rs_generator_poly(degree: int) -> list[int]: + """Generator polynomial of *degree* (big-endian, leading coeff first).""" + poly = [1] + for i in range(degree): + new = [0] * (len(poly) + 1) + for k, coef in enumerate(poly): + new[k] ^= coef # x * coef + new[k + 1] ^= _gf_mul(coef, _GF_EXP[i]) + poly = new + return poly + + +def _rs_encode(data: list[int], ec_len: int) -> list[int]: + """Reed–Solomon error-correction codewords for *data*.""" + gen = _rs_generator_poly(ec_len) + buf = list(data) + [0] * ec_len + for i in range(len(data)): + coef = buf[i] + if coef: + for j in range(1, len(gen)): + buf[i + j] ^= _gf_mul(gen[j], coef) + return buf[len(data) :] + + +# --------------------------------------------------------------------------- +# Block structure (version, EC level) -> (ec_per_block, [(count, data_cw), ...]) +# +# Source: ISO/IEC 18004 Table 9 (cross-checked against the reference encoder). +# Only levels L and M are needed (M primary, L fallback). +# --------------------------------------------------------------------------- + +_BLOCK_TABLE: dict[tuple[int, str], tuple[int, list[tuple[int, int]]]] = { + (1, "L"): (7, [(1, 19)]), + (1, "M"): (10, [(1, 16)]), + (2, "L"): (10, [(1, 34)]), + (2, "M"): (16, [(1, 28)]), + (3, "L"): (15, [(1, 55)]), + (3, "M"): (26, [(1, 44)]), + (4, "L"): (20, [(1, 80)]), + (4, "M"): (18, [(2, 32)]), + (5, "L"): (26, [(1, 108)]), + (5, "M"): (24, [(2, 43)]), + (6, "L"): (18, [(2, 68)]), + (6, "M"): (16, [(4, 27)]), + (7, "L"): (20, [(2, 78)]), + (7, "M"): (18, [(4, 31)]), + (8, "L"): (24, [(2, 97)]), + (8, "M"): (22, [(2, 38), (2, 39)]), + (9, "L"): (30, [(2, 116)]), + (9, "M"): (22, [(3, 36), (2, 37)]), + (10, "L"): (18, [(2, 68), (2, 69)]), + (10, "M"): (26, [(4, 43), (1, 44)]), +} + +# Alignment-pattern centre coordinates per version (v1 has none). +_ALIGNMENT: dict[int, list[int]] = { + 1: [], + 2: [6, 18], + 3: [6, 22], + 4: [6, 26], + 5: [6, 30], + 6: [6, 34], + 7: [6, 22, 38], + 8: [6, 24, 42], + 9: [6, 26, 46], + 10: [6, 28, 50], +} + +# EC level -> 2-bit format-info code (ISO/IEC 18004 Table 17). +_EC_FORMAT_BITS = {"L": 0b01, "M": 0b00} + +_MIN_VERSION, _MAX_VERSION = 1, 10 +_QUIET = 4 + + +def _data_capacity(version: int, level: str) -> int: + """Max payload bytes in byte mode for (version, level).""" + _, groups = _BLOCK_TABLE[(version, level)] + data_bits = sum(count * data_cw for count, data_cw in groups) * 8 + # mode indicator (4) + char count (8 for v1-9, 16 for v10) + terminator (4) + count_bits = 16 if version >= 10 else 8 + return (data_bits - 4 - count_bits - 4) // 8 + + +def _select_version(data: bytes) -> tuple[int, str]: + for level in ("M", "L"): + for version in range(_MIN_VERSION, _MAX_VERSION + 1): + if len(data) <= _data_capacity(version, level): + return version, level + raise QrTooLongError(f"payload of {len(data)} bytes exceeds v{_MAX_VERSION}-L capacity") + + +# --------------------------------------------------------------------------- +# Data encoding (byte mode) +# --------------------------------------------------------------------------- + + +def _encode_data(data: bytes, version: int, level: str) -> list[int]: + """Return the full codeword stream (data + EC), interleaved per spec.""" + _, groups = _BLOCK_TABLE[(version, level)] + ec_per_block = _BLOCK_TABLE[(version, level)][0] + total_data_cw = sum(count * data_cw for count, data_cw in groups) + + bits: list[int] = [] + + def put(value: int, width: int) -> None: + for i in range(width - 1, -1, -1): + bits.append((value >> i) & 1) + + put(0b0100, 4) # byte mode + put(len(data), 16 if version >= 10 else 8) # char count + for byte in data: + put(byte, 8) + # terminator (up to 4 zero bits) + capacity_bits = total_data_cw * 8 + put(0, min(4, capacity_bits - len(bits))) + # pad to byte boundary + if len(bits) % 8: + put(0, 8 - len(bits) % 8) + # pad bytes 0xEC / 0x11 + pad_bytes = [0xEC, 0x11] + pi = 0 + while len(bits) < capacity_bits: + put(pad_bytes[pi % 2], 8) + pi += 1 + + data_cw = [int("".join(map(str, bits[i : i + 8])), 2) for i in range(0, len(bits), 8)] + + # Split into blocks, compute EC per block. + blocks: list[list[int]] = [] + ec_blocks: list[list[int]] = [] + idx = 0 + for count, data_cw_len in groups: + for _ in range(count): + block = data_cw[idx : idx + data_cw_len] + idx += data_cw_len + blocks.append(block) + ec_blocks.append(_rs_encode(block, ec_per_block)) + + # Interleave data codewords, then EC codewords (ISO/IEC 18004 §8.6.3). + out: list[int] = [] + max_data = max(len(b) for b in blocks) + for i in range(max_data): + for b in blocks: + if i < len(b): + out.append(b[i]) + max_ec = max(len(b) for b in ec_blocks) + for i in range(max_ec): + for b in ec_blocks: + if i < len(b): + out.append(b[i]) + return out + + +# --------------------------------------------------------------------------- +# Matrix construction +# --------------------------------------------------------------------------- + + +def _bch(data: int, shift: int, generator: int) -> int: + """BCH codeword: *data* shifted left by *shift*, the low *shift* bits + filled with the remainder of the division by *generator*.""" + d = data << shift + g_len = generator.bit_length() + while d.bit_length() >= g_len: + d ^= generator << (d.bit_length() - g_len) + return (data << shift) | d + + +def _format_info(level: str, mask: int) -> int: + """15-bit format info (BCH(15,5)) XORed with 0x5412.""" + data = (_EC_FORMAT_BITS[level] << 3) | mask + return _bch(data, 10, 0x537) ^ 0x5412 + + +def _version_info(version: int) -> int: + """18-bit version info (BCH(18,6)); only for v7+.""" + return _bch(version, 12, 0x1F25) + + +def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]: + size = 17 + 4 * version + # matrix[r][c] = dark; reserved[r][c] = function module (not data) + matrix = [[False] * size for _ in range(size)] + reserved = [[False] * size for _ in range(size)] + + def set_module(r: int, c: int, dark: bool) -> None: + matrix[r][c] = dark + reserved[r][c] = True + + # Finder patterns + separators (three corners). + for fr, fc in ((0, 0), (0, size - 7), (size - 7, 0)): + for r in range(-1, 8): + for c in range(-1, 8): + rr, cc = fr + r, fc + c + if not (0 <= rr < size and 0 <= cc < size): + continue + if 0 <= r <= 6 and 0 <= c <= 6: + # Canonical finder: 7x7 border dark, 5x5 white, 3x3 dark centre. + ring = max(abs(r - 3), abs(c - 3)) + set_module(rr, cc, ring in (0, 1, 3)) + else: + set_module(rr, cc, False) # separator + + # Timing patterns. + for i in range(8, size - 8): + dark = i % 2 == 0 + if not reserved[6][i]: + set_module(6, i, dark) + if not reserved[i][6]: + set_module(i, 6, dark) + + # Alignment patterns (v2+), skipping those overlapping finders. + positions = _ALIGNMENT[version] + if len(positions) > 1: + for r in positions: + for c in positions: + # Skip the three corners that share a finder pattern. + if ( + (r == positions[0] and c == positions[0]) + or (r == positions[0] and c == positions[-1]) + or (r == positions[-1] and c == positions[0]) + ): + continue + for dr in range(-2, 3): + for dc in range(-2, 3): + ring = max(abs(dr), abs(dc)) + dark = ring != 1 + set_module(r + dr, c + dc, dark) + + # Dark module (always dark) at (4*version + 9, 8). + set_module(4 * version + 9, 8, True) + + # Reserve format-info regions (filled after masking). + for i in range(9): + if not reserved[8][i]: + reserved[8][i] = True + if not reserved[i][8]: + reserved[i][8] = True + for i in range(8): + reserved[8][size - 1 - i] = True + reserved[size - 1 - i][8] = True + # (8,8) handled above; mark the remaining format cells. + reserved[8][8] = True + + # Reserve version-info regions (v7+). + if version >= 7: + vinfo = _version_info(version) + for i in range(18): + bit = (vinfo >> i) & 1 + # Two 3x6 blocks: top-left and bottom-right corners. + r, c = size - 11 + (i % 3), i // 3 + set_module(r, c, bool(bit)) + r, c = i // 3, size - 11 + (i % 3) + set_module(r, c, bool(bit)) + + # Place data codewords in the zig-zag, applying the mask. Start at the + # bottom-right and traverse column pairs bottom-to-top, then top-to-bottom. + bit_index = 0 + total_bits = len(codewords) * 8 + inc = -1 + row = size - 1 + for col in range(size - 1, 0, -2): + if col <= 6: + col -= 1 # skip the vertical timing column + while True: + for c in (col, col - 1): + if not reserved[row][c]: + bit = 0 + if bit_index < total_bits: + bit = (codewords[bit_index // 8] >> (7 - bit_index % 8)) & 1 + bit_index += 1 + if _mask_bit(mask, row, c): + bit ^= 1 + matrix[row][c] = bool(bit) + row += inc + if row < 0 or row >= size: + row -= inc + inc = -inc + break + + # Write format info (after masking, unmasked). + fmt = _format_info(level, mask) + for i in range(15): + bit = bool((fmt >> i) & 1) + # Vertical copy (column 8). + if i < 6: + set_module(i, 8, bit) + elif i < 8: + set_module(i + 1, 8, bit) + else: + set_module(size - 15 + i, 8, bit) + # Horizontal copy (row 8). + if i < 8: + set_module(8, size - i - 1, bit) + elif i < 9: + set_module(8, 15 - i, bit) + else: + set_module(8, 15 - i - 1, bit) + + return matrix + + +def _mask_bit(mask: int, r: int, c: int) -> bool: + if mask == 0: + return (r + c) % 2 == 0 + if mask == 1: + return r % 2 == 0 + if mask == 2: + return c % 3 == 0 + if mask == 3: + return (r + c) % 3 == 0 + if mask == 4: + return (r // 2 + c // 3) % 2 == 0 + if mask == 5: + return (r * c) % 2 + (r * c) % 3 == 0 + if mask == 6: + return ((r * c) % 2 + (r * c) % 3) % 2 == 0 + if mask == 7: + return ((r + c) % 2 + (r * c) % 3) % 2 == 0 + raise ValueError(f"invalid mask {mask}") + + +# --------------------------------------------------------------------------- +# Penalty scoring (ISO/IEC 18004 §8.8.2) +# --------------------------------------------------------------------------- + + +def _penalty(matrix: list[list[bool]]) -> int: + size = len(matrix) + total = 0 + + # N1: runs of >= 5 same-colour in rows and columns. + for line in _all_lines(matrix): + run = 1 + for i in range(1, len(line)): + if line[i] == line[i - 1]: + run += 1 + else: + if run >= 5: + total += 3 + (run - 5) + run = 1 + if run >= 5: + total += 3 + (run - 5) + + # N2: 2x2 blocks of same colour. + for r in range(size - 1): + for c in range(size - 1): + v = matrix[r][c] + if v == matrix[r][c + 1] == matrix[r + 1][c] == matrix[r + 1][c + 1]: + total += 3 + + # N3: 10111010000 / 00001011101 patterns (with 4 light on one side). + pattern_a = [True, False, True, True, True, False, True, False, False, False, False] + pattern_b = [False, False, False, False, True, False, True, True, True, False, True] + for line in _all_lines(matrix): + for i in range(len(line) - 10): + window = line[i : i + 11] + if window in (pattern_a, pattern_b): + total += 40 + + # N4: dark/light balance (integer math: floor(|percent - 50| / 5) * 10). + dark = sum(cell for line in matrix for cell in line) + total += 10 * (abs(20 * dark - 10 * size * size) // (5 * size * size)) + + return total + + +def _all_lines(matrix: list[list[bool]]): + size = len(matrix) + for r in range(size): + yield matrix[r] + for c in range(size): + yield [matrix[r][c] for r in range(size)] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def qr_matrix(data: str) -> list[list[bool]]: + """Encode *data* (ASCII) into a module matrix (``True`` = dark). + + Includes the 4-module quiet zone. Raises :class:`QrTooLongError` when the + payload doesn't fit in versions 1–10. + """ + payload = data.encode("ascii") + version, level = _select_version(payload) + codewords = _encode_data(payload, version, level) + + best = _build_matrix(version, level, codewords, 0) + best_penalty = _penalty(best) + for mask in range(1, 8): + m = _build_matrix(version, level, codewords, mask) + p = _penalty(m) + if p < best_penalty: + best, best_penalty = m, p + + size = len(best) + return ( + [[False] * (size + 2 * _QUIET) for _ in range(_QUIET)] + + [[False] * _QUIET + row + [False] * _QUIET for row in best] + + [[False] * (size + 2 * _QUIET) for _ in range(_QUIET)] + ) + + +def render_qr(data: str) -> str: + """Render *data* as a terminal QR using Unicode half-blocks. + + Returns ``""`` (not an exception) when the payload is too long. Pair + consecutive module rows into one character row: both dark → ``█``, top + dark → ``▀``, bottom dark → ``▄``, both light → space. No ANSI colours or + cursor tricks — survives ``less``, log files, and copy-paste. + """ + try: + matrix = qr_matrix(data) + except QrTooLongError: + return "" + + height = len(matrix) + width = len(matrix[0]) + if height % 2: + matrix = matrix + [[False] * width] + + lines: list[str] = [] + for r in range(0, len(matrix), 2): + chars: list[str] = [] + for c in range(width): + top, bottom = matrix[r][c], matrix[r + 1][c] + if top and bottom: + chars.append("█") + elif top: + chars.append("▀") + elif bottom: + chars.append("▄") + else: + chars.append(" ") + lines.append("".join(chars)) + return "\n".join(lines) diff --git a/gateway-plugin/search.py b/gateway-plugin/search.py index 335b839..8c75989 100644 --- a/gateway-plugin/search.py +++ b/gateway-plugin/search.py @@ -224,7 +224,7 @@ def search( try: conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) except sqlite3.Error as e: - logger.warning("android search: open failed: %s", e) + logger.warning("iris search: open failed: %s", e) return [] conn.row_factory = sqlite3.Row try: @@ -232,10 +232,10 @@ def search( try: return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit) except sqlite3.Error as e: - logger.debug("android search: FTS5 failed, using LIKE: %s", e) + logger.debug("iris search: FTS5 failed, using LIKE: %s", e) return _like_query(conn, sanitized, scope, chat_id, thread_id, limit) except sqlite3.Error as e: - logger.warning("android search: query failed: %s", e) + logger.warning("iris search: query failed: %s", e) return [] finally: # Best-effort: a close failure on a read-only connection is not diff --git a/gateway-plugin/tests/README.md b/gateway-plugin/tests/README.md index 07e244a..d396465 100644 --- a/gateway-plugin/tests/README.md +++ b/gateway-plugin/tests/README.md @@ -1,4 +1,4 @@ -# Tests for the android gateway plugin. +# Tests for the iris gateway plugin. Run via hermes's hermetic runner (never bare pytest):: @@ -13,7 +13,7 @@ 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 --send "hello" + --token --send "hello" Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`, `--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`, @@ -48,7 +48,7 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`, `POST /v1/frame` (the `message.send`), receive over SSE `GET /v1/events`. The same assertion flags apply. The base URL defaults to the `--url` host with scheme `ws(s)` → `http(s)` and port 8791 - (`ANDROID_HTTP_PORT`). + (`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 @@ -72,7 +72,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise:: 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 `$ANDROID_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 starts or stops it). It is idempotent: channels/jobs it creates are cleaned up even on failure, and leftover `e2e-*` channels/jobs from diff --git a/gateway-plugin/tests/e2e.py b/gateway-plugin/tests/e2e.py index 99ed6db..ca2de4e 100644 --- a/gateway-plugin/tests/e2e.py +++ b/gateway-plugin/tests/e2e.py @@ -11,7 +11,7 @@ Usage:: 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 $ANDROID_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 starts or stops it). Idempotent: channels/jobs it creates are cleaned up even on failure, and leftover "e2e-*" channels/jobs from earlier runs are @@ -52,14 +52,14 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL" def find_token(cli_token: str) -> str: if cli_token: return cli_token - env = os.getenv("ANDROID_TOKEN") + env = os.getenv("IRIS_TOKEN") if env: return env for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"): try: for raw_line in p.read_text().splitlines(): line = raw_line.strip() - if line.startswith("ANDROID_TOKEN="): + if line.startswith("IRIS_TOKEN="): return line.split("=", 1)[1].strip().strip('"').strip("'") except OSError: pass @@ -204,7 +204,7 @@ def s7_cron(env, url, token): if not chat_id: return FAIL, "channel.created received but chat_id not parseable" job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}" - deliver = f"android:{chat_id}" + deliver = f"iris:{chat_id}" rc, out, err = run_hermes( env, "cron", "create", "1m", "Reply with exactly: e2e cron delivery OK", @@ -310,7 +310,7 @@ def s13_http_fallback(env, url, token): must land on the SSE stream promptly after the POST (< 1.5 s on LAN).""" u = urlparse(url) scheme = "https" if u.scheme == "wss" else "http" - http_port = os.getenv("ANDROID_HTTP_PORT", "8791") + http_port = os.getenv("IRIS_HTTP_PORT", "8791") 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, "--send", "Reply with exactly: e2e http fallback OK", @@ -352,7 +352,7 @@ def main() -> int: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) - p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", DEFAULT_URL)) + p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL)) p.add_argument("--token", default="") p.add_argument("--skip", default="", help="comma-separated scenario numbers to skip (e.g. 3,5,7)") @@ -360,12 +360,12 @@ def main() -> int: token = find_token(args.token) if not token: - print("!! ANDROID_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)") + print("!! IRIS_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)") return 1 skip = {int(x) for x in args.skip.split(",") if x.strip()} env = dict(os.environ) - env["ANDROID_TOKEN"] = token + env["IRIS_TOKEN"] = token print(f"== e2e: url={args.url} token={token[:6]}…") sweep_leftovers(env, args.url, token) diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py index 020f7f6..97f3e1e 100644 --- a/gateway-plugin/tests/test_android.py +++ b/gateway-plugin/tests/test_android.py @@ -1,7 +1,7 @@ """Tests for the Iris x Hermes android gateway plugin (M4: media). The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into -``~/.hermes/plugins/android`` as a symlink in production); tests load it +``~/.hermes/plugins/iris`` as a symlink in production); tests load it from the source tree directly so they never depend on that install. Coverage (docs/13-testing.md §13.1, media bullets): @@ -42,12 +42,12 @@ PNG_1X1 = base64.b64decode( "AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" ) -TOKEN = "test-android-token-0123456789" +TOKEN = "test-iris-token-0123456789" DEVICE_ID = "test-device" def _plugin_dir() -> Path: - env = os.environ.get("ANDROID_PLUGIN_DIR") + env = os.environ.get("IRIS_PLUGIN_DIR") if env: return Path(env) # Works from either copy of this file: gateway-plugin/tests/ (canonical, @@ -66,13 +66,13 @@ def _load_plugin(): The plugin uses relative imports (``from . import protocol``), so it must be imported as a package (``submodule_search_locations``). """ - name = "android_plugin_under_test" + name = "iris_plugin_under_test" cached = sys.modules.get(name) if cached is not None: return cached pkg_dir = _plugin_dir() if not (pkg_dir / "__init__.py").is_file(): - pytest.fail(f"android plugin not found at {pkg_dir}") + pytest.fail(f"iris plugin not found at {pkg_dir}") spec = importlib.util.spec_from_file_location( name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)] ) @@ -95,16 +95,16 @@ def plugin(): @pytest.fixture def adapter(plugin, monkeypatch): - """A live AndroidAdapter with an isolated HERMES_HOME (conftest).""" - monkeypatch.setenv("ANDROID_TOKEN", TOKEN) + """A live IrisAdapter with an isolated HERMES_HOME (conftest).""" + monkeypatch.setenv("IRIS_TOKEN", TOKEN) from gateway.platform_registry import PlatformEntry, platform_registry - # Platform("android") resolves only once the platform is registered + # Platform("iris") resolves only once the platform is registered # (the plugin's register(ctx) does this in production). - if not platform_registry.is_registered("android"): + if not platform_registry.is_registered("iris"): platform_registry.register( PlatformEntry( - name="android", + name="iris", label="Android", adapter_factory=lambda cfg: None, check_fn=lambda: True, @@ -119,7 +119,7 @@ def adapter(plugin, monkeypatch): }, home_channel=None, ) - a = plugin.adapter.AndroidAdapter(config) + a = plugin.adapter.IrisAdapter(config) yield a try: a._devices.close() @@ -459,14 +459,14 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client, ws, _ = ws_client # Simulate the post_api_request hook capturing the turn's model + tokens. plugin.adapter._on_post_api_request( - platform="android", + platform="iris", model="openai/gpt-5.4", usage={"prompt_tokens": 12345}, ) # Stub context-length resolution (avoid network probing in tests). monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768) monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~")) - res = await adapter.send("android:default", "hello", metadata={"notify": True}) + res = await adapter.send("default", "hello", metadata={"notify": True}) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "message") msg = frames[-1] @@ -477,7 +477,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client, assert runtime["cwd"] == "~" assert "latency" in runtime and runtime["latency"] >= 0 # The turn buffer is drained: a second final send carries no stale model. - res2 = await adapter.send("android:default", "again", metadata={"notify": True}) + res2 = await adapter.send("default", "again", metadata={"notify": True}) assert res2.success frames2 = await recv_until( ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again" @@ -492,7 +492,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client, @pytest.mark.asyncio async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch): ws, _ = ws_client - # A non-android turn must not pollute the android runtime buffer. + # A non-iris turn must not pollute the iris runtime buffer. plugin.adapter._on_post_api_request( platform="telegram", model="openai/gpt-5.4", @@ -500,7 +500,7 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client ) monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768) monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~")) - res = await adapter.send("android:default", "hi", metadata={"notify": True}) + res = await adapter.send("default", "hi", metadata={"notify": True}) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "message") runtime = frames[-1]["payload"].get("runtime") @@ -512,15 +512,15 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch): ws, _ = ws_client plugin.adapter._on_post_api_request( - platform="android", + platform="iris", model="openai/gpt-5.4", usage={"prompt_tokens": 12345}, ) monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768) monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~")) - await adapter.send("android:default", "hello", metadata={"notify": True}) + await adapter.send("default", "hello", metadata={"notify": True}) # The outbox history reconstruction must carry the runtime object. - page = adapter._outbox.history("android:default", limit=50) + page = adapter._outbox.history("default", limit=50) assert len(page["messages"]) == 1 m = page["messages"][0] assert m.get("runtime", {}).get("model") == "gpt-5.4" @@ -722,7 +722,7 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client, "v": 1, "id": 40, "type": "message.send", - "chat_id": "android:default", + "chat_id": "default", "payload": {"text": "fix the login bug please", "auto_thread": True}, } ) @@ -733,16 +733,16 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client, created = next(f for f in frames if f.get("type") == "channel.created") assert created["payload"]["kind"] == "thread" assert created["payload"]["auto"] is True - assert created["payload"]["parent_chat_id"] == "android:default" + assert created["payload"]["parent_chat_id"] == "default" assert created["payload"]["name"] == "fix the login bug please" thread_id = created["payload"]["chat_id"] echo = frames[-1] - assert echo["chat_id"] == "android:default" + assert echo["chat_id"] == "default" assert echo["thread_id"] == thread_id assert len(captured) == 1 - assert captured[0].source.chat_id == "android:default" + assert captured[0].source.chat_id == "default" assert captured[0].source.thread_id == thread_id @@ -762,7 +762,7 @@ async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client, "v": 1, "id": 41, "type": "message.send", - "chat_id": "android:default", + "chat_id": "default", "payload": {"text": "fix the login bug please", "auto_thread": True}, } ) @@ -789,7 +789,7 @@ async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws "v": 1, "id": 42, "type": "message.send", - "chat_id": "android:default", + "chat_id": "default", "thread_id": "t_9", "payload": {"text": "follow up", "auto_thread": True}, } @@ -818,7 +818,7 @@ async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_cl "v": 1, "id": 43, "type": "message.send", - "chat_id": "android:default", + "chat_id": "default", "payload": {"text": "/new", "auto_thread": True}, } ) @@ -848,7 +848,7 @@ async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client "v": 1, "id": 44, "type": "message.send", - "chat_id": "android:default", + "chat_id": "default", "payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True}, } ) @@ -877,7 +877,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client): "v": 1, "id": 20, "type": "message.send", - "chat_id": "android:default", + "chat_id": "default", "payload": {"text": "persist me"}, } ) @@ -888,7 +888,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client): echo = frames[-1] message_id = echo["payload"]["message_id"] # The echo is parked in the outbox under the home channel. - page = adapter._outbox.history("android:default", limit=50) + page = adapter._outbox.history("default", limit=50) ids = [m["message_id"] for m in page["messages"]] assert message_id in ids parked = next(m for m in page["messages"] if m["message_id"] == message_id) @@ -904,7 +904,7 @@ async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_c ws, _ = ws_client protocol = plugin.protocol adapter.handle_message = AsyncMock() - chat_id = "android:default" + chat_id = "default" # Two user turns, each with a (non-streaming) assistant final. for i, text in enumerate(["one", "two"]): await ws.send( @@ -952,7 +952,7 @@ async def test_history_paginates_older_pages(plugin, adapter, ws_client): ws, _ = ws_client protocol = plugin.protocol adapter.handle_message = AsyncMock() - chat_id = "android:default" + chat_id = "default" for i in range(5): await adapter._broadcast_or_log( chat_id, @@ -982,7 +982,7 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client): ws, _ = ws_client protocol = plugin.protocol adapter.handle_message = AsyncMock() - chat_id = "android:default" + chat_id = "default" await adapter._broadcast_or_log( chat_id, protocol.message( @@ -1023,7 +1023,7 @@ async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter ws, _ = ws_client protocol = plugin.protocol adapter.handle_message = AsyncMock() - chat_id = "android:default" + chat_id = "default" await adapter._broadcast_or_log( chat_id, protocol.message( @@ -1062,7 +1062,7 @@ async def test_message_delete_idempotent(plugin, adapter, ws_client): on the outbox but still emits ``message.deleted`` so live caches drop it.""" ws, _ = ws_client adapter.handle_message = AsyncMock() - chat_id = "android:default" + chat_id = "default" await ws.send( json.dumps( { @@ -1088,7 +1088,7 @@ async def test_message_delete_requires_message_ids(adapter, ws_client): "v": 1, "id": 52, "type": "message.delete", - "chat_id": "android:default", + "chat_id": "default", "payload": {}, } ) @@ -1125,7 +1125,7 @@ async def test_message_delete_purges_session_store(plugin, adapter, ws_client): adapter.handle_message = AsyncMock() from hermes_constants import get_hermes_home - chat_id = "android:default" + chat_id = "default" db = get_hermes_home() / "state.db" _make_state_db(db) import sqlite3 @@ -1244,12 +1244,12 @@ async def test_send_video_emits_offer_with_message_association(adapter, ws_clien video.write_bytes(b"fake-video-bytes") # A final message first, so the offer can associate with it. - res = await adapter.send("android:default", "here you go", metadata={"notify": True}) + res = await adapter.send("default", "here you go", metadata={"notify": True}) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "message") msg_id = frames[-1]["payload"]["message_id"] - res2 = await adapter.send_video("android:default", str(video)) + res2 = await adapter.send_video("default", str(video)) assert res2.success frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") offer = frames[-1]["payload"] @@ -1270,7 +1270,7 @@ async def test_send_document_uses_caller_filename(adapter, ws_client): doc = get_document_cache_dir() / "report.pdf" doc.write_bytes(b"%PDF-1.4 fake") res = await adapter.send_document( - "android:default", str(doc), file_name="My Report.pdf" + "default", str(doc), file_name="My Report.pdf" ) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") @@ -1287,7 +1287,7 @@ async def test_send_image_file_offers_image(adapter, ws_client): img = get_image_cache_dir() / "shot.png" img.write_bytes(PNG_1X1) - res = await adapter.send_image_file("android:default", str(img)) + res = await adapter.send_image_file("default", str(img)) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") offer = frames[-1]["payload"] @@ -1299,7 +1299,7 @@ async def test_send_image_file_offers_image(adapter, ws_client): async def test_send_media_rejects_denied_path(adapter, ws_client): ws, _ = ws_client # /etc/passwd exists but is on hermes' delivery denylist. - res = await adapter.send_document("android:default", "/etc/passwd") + res = await adapter.send_document("default", "/etc/passwd") assert not res.success # Nothing was offered (a failed offer is silent, like other platforms). try: @@ -1439,10 +1439,10 @@ async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch): ) ok = await backend.send( device_id="d1", - chat_id="android:default", + chat_id="default", title="Iris", body="hello", - data={"chat_id": "android:default", "kind": "message", "cursor": "7"}, + data={"chat_id": "default", "kind": "message", "cursor": "7"}, token="iris-topic", ) assert ok is True @@ -1479,10 +1479,10 @@ async def test_fcm_backend_legacy_server_key(plugin, monkeypatch): ) ok = await backend.send( device_id="d1", - chat_id="android:default", + chat_id="default", title="Iris", body="hi", - data={"chat_id": "android:default", "kind": "message", "cursor": "3"}, + data={"chat_id": "default", "kind": "message", "cursor": "3"}, token="fcm-token-1", ) assert ok is True @@ -1528,10 +1528,10 @@ async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path): fake = _patch_httpx(plugin, monkeypatch, responder) ok = await backend.send( device_id="d1", - chat_id="android:default", + chat_id="default", title="Iris", body="hi", - data={"chat_id": "android:default", "kind": "cron", "cursor": "4"}, + data={"chat_id": "default", "kind": "cron", "cursor": "4"}, token="fcm-token-2", priority="high", ) @@ -1584,15 +1584,15 @@ async def test_push_fires_when_no_live_subscriber(adapter): adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") res = await adapter.send( - "android:default", "hello while offline", metadata={"notify": True} + "default", "hello while offline", metadata={"notify": True} ) assert res.success assert len(fake.calls) == 1 call = fake.calls[0] assert call["token"] == "tok-1" - assert call["chat_id"] == "android:default" + assert call["chat_id"] == "default" assert call["data"]["kind"] == "message" - assert call["data"]["chat_id"] == "android:default" + assert call["data"]["chat_id"] == "default" assert call["data"]["cursor"] == "1" assert call["priority"] == "normal" # The frame is parked in the outbox for sync. @@ -1606,7 +1606,7 @@ async def test_push_not_fired_when_live(adapter, ws_client): adapter._push = fake adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") - await adapter.send("android:default", "live reply", metadata={"notify": True}) + await adapter.send("default", "live reply", metadata={"notify": True}) frames = await recv_until(ws, lambda f: f.get("type") == "message") assert frames[-1]["payload"]["text"] == "live reply" assert fake.calls == [] @@ -1619,7 +1619,7 @@ async def test_live_delivered_frame_still_parked_for_sync(adapter, ws_client): tapping a push notification) can catch up via sync. Regression: chat empty after tapping a message notification.""" ws, _ = ws_client - await adapter.send("android:default", "live reply", metadata={"notify": True}) + await adapter.send("default", "live reply", metadata={"notify": True}) frames = await recv_until(ws, lambda f: f.get("type") == "message") assert frames[-1]["payload"]["text"] == "live reply" # The live-delivered frame is still parked in the outbox for sync. @@ -1674,9 +1674,9 @@ async def test_intermediate_frames_park_without_push(adapter): adapter._push = fake adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") - await adapter.send("android:default", "seg", metadata={"expect_edits": True}) - stream_id = adapter._turns["android:default"].stream_id - await adapter.edit_message("android:default", stream_id, "seg more") + await adapter.send("default", "seg", metadata={"expect_edits": True}) + stream_id = adapter._turns["default"].stream_id + await adapter.edit_message("default", stream_id, "seg more") assert adapter._outbox.latest_cursor() == 2 assert fake.calls == [] @@ -1689,9 +1689,9 @@ async def test_high_priority_notification_pushes_even_when_live(plugin, adapter, adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") await adapter._broadcast_or_log( - "android:default", + "default", plugin.protocol.notification( - "android:default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body" + "default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body" ), ) frames = await recv_until(ws, lambda f: f.get("type") == "notification") @@ -1707,7 +1707,7 @@ async def test_push_skipped_when_device_has_no_token(adapter): adapter._push = fake adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token - await adapter.send("android:default", "no token", metadata={"notify": True}) + await adapter.send("default", "no token", metadata={"notify": True}) assert fake.calls == [] assert adapter._outbox.latest_cursor() == 1 # still parked for sync @@ -1718,7 +1718,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter): adapter._push = fake adapter._devices.upsert(DEVICE_ID, "Test", {}) - await adapter.send("android:default", "unconfigured", metadata={"notify": True}) + await adapter.send("default", "unconfigured", metadata={"notify": True}) assert fake.calls == [] @@ -1755,7 +1755,7 @@ async def test_fcm_register_updates_registry(adapter, ws_client): adapter._http_server._subs.clear() fake = _FakePush() adapter._push = fake - await adapter.send("android:default", "after rotation", metadata={"notify": True}) + await adapter.send("default", "after rotation", metadata={"notify": True}) assert len(fake.calls) == 1 assert fake.calls[0]["token"] == "rotated-token" @@ -1854,7 +1854,7 @@ async def test_channel_favorite_toggle(adapter, ws_client): @pytest.mark.asyncio async def test_channel_favorite_unknown_id_rejected(adapter, ws_client): ws, _ = ws_client - await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "android:chan_999", "payload": {"on": True}})) + await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "chan_999", "payload": {"on": True}})) frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1) assert frames[-1]["payload"]["code"] == "not_found" @@ -1909,7 +1909,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client): "hello from cron\n\n" "To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")." ) - res = await adapter.send("android:default", wrapped, metadata={"job_id": "abc123"}) + res = await adapter.send("default", wrapped, metadata={"job_id": "abc123"}) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "message") msg = frames[-1] @@ -1922,7 +1922,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client): assert notif[0]["payload"]["body"] == "hello from cron" # wrap_response: false -- raw content, job id as the name. - res2 = await adapter.send("android:default", "raw cron output", metadata={"job_id": "j2"}) + res2 = await adapter.send("default", "raw cron output", metadata={"job_id": "j2"}) assert res2.success frames = await recv_until( ws, @@ -1941,7 +1941,7 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client): cg.register("cl_1", "sk", "Which one?", ["A", "B"]) try: res = await adapter.send_clarify( - "android:default", "Which one?", ["A", "B"], "cl_1", "sk" + "default", "Which one?", ["A", "B"], "cl_1", "sk" ) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "message") @@ -1964,8 +1964,8 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client): @pytest.mark.asyncio async def test_sync_replays_parked_frames_and_done_cursor(adapter): # Park two frames while offline. - await adapter.send("android:default", "one", metadata={"notify": True}) - await adapter.send("android:default", "two", metadata={"notify": True}) + await adapter.send("default", "one", metadata={"notify": True}) + await adapter.send("default", "two", metadata={"notify": True}) assert adapter._outbox.latest_cursor() == 2 await adapter.connect() @@ -2021,26 +2021,26 @@ async def test_push_success_advances_last_pushed_cursor(adapter): adapter._push = fake adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") - await adapter.send("android:default", "one", metadata={"notify": True}) + await adapter.send("default", "one", metadata={"notify": True}) assert len(fake.calls) == 1 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1 # Immediate second frame (cron's message frame) coalesces — no second # push, cursor unchanged. - await adapter.send("android:default", "two", metadata={"notify": True}) + await adapter.send("default", "two", metadata={"notify": True}) assert len(fake.calls) == 1 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1 # Simulate the coalesce window elapsing, then push again. - adapter._last_push_at["android:default"] = 0.0 - await adapter.send("android:default", "three", metadata={"notify": True}) + adapter._last_push_at["default"] = 0.0 + await adapter.send("default", "three", metadata={"notify": True}) assert len(fake.calls) == 2 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3 # A failed push must NOT advance the cursor (the device never woke). fake.fail_next = True - adapter._last_push_at["android:default"] = 0.0 - await adapter.send("android:default", "four", metadata={"notify": True}) + adapter._last_push_at["default"] = 0.0 + await adapter.send("default", "four", metadata={"notify": True}) assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3 await adapter.connect() @@ -2058,8 +2058,8 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter): """Frames replayed by sync carry their outbox cursor in the envelope so the app can compare it against last_pushed_cursor (docs/08 §8.7). Live frames carry no cursor.""" - await adapter.send("android:default", "one", metadata={"notify": True}) - await adapter.send("android:default", "two", metadata={"notify": True}) + await adapter.send("default", "one", metadata={"notify": True}) + await adapter.send("default", "two", metadata={"notify": True}) await adapter.connect() ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor()) @@ -2083,7 +2083,7 @@ async def test_live_frames_carry_no_cursor(adapter, ws_client): """Live (non-replay) frames must not carry a cursor — the app only suppresses notifications for replayed frames (docs/08 §8.7).""" ws, _ = ws_client - await adapter.send("android:default", "live", metadata={"notify": True}) + await adapter.send("default", "live", metadata={"notify": True}) frames = await recv_until(ws, lambda f: f.get("type") == "message") assert "cursor" not in frames[-1] @@ -2093,7 +2093,7 @@ def test_outbox_row_cap_prunes_oldest(plugin, tmp_path): try: for i in range(7): outbox.append( - "android:default", + "default", json.dumps({"v": 1, "type": "message", "payload": {"n": i}}), ) assert outbox.latest_cursor() == 7 # cursor stays monotonic @@ -2110,7 +2110,7 @@ def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path): in the chat, leaving other messages intact; a thread_id scopes the delete.""" outbox = plugin.outbox.Outbox(tmp_path / "ob.db") try: - chat = "android:default" + chat = "default" # A streaming message spans start/update/stop; a standalone message is # one frame. Plus an unrelated message that must survive. outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}})) @@ -2139,12 +2139,12 @@ def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path): scoped to that thread's frames only.""" outbox = plugin.outbox.Outbox(tmp_path / "ob.db") try: - chan = "android:chan_9" + chan = "chan_9" # Flat-lane frames + two threads' frames, plus an unrelated channel. outbox.append(chan, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "a", "role": "user", "text": "flat"}})) outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "b", "role": "user", "text": "t1"}})) outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_2", "payload": {"message_id": "c", "role": "user", "text": "t2"}})) - outbox.append("android:chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}})) + outbox.append("chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}})) # Thread delete: only t_1's frame goes. assert outbox.delete_lane(chan, thread_id="t_1") == 1 rows = outbox.replay(0) @@ -2164,7 +2164,7 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path): a channel, its threads; the default channel cannot be deleted.""" d = plugin.channels.ChannelDirectory(tmp_path / "ch.db") try: - d.ensure_default("android:default", "Default") + d.ensure_default("default", "Default") chan = d.create("Work", kind="channel") t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"]) # Deleting the channel removes it AND its thread from the directory. @@ -2180,10 +2180,10 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path): assert d.get(t2["chat_id"]) is None assert d.get(chan2["chat_id"]) is not None # parent channel survives # The default channel cannot be deleted. - assert d.delete("android:default") is None - assert d.get("android:default") is not None + assert d.delete("default") is None + assert d.get("default") is not None # Unknown id -> None. - assert d.delete("android:chan_nope") is None + assert d.delete("chan_nope") is None finally: d.close() @@ -2272,14 +2272,14 @@ def test_tool_end_fields_from_hook(plugin): def test_parse_tool_line_or_block_verbose(plugin): a = plugin.adapter content = '🔍 web_search(["query"])\n{"query": "hermes agent"}' - name, preview, args = a.AndroidAdapter._parse_tool_line_or_block( + name, preview, args = a.IrisAdapter._parse_tool_line_or_block( '🔍 web_search(["query"])', content ) assert name == "web_search" assert args == {"query": "hermes agent"} assert preview == "hermes agent" # derived short preview # Non-verbose line -> args None, preview from the line. - name2, preview2, args2 = a.AndroidAdapter._parse_tool_line_or_block( + name2, preview2, args2 = a.IrisAdapter._parse_tool_line_or_block( '🔍 web_search: "x"', '🔍 web_search: "x"' ) assert name2 == "web_search" and preview2 == "x" and args2 is None @@ -2289,11 +2289,11 @@ def test_tool_start_frame_emoji_field(plugin): """``tool.start`` carries the cosmetic emoji when given, omits it when None (the app then falls back to its own default glyph).""" pf = plugin.protocol.tool_start - f = pf("android:default", 3, "terminal", emoji="💻") + f = pf("default", 3, "terminal", emoji="💻") assert f.payload["emoji"] == "💻" - f2 = pf("android:default", 3, "terminal") + f2 = pf("default", 3, "terminal") assert "emoji" not in f2.payload - f3 = pf("android:default", 3, "terminal", emoji=None) + f3 = pf("default", 3, "terminal", emoji=None) assert "emoji" not in f3.payload @@ -2330,7 +2330,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey "agent.display.get_tool_emoji", lambda name, default="⚡": "💻" if name == "terminal" else default, ) - res = await adapter.send('android:default', '💻 terminal: "ls -la"') + res = await adapter.send('default', '💻 terminal: "ls -la"') assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "tool.start") payload = frames[-1]["payload"] @@ -2338,7 +2338,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey assert payload["emoji"] == "💻" # Unknown tool -> field omitted (app falls back to its default glyph). monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default) - res2 = await adapter.send('android:default', '🔧 patch: "x"') + res2 = await adapter.send('default', '🔧 patch: "x"') assert res2.success frames2 = await recv_until( ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch" diff --git a/gateway-plugin/tests/test_android_http.py b/gateway-plugin/tests/test_android_http.py index e55a7b1..055feaa 100644 --- a/gateway-plugin/tests/test_android_http.py +++ b/gateway-plugin/tests/test_android_http.py @@ -1,4 +1,4 @@ -"""Tests for the android plugin's HTTP fallback transport (docs/19). +"""Tests for the iris plugin's HTTP fallback transport (docs/19). The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it from the source tree directly (same pattern as ``test_android.py``). @@ -44,9 +44,9 @@ import pytest_asyncio # Test-only token (not a credential; the adapter is built with it via # monkeypatch in the fixture below). # pi-lens-ignore: S105 -TOKEN = "test-android-http-token-0123456789" +TOKEN = "test-iris-http-token-0123456789" DEVICE_ID = "test-http-device" -CHAT_ID = "android:default" +CHAT_ID = "default" # 1x1 PNG (same fixture as test_android.py). PNG_1X1 = base64.b64decode( @@ -56,7 +56,7 @@ PNG_1X1 = base64.b64decode( def _plugin_dir() -> Path: - env = os.environ.get("ANDROID_PLUGIN_DIR") + env = os.environ.get("IRIS_PLUGIN_DIR") if env: return Path(env) # Works from either copy of this file: gateway-plugin/tests/ (canonical, @@ -72,13 +72,13 @@ def _plugin_dir() -> Path: def _load_plugin(): """Load the gateway-plugin package under a unique module name (same pattern as test_android.py).""" - name = "android_plugin_http_under_test" + name = "iris_plugin_http_under_test" cached = sys.modules.get(name) if cached is not None: return cached pkg_dir = _plugin_dir() if not (pkg_dir / "__init__.py").is_file(): - pytest.fail(f"android plugin not found at {pkg_dir}") + pytest.fail(f"iris plugin not found at {pkg_dir}") spec = importlib.util.spec_from_file_location( name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)] ) @@ -101,14 +101,14 @@ def plugin(): @pytest.fixture def adapter(plugin, monkeypatch): - """A live AndroidAdapter with an isolated HERMES_HOME (conftest).""" - monkeypatch.setenv("ANDROID_TOKEN", TOKEN) + """A live IrisAdapter with an isolated HERMES_HOME (conftest).""" + monkeypatch.setenv("IRIS_TOKEN", TOKEN) from gateway.platform_registry import PlatformEntry, platform_registry - if not platform_registry.is_registered("android"): + if not platform_registry.is_registered("iris"): platform_registry.register( PlatformEntry( - name="android", + name="iris", label="Android", adapter_factory=lambda cfg: None, check_fn=lambda: True, @@ -124,7 +124,7 @@ def adapter(plugin, monkeypatch): }, home_channel=None, ) - a = plugin.adapter.AndroidAdapter(config) + a = plugin.adapter.IrisAdapter(config) yield a with contextlib.suppress(Exception): a._devices.close() diff --git a/gateway-plugin/tests/ws_probe.py b/gateway-plugin/tests/ws_probe.py index 4e1d394..39878d5 100644 --- a/gateway-plugin/tests/ws_probe.py +++ b/gateway-plugin/tests/ws_probe.py @@ -7,13 +7,13 @@ while building the Kotlin client. Usage:: - hermes gateway & # with the android plugin - python gateway-plugin/tests/ws_probe.py --token \ + hermes gateway & # with the iris plugin + python gateway-plugin/tests/ws_probe.py --token \ --send "hello" Options: --url ws://host:port/ws (default ws://127.0.0.1:8790/ws) - --token ANDROID_TOKEN (default: $ANDROID_TOKEN) + --token IRIS_TOKEN (default: $IRIS_TOKEN) --device device_id (default: probe-) --send TEXT send this message after pairing (default: "hello") --upload F M4: upload F (chunked media.upload) and attach it to the @@ -573,7 +573,7 @@ def run_http(args, base: str) -> int: "v": 1, "id": 1, "type": "message.send", - "chat_id": "android:default", + "chat_id": "default", "payload": {"text": args.send}, } conn = HTTPConnection(host, port, timeout=30) @@ -666,8 +666,8 @@ def run_http(args, base: str) -> int: def main() -> int: p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws")) - p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", "")) + p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws")) + p.add_argument("--token", default=os.getenv("IRIS_TOKEN", "")) p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}") p.add_argument("--send", default="hello") p.add_argument( @@ -724,8 +724,8 @@ def main() -> int: ) p.add_argument( "--chat-id", - default="android:default", - help="chat_id for --scope chat (default android:default)", + default="default", + help="chat_id for --scope chat (default default)", ) p.add_argument( "--channel-create", default="", help="M3: create a channel, print its chat_id, exit" @@ -762,7 +762,7 @@ def main() -> int: ) args = p.parse_args() if not args.token and not args.authfail: - p.error("--token (or $ANDROID_TOKEN) is required") + p.error("--token (or $IRIS_TOKEN) is required") if args.assert_read_receipt and not args.send: 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