From 7309158e124c4447792f06f937b345be6e526e0e Mon Sep 17 00:00:00 2001 From: ARIA Date: Sat, 22 Aug 2026 03:32:07 +0200 Subject: [PATCH] Add Gitea CI + release workflows (CI-SETUP.md) - .gitea/workflows/ci.yml: gateway plugin tests + Kotlin host tests on push/PR (hermes-agent cloned at pinned SHA, vendored test mirror) - .gitea/workflows/release.yml: manual dispatch; runs tests, builds signed Android APK (debug fallback) + Linux desktop app-image/deb via jpackage, creates Gitea release v with artifacts (re-run safe) - androidApp: versionName from -PappVersion, CI release signing from ANDROID_KEYSTORE_* env vars - desktopApp: jpackage --app-version from -PappVersion - scripts/make_release_keystore.sh: one-time keystore + Gitea secret setup - vendor gateway-plugin/tests/test_android.py (byte-identical mirror) and ignore it in .pi-lens.json --- .gitea/workflows/ci.yml | 73 + .gitea/workflows/release.yml | 220 +++ .pi-lens.json | 3 + CI-SETUP.md | 501 ++++++ app/androidApp/build.gradle.kts | 25 +- app/desktopApp/build.gradle.kts | 5 +- gateway-plugin/tests/test_android.py | 2278 ++++++++++++++++++++++++++ scripts/make_release_keystore.sh | 38 + 8 files changed, 3141 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 CI-SETUP.md create mode 100644 gateway-plugin/tests/test_android.py create mode 100755 scripts/make_release_keystore.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..1db29ca --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + 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 + + # 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 + uv sync + + - 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 + + 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" + + # 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" + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /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 \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..03c6b1d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,220 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g. 0.2.0)" + required: true + type: string + changelog: + description: "Release notes (markdown, shown on the release page)" + required: false + type: string + +jobs: + 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: 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 + uv sync + + - 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 + + 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" + + - 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" + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "sdk.dir=$ANDROID_HOME" > app/local.properties + + - name: Run host tests + working-directory: app + run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest + + android: + name: Build Android APK + runs-on: ubuntu-latest + needs: [gateway, kotlin] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - 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" + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "sdk.dir=$ANDROID_HOME" > app/local.properties + + - name: Restore release keystore (from Gitea secrets) + env: + KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + if [ -n "$KS_B64" ]; then + echo "$KS_B64" | base64 -d > app/release.keystore + echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV" + echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV" + echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV" + echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV" + echo "Building SIGNED release APK" + else + echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)" + fi + + - name: Build APK + run: | + VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + cd app + if [ -n "$ANDROID_KEYSTORE_FILE" ]; then + ./gradlew :androidApp:assembleRelease -PappVersion="$VERSION" + cp androidApp/build/outputs/apk/release/androidApp-release.apk \ + "$GITHUB_WORKSPACE/iris-android-v$VERSION.apk" + else + ./gradlew :androidApp:assembleDebug -PappVersion="$VERSION" + cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \ + "$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk" + fi + + - uses: actions/upload-artifact@v4 + with: + name: android + path: iris-android-*.apk + + desktop: + name: Build desktop (Linux, jpackage) + runs-on: ubuntu-latest + needs: [gateway, kotlin] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Strip local JDK pin + run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties + + # jpackage cannot cross-compile: this job only produces Linux packages. + # When a Windows / macOS runner exists later, copy this job, change + # runs-on, and drop the -x64-linux suffix (jpackage picks the native + # type: msi on Windows, dmg on macOS). + - name: Build app-image + deb + run: | + VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + cd app + # Self-contained app image (JRE bundled via jlink). + ./gradlew :desktopApp:jpackage -PappVersion="$VERSION" + (cd desktopApp/build/jpackage && zip -qr \ + "$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris) + # .deb package (dpkg-deb ships with Ubuntu). + ./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION" + cp desktopApp/build/jpackage/*.deb \ + "$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb" + + - uses: actions/upload-artifact@v4 + with: + name: desktop + path: | + iris-desktop-linux-x64-*.zip + iris-desktop-linux-x64-*.deb + + release: + name: Create Gitea release + runs-on: ubuntu-latest + needs: [android, desktop] + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create release + upload artifacts + env: + # Optional: create a personal access token (scope: Releases: write) + # and store it as secret GITEA_TOKEN. Without it the workflow uses + # the automatic GITHUB_TOKEN that Gitea Actions provides. + RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" + REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" + TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" + VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH") + TAG="v$VERSION" + API="$SERVER/api/v1/repos/$REPO" + AUTH="Authorization: token $TOKEN" + + # Re-run safety: drop a previous release (and its tag) for this version. + OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') + if [ -n "$OLD_ID" ]; then + curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null + fi + + # Gitea creates the tag at the default branch HEAD automatically. + RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + "$API/releases" \ + -d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \ + '{tag_name:$tag, title:$title, body:$body}')" \ + | jq -r .id) + echo "Created release $TAG (id $RELEASE_ID)" + + for f in artifacts/*/*; do + [ -f "$f" ] || continue + echo "Uploading $(basename "$f")" + curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \ + "$API/releases/$RELEASE_ID/attachments" > /dev/null + done + echo "Done: $SERVER/$REPO/releases/tag/$TAG" \ No newline at end of file diff --git a/.pi-lens.json b/.pi-lens.json index 09a69a2..4cf0925 100644 --- a/.pi-lens.json +++ b/.pi-lens.json @@ -1,4 +1,7 @@ { + "ignore": [ + "gateway-plugin/tests/test_android.py" + ], "rules": { "unchecked-throwing-call-python": { "disable": [ diff --git a/CI-SETUP.md b/CI-SETUP.md new file mode 100644 index 0000000..2ebe35a --- /dev/null +++ b/CI-SETUP.md @@ -0,0 +1,501 @@ +# CI / Release setup — manual edit list + +Everything needed for the Gitea workflows (CI + manual release). Items marked +**DONE** were already applied; the rest are copy-paste instructions. + +--- + +## 1. DONE — no action needed + +- `gateway-plugin/tests/test_android.py` — vendored byte-identical mirror of + `hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout + is the canonical copy; **keep the two in sync** when you change that test). +- `.pi-lens.json` — added `"ignore": ["gateway-plugin/tests/test_android.py"]` + so the scanner doesn't flag the vendored mirror. + +--- + +## 2. NEW FILE: `.gitea/workflows/ci.yml` + +Runs on every push to `master` and on PRs: gateway plugin tests + Kotlin host +tests (android + desktop). + +```yaml +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + 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 + + # 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 + uv sync + + - 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 + + 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" + + # 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" + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /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 +``` + +--- + +## 3. NEW FILE: `.gitea/workflows/release.yml` + +Manual trigger: **repo → Actions → Release → Run workflow**, enter a +`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI, +builds a signed Android APK + the Linux desktop packages (jpackage, JRE +bundled), then creates the Gitea release `v` with all artifacts as +download attachments. + +```yaml +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g. 0.2.0)" + required: true + type: string + changelog: + description: "Release notes (markdown, shown on the release page)" + required: false + type: string + +jobs: + 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: 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 + uv sync + + - 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 + + 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" + + - 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" + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "sdk.dir=$ANDROID_HOME" > app/local.properties + + - name: Run host tests + working-directory: app + run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest + + android: + name: Build Android APK + runs-on: ubuntu-latest + needs: [gateway, kotlin] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - 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" + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "sdk.dir=$ANDROID_HOME" > app/local.properties + + - name: Restore release keystore (from Gitea secrets) + env: + KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + if [ -n "$KS_B64" ]; then + echo "$KS_B64" | base64 -d > app/release.keystore + echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV" + echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV" + echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV" + echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV" + echo "Building SIGNED release APK" + else + echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)" + fi + + - name: Build APK + run: | + VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + cd app + if [ -n "$ANDROID_KEYSTORE_FILE" ]; then + ./gradlew :androidApp:assembleRelease -PappVersion="$VERSION" + cp androidApp/build/outputs/apk/release/androidApp-release.apk \ + "$GITHUB_WORKSPACE/iris-android-v$VERSION.apk" + else + ./gradlew :androidApp:assembleDebug -PappVersion="$VERSION" + cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \ + "$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk" + fi + + - uses: actions/upload-artifact@v4 + with: + name: android + path: iris-android-*.apk + + desktop: + name: Build desktop (Linux, jpackage) + runs-on: ubuntu-latest + needs: [gateway, kotlin] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Strip local JDK pin + run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties + + # jpackage cannot cross-compile: this job only produces Linux packages. + # When a Windows / macOS runner exists later, copy this job, change + # runs-on, and drop the -x64-linux suffix (jpackage picks the native + # type: msi on Windows, dmg on macOS). + - name: Build app-image + deb + run: | + VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + cd app + # Self-contained app image (JRE bundled via jlink). + ./gradlew :desktopApp:jpackage -PappVersion="$VERSION" + (cd desktopApp/build/jpackage && zip -qr \ + "$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris) + # .deb package (dpkg-deb ships with Ubuntu). + ./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION" + cp desktopApp/build/jpackage/*.deb \ + "$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb" + + - uses: actions/upload-artifact@v4 + with: + name: desktop + path: | + iris-desktop-linux-x64-*.zip + iris-desktop-linux-x64-*.deb + + release: + name: Create Gitea release + runs-on: ubuntu-latest + needs: [android, desktop] + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create release + upload artifacts + env: + # Optional: create a personal access token (scope: Releases: write) + # and store it as secret GITEA_TOKEN. Without it the workflow uses + # the automatic GITHUB_TOKEN that Gitea Actions provides. + RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" + REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" + TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" + VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH") + TAG="v$VERSION" + API="$SERVER/api/v1/repos/$REPO" + AUTH="Authorization: token $TOKEN" + + # Re-run safety: drop a previous release (and its tag) for this version. + OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') + if [ -n "$OLD_ID" ]; then + curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null + fi + + # Gitea creates the tag at the default branch HEAD automatically. + RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + "$API/releases" \ + -d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \ + '{tag_name:$tag, title:$title, body:$body}')" \ + | jq -r .id) + echo "Created release $TAG (id $RELEASE_ID)" + + for f in artifacts/*/*; do + [ -f "$f" ] || continue + echo "Uploading $(basename "$f")" + curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \ + "$API/releases/$RELEASE_ID/attachments" > /dev/null + done + echo "Done: $SERVER/$REPO/releases/tag/$TAG" +``` + +--- + +## 4. EDITS to existing Gradle files + +### 4a. `app/androidApp/build.gradle.kts` + +**Change 1** — in `defaultConfig`, replace: + +```kotlin + versionName = "0.1.0" +``` + +with: + +```kotlin + // CI passes -PappVersion= (release workflow); local builds + // keep the default. + versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0" +``` + +**Change 2** — inside the `android { }` block, right after the `buildTypes { }` +block, add: + +```kotlin + // CI release signing: .gitea/workflows/release.yml restores a keystore + // from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see + // scripts/make_release_keystore.sh). Without them the release build stays + // unsigned and the workflow falls back to a debug APK. + val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE") + if (ciKeystore != null) { + signingConfigs { + create("release") { + storeFile = file(ciKeystore) + storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } + } +``` + +### 4b. `app/desktopApp/build.gradle.kts` + +**Change 1** — near the top (after `val arch = ...`), add: + +```kotlin +// CI passes -PappVersion= (release workflow); local builds keep the +// default. jpackage requires a plain semver (no leading "v"). +val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0" +``` + +**Change 2** — in the `jpackage` Exec task's `commandLine(...)`, replace: + +```kotlin + "--app-version", "0.1.0", +``` + +with: + +```kotlin + "--app-version", appVersion, +``` + +--- + +## 5. NEW FILE: `scripts/make_release_keystore.sh` (Android signing, one-time) + +This is the noob-friendly signing setup. Run it **once** on your machine: + +```bash +scripts/make_release_keystore.sh +``` + +It generates a keystore with random passwords and prints the four values to +paste into Gitea. Then create the secrets in +**Gitea → repo → Settings → Actions → Secrets**: + +| Secret | Value | +| --- | --- | +| `ANDROID_KEYSTORE_BASE64` | the long base64 blob the script prints | +| `ANDROID_KEYSTORE_PASSWORD` | printed by the script | +| `ANDROID_KEY_ALIAS` | `iris` | +| `ANDROID_KEY_PASSWORD` | printed by the script | + +Until the secrets exist, the release workflow still works but ships a +**debug** APK (installable, but not suitable for updates). + +```bash +#!/usr/bin/env bash +# One-time setup: create the Android release keystore and print the values to +# paste into Gitea (repo -> Settings -> Actions -> Secrets). +# +# Usage: scripts/make_release_keystore.sh [output-file] +# (default: ~/iris-release.keystore) +# +# WARNING: back up the keystore file immediately. If it is lost, the app can +# never be updated on users' phones (a new key = a brand-new app as far as +# Android is concerned). +set -euo pipefail + +OUT="${1:-$HOME/iris-release.keystore}" +if [ -e "$OUT" ]; then + echo "Refusing to overwrite existing file: $OUT" >&2 + exit 1 +fi + +STORE_PASS="$(openssl rand -base64 18 | tr -d '/+=')" +KEY_PASS="$(openssl rand -base64 18 | tr -d '/+=')" + +keytool -genkeypair -v \ + -keystore "$OUT" -storetype PKCS12 \ + -alias iris -keyalg RSA -keysize 2048 -validity 10000 \ + -storepass "$STORE_PASS" -keypass "$KEY_PASS" \ + -dname "CN=Iris Release, OU=Mobile, O=Iris, C=DE" + +echo +echo "Keystore written to: $OUT" +echo ">>> Back it up NOW (password manager / cloud storage). <<<" +echo +echo "Add these four secrets in Gitea (repo -> Settings -> Actions -> Secrets):" +echo +echo " ANDROID_KEYSTORE_BASE64 = $(base64 -w0 "$OUT")" +echo +echo " ANDROID_KEYSTORE_PASSWORD = $STORE_PASS" +echo " ANDROID_KEY_ALIAS = iris" +echo " ANDROID_KEY_PASSWORD = $KEY_PASS" +``` + +Don't forget: `chmod +x scripts/make_release_keystore.sh` + +--- + +## 6. Prerequisites / checks before the first run + +1. **act_runner must be registered** on gitea.zephyre.one with the label + `ubuntu-latest` (that's what both workflows request). Check under + Gitea → (your user or the org) → Actions → Runners. +2. The runner needs internet access (GitHub, Google, Maven Central, Gradle). +3. No Gitea secrets are *required* — but add the four keystore secrets from + §5 for a signed APK, and optionally a `GITEA_TOKEN` (Releases: write) if + the automatic `GITHUB_TOKEN` doesn't have release permission. + +## 7. Using it + +- **CI**: pushes to `master` / PRs run automatically. +- **Release**: repo → **Actions** → *Release* → **Run workflow** → enter + `version` (e.g. `0.2.0`) + `changelog` → start. The release appears at + `https://gitea.zephyre.one/ARIA/iris_x_hermes/releases/tag/v0.2.0` with: + - `iris-android-v0.2.0.apk` (signed, or `-debug` without keystore secrets) + - `iris-desktop-linux-x64-v0.2.0.zip` (app image, JRE bundled) + - `iris-desktop-linux-x64-v0.2.0.deb` +- Re-running with the same version **replaces** the old release (old tag + + attachments are deleted first). + +## 8. Later: Windows / macOS desktop builds + +When the Windows VM / MacBook runner exists: + +1. Register act_runner on that machine (labels e.g. `windows-latest`, + `macos-latest`). +2. In `release.yml`, copy the `desktop` job, change `runs-on`, and adjust the + artifact names (`iris-desktop-windows-x64-…`, `iris-desktop-macos-…`). + jpackage then produces `msi`/`dmg` natively — no other changes needed. +3. Bump the pinned hermes-agent SHA in both workflows whenever you update the + local `hermes-agent/` checkout (`git -C hermes-agent rev-parse HEAD`). diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index 6c1efc9..5aa5b4d 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -14,7 +14,9 @@ android { minSdk = 29 targetSdk = 34 versionCode = 1 - versionName = "0.1.0" + // CI passes -PappVersion= (release workflow); local builds + // keep the default. + versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0" } buildTypes { @@ -23,6 +25,27 @@ android { } } + // CI release signing: .gitea/workflows/release.yml restores a keystore + // from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see + // scripts/make_release_keystore.sh). Without them the release build stays + // unsigned and the workflow falls back to a debug APK. + val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE") + if (ciKeystore != null) { + signingConfigs { + create("release") { + storeFile = file(ciKeystore) + storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/app/desktopApp/build.gradle.kts b/app/desktopApp/build.gradle.kts index 622e436..4fb1573 100644 --- a/app/desktopApp/build.gradle.kts +++ b/app/desktopApp/build.gradle.kts @@ -9,6 +9,9 @@ plugins { val composeVersion = "1.11.1" val os = OperatingSystem.current() val arch = System.getProperty("os.arch") ?: "amd64" +// CI passes -PappVersion= (release workflow); local builds keep the +// default. jpackage requires a plain semver (no leading "v"). +val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0" val desktopTarget = when { os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64" os.isWindows -> "windows-x64" @@ -79,7 +82,7 @@ tasks.register("jpackage") { commandLine( if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage", "--name", "iris", - "--app-version", "0.1.0", + "--app-version", appVersion, "--vendor", "Iris", "--type", type, "--input", inputDir.absolutePath, diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py new file mode 100644 index 0000000..99c4c43 --- /dev/null +++ b/gateway-plugin/tests/test_android.py @@ -0,0 +1,2278 @@ +"""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 +from the source tree directly so they never depend on that install. + +Coverage (docs/13-testing.md §13.1, media bullets): + * upload start -> binary chunks -> end reassembles + sha256 verified + * over-limit (declared and mid-stream) -> ``media_too_large`` + * sha256 mismatch -> ``internal`` + * ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the + ``MessageEvent`` carries ``media_urls``/``media_types`` + * ``send_*`` -> ``media.offer`` (fields + message association) + * ``media.pull`` serves only allowed paths (denied/unknown -> ``not_found``) + * kind re-sniffing (don't trust the client) + +Run via ``scripts/run_tests.sh tests/gateway/test_android.py``. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import importlib.util +import json +import os +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio + +# 1x1 red PNG (67 bytes) -- passes hermes' image magic-byte check. +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" + "AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" +) + +TOKEN = "test-android-token-0123456789" +DEVICE_ID = "test-device" + + +def _plugin_dir() -> Path: + env = os.environ.get("ANDROID_PLUGIN_DIR") + if env: + return Path(env) + # hermes-agent/tests/gateway/test_android.py -> repo root is parents[3]. + return Path(__file__).resolve().parents[3] / "gateway-plugin" + + +def _load_plugin(): + """Load the gateway-plugin package under a unique module name. + + The plugin uses relative imports (``from . import protocol``), so it + must be imported as a package (``submodule_search_locations``). + """ + name = "android_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}") + spec = importlib.util.spec_from_file_location( + name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)] + ) + if spec is None or spec.loader is None: + pytest.fail(f"could not build import spec for {pkg_dir}") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(name, None) + raise + return module + + +@pytest.fixture(scope="module") +def plugin(): + return _load_plugin() + + +@pytest.fixture +def adapter(plugin, monkeypatch): + """A live AndroidAdapter with an isolated HERMES_HOME (conftest).""" + monkeypatch.setenv("ANDROID_TOKEN", TOKEN) + from gateway.platform_registry import PlatformEntry, platform_registry + + # Platform("android") resolves only once the platform is registered + # (the plugin's register(ctx) does this in production). + if not platform_registry.is_registered("android"): + platform_registry.register( + PlatformEntry( + name="android", + label="Android", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + ) + ) + + config = SimpleNamespace( + extra={ + "host": "127.0.0.1", + "port": 0, # ephemeral port + "max_upload_bytes": 1024 * 1024, # 1 MiB -- keeps over-limit tests fast + }, + home_channel=None, + ) + a = plugin.adapter.AndroidAdapter(config) + yield a + try: + a._devices.close() + except Exception: + pass + try: + a._outbox.close() + except Exception: + pass + + +async def _hello(ws) -> dict: + await ws.send( + json.dumps( + { + "v": 1, + "type": "hello", + "payload": { + "token": TOKEN, + "device_id": DEVICE_ID, + "device_name": "Test Device", + "caps": {}, + }, + } + ) + ) + raw = await asyncio.wait_for(ws.recv(), timeout=5) + ack = json.loads(raw) + assert ack["type"] == "hello.ack", f"expected hello.ack, got {ack}" + return ack + + +@pytest_asyncio.fixture +async def ws_client(adapter): + """Connected + paired WS client; the adapter's server runs on an + ephemeral port for the duration of the test.""" + from websockets.asyncio.client import connect + + await adapter.connect() + try: + port = adapter._ws_server._server.sockets[0].getsockname()[1] + async with connect( + f"ws://127.0.0.1:{port}/ws", max_size=4 * 1024 * 1024 + ) as ws: + ack = await _hello(ws) + yield ws, ack + finally: + await adapter.disconnect() + + +async def recv_until(ws, predicate, timeout: float = 10.0) -> list: + """Collect frames (dicts; binary frames as ("binary", bytes)) until + *predicate* matches a JSON frame. Returns all frames collected.""" + frames: list = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise AssertionError( + "timed out waiting for frame; got: " + + ", ".join(f.get("type", "?") if isinstance(f, dict) else "binary" for f in frames) + ) + raw = await asyncio.wait_for(ws.recv(), timeout=remaining) + if isinstance(raw, (bytes, bytearray)): + frames.append(("binary", bytes(raw))) + continue + frame = json.loads(raw) + frames.append(frame) + if predicate(frame): + return frames + + +async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image", + mime: str = "image/png", filename: str = "t.png", + request_id: int = 1) -> dict: + """Drive a full media.upload flow; returns the terminal frame (ack or error).""" + await ws.send( + json.dumps( + { + "v": 1, + "id": request_id, + "type": "media.upload.start", + "payload": { + "media_ref": media_ref, + "kind": kind, + "mime": mime, + "size": len(data), + "filename": filename, + }, + } + ) + ) + # Two chunks to exercise reassembly. + half = len(data) // 2 + await ws.send(data[:half]) + await ws.send(data[half:]) + await ws.send( + json.dumps( + { + "v": 1, + "id": request_id + 1, + "type": "media.upload.end", + "payload": { + "media_ref": media_ref, + "sha256": hashlib.sha256(data).hexdigest(), + }, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") in ("media.upload.ack", "error")) + return frames[-1] + + +# ── Pure helpers ──────────────────────────────────────────────────────────── + + +def test_reclassify_kind_does_not_trust_client(plugin): + media = plugin.media + # Image magic wins over a lying "document" claim. + assert media.reclassify_kind(PNG_1X1, "document") == "image" + # Ogg audio claimed as document. + ogg = b"OggS" + b"\x00" * 16 + assert media.reclassify_kind(ogg, "document") == "audio" + # Voice stays voice when the bytes are audio. + assert media.reclassify_kind(ogg, "voice") == "voice" + # m4a brand claimed as video -> audio. + m4a = b"\x00\x00\x00\x20ftypm4a " + b"\x00" * 16 + assert media.reclassify_kind(m4a, "video") == "audio" + # Generic mp4 (isom) claimed as audio -> video. + mp4 = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 16 + assert media.reclassify_kind(mp4, "audio") == "video" + # No magic: keep declared video, else document. + assert media.reclassify_kind(b"hello world", "video") == "video" + assert media.reclassify_kind(b"hello world", "document") == "document" + + +def test_mime_and_ext_helpers(plugin): + media = plugin.media + assert media.mime_for_path("/x/a.PNG") == "image/png" + assert media.mime_for_path("/x/clip.MP4") == "video/mp4" + assert media.mime_for_path("/x/a.unknownext") == "application/octet-stream" + assert media.ext_for_mime("image/png", "x.jpg", ".jpg") == ".png" + assert media.ext_for_mime("application/pdf", "report.pdf", ".bin") == ".pdf" + assert media.ext_for_mime("application/x-unknown", "noext", ".bin") == ".bin" + + +# ── Runtime-metadata footer (app-controlled display) ──────────────────────── + + +def test_runtime_footer_helper_omits_absent_fields(plugin): + pf = plugin.protocol.runtime_footer + # All absent -> empty dict (the app shows nothing). + assert pf() == {} + # Only fields with data are included. + d = pf(model="gpt-5.4", context_pct=38, cwd="~", latency=22.5, cost=0.0012) + assert d == { + "model": "gpt-5.4", + "context_pct": 38, + "cwd": "~", + "latency": 22.5, + "cost": 0.0012, + } + # context_pct clamped to 0-100. + assert pf(context_pct=150)["context_pct"] == 100 + assert pf(context_pct=-5)["context_pct"] == 0 + # cost omitted when 0 (local/free models); latency omitted when negative. + assert "cost" not in pf(cost=0.0) + assert "latency" not in pf(latency=-1.0) + + +def test_home_relative_cwd_collapses_home(plugin): + f = plugin.adapter._home_relative_cwd + home = os.path.expanduser("~") + assert f(home) == "~" + assert f(os.path.join(home, "proj", "src")) == "~/proj/src" + assert f("") == "" + # A path outside the home is returned absolute (unmodified). + assert f("/etc") == "/etc" + + +@pytest.mark.asyncio +async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client, monkeypatch): + ws, _ = ws_client + # Simulate the post_api_request hook capturing the turn's model + tokens. + plugin.adapter._on_post_api_request( + platform="android", + 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}) + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "message") + msg = frames[-1] + runtime = msg["payload"].get("runtime") + assert runtime is not None + assert runtime["model"] == "gpt-5.4" # vendor prefix dropped + assert runtime["context_pct"] == round(12345 / 32768 * 100) # 38 + 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}) + assert res2.success + frames2 = await recv_until( + ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again" + ) + runtime2 = frames2[-1]["payload"].get("runtime") + # cwd is env-derived (always present); model/tokens are turn-scoped (gone). + assert runtime2 is not None + assert "model" not in runtime2 + assert "context_pct" not in runtime2 + + +@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. + plugin.adapter._on_post_api_request( + platform="telegram", + model="openai/gpt-5.4", + usage={"prompt_tokens": 999}, + ) + 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}) + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "message") + runtime = frames[-1]["payload"].get("runtime") + assert runtime is not None + assert "model" not in runtime # the telegram turn's model was not captured + + +@pytest.mark.asyncio +async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch): + ws, _ = ws_client + plugin.adapter._on_post_api_request( + platform="android", + 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}) + # The outbox history reconstruction must carry the runtime object. + page = adapter._outbox.history("android:default", limit=50) + assert len(page["messages"]) == 1 + m = page["messages"][0] + assert m.get("runtime", {}).get("model") == "gpt-5.4" + assert m["runtime"]["context_pct"] == round(12345 / 32768 * 100) + + +# ── Inbound upload ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_upload_reassembles_verifies_and_caches(adapter, ws_client): + ws, ack = ws_client + assert ack["payload"]["server_caps"]["media"] is True + + terminal = await upload_file(ws, "mu_t1", PNG_1X1) + assert terminal["type"] == "media.upload.ack", terminal + assert terminal["id"] == 2 + assert terminal["payload"]["ok"] is True + assert terminal["payload"]["media_ref"] == "mu_t1" + + entry = adapter._media.get_inbound("mu_t1") + assert entry is not None + assert entry.kind == "image" + assert entry.mime == "image/png" + assert entry.size == len(PNG_1X1) + assert os.path.isfile(entry.path) + assert Path(entry.path).read_bytes() == PNG_1X1 + # Cached under the hermes image cache root (profile-aware). + from hermes_constants import get_hermes_home + + assert str(get_hermes_home() / "cache" / "images") in entry.path + + +@pytest.mark.asyncio +async def test_upload_declared_over_limit_rejected(adapter, ws_client): + ws, _ = ws_client + limit = adapter.max_upload_bytes + await ws.send( + json.dumps( + { + "v": 1, + "id": 1, + "type": "media.upload.start", + "payload": { + "media_ref": "mu_big", + "kind": "document", + "mime": "application/pdf", + "size": limit + 1, + "filename": "big.pdf", + }, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error") + err = frames[-1] + assert err["payload"]["code"] == "media_too_large" + assert err["id"] == 1 + + +@pytest.mark.asyncio +async def test_upload_midstream_over_limit_rejected(adapter, ws_client): + ws, _ = ws_client + limit = adapter.max_upload_bytes + await ws.send( + json.dumps( + { + "v": 1, + "id": 1, + "type": "media.upload.start", + "payload": { + "media_ref": "mu_mid", + "kind": "document", + "mime": "application/octet-stream", + "size": limit, + "filename": "mid.bin", + }, + } + ) + ) + # Declared size passes the start check; the stream exceeds it. + await ws.send(b"x" * (limit // 2)) + await ws.send(b"x" * (limit // 2 + 1)) + frames = await recv_until(ws, lambda f: f.get("type") == "error") + assert frames[-1]["payload"]["code"] == "media_too_large" + # The session is discarded: a late end cannot complete it. + await ws.send( + json.dumps( + { + "v": 1, + "id": 2, + "type": "media.upload.end", + "payload": {"media_ref": "mu_mid", "sha256": "0" * 64}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2) + assert frames[-1]["payload"]["code"] == "not_found" + + +@pytest.mark.asyncio +async def test_upload_sha256_mismatch_rejected(adapter, ws_client): + ws, _ = ws_client + await ws.send( + json.dumps( + { + "v": 1, + "id": 1, + "type": "media.upload.start", + "payload": { + "media_ref": "mu_bad", + "kind": "image", + "mime": "image/png", + "size": len(PNG_1X1), + "filename": "bad.png", + }, + } + ) + ) + await ws.send(PNG_1X1) + await ws.send( + json.dumps( + { + "v": 1, + "id": 2, + "type": "media.upload.end", + "payload": {"media_ref": "mu_bad", "sha256": "0" * 64}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error") + assert frames[-1]["payload"]["code"] == "internal" + assert adapter._media.get_inbound("mu_bad") is None + + +@pytest.mark.asyncio +async def test_upload_duplicate_ref_rejected(adapter, ws_client): + ws, _ = ws_client + first = await upload_file(ws, "mu_dup", PNG_1X1) + assert first["type"] == "media.upload.ack" + second = await upload_file(ws, "mu_dup", PNG_1X1, request_id=10) + assert second["type"] == "error" + assert second["payload"]["code"] == "unsupported" + + +@pytest.mark.asyncio +async def test_upload_non_image_bytes_reclassified_not_cached_as_image(adapter, ws_client): + ws, _ = ws_client + # Lies about being a PNG: the magic-byte re-sniff must keep it out of the + # image cache (it lands as a document instead). + payload = b"not an image" + terminal = await upload_file( + ws, "mu_liar", payload, kind="image", mime="image/png", filename="liar.html" + ) + assert terminal["type"] == "media.upload.ack", terminal + entry = adapter._media.get_inbound("mu_liar") + assert entry is not None + assert entry.kind == "document" + from hermes_constants import get_hermes_home + + assert str(get_hermes_home() / "cache" / "images") not in entry.path + + +# ── Inbound message.send with media_refs ──────────────────────────────────── + + +@pytest.mark.asyncio +async def test_message_send_with_media_refs(adapter, ws_client): + ws, _ = ws_client + from gateway.platforms.base import MessageType + + terminal = await upload_file(ws, "mu_msg", PNG_1X1) + assert terminal["type"] == "media.upload.ack" + + captured: list = [] + adapter.handle_message = AsyncMock(side_effect=lambda event: captured.append(event)) + + await ws.send( + json.dumps( + { + "v": 1, + "id": 5, + "type": "message.send", + "payload": {"text": "look at this", "media_refs": ["mu_msg"]}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + echo = frames[-1] + media = echo["payload"]["media"] + assert len(media) == 1 + assert media[0]["media_id"] == "mu_msg" + assert media[0]["kind"] == "image" + assert media[0]["mime"] == "image/png" + assert media[0]["size"] == len(PNG_1X1) + + assert len(captured) == 1 + event = captured[0] + assert event.message_type == MessageType.PHOTO + assert len(event.media_urls) == 1 + assert os.path.isfile(event.media_urls[0]) + assert event.media_types == ["image/png"] + + # Refs are single-use: a second send with the same ref is rejected. + await ws.send( + json.dumps( + { + "v": 1, + "id": 6, + "type": "message.send", + "payload": {"text": "again", "media_refs": ["mu_msg"]}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 6) + assert frames[-1]["payload"]["code"] == "unsupported" + + +@pytest.mark.asyncio +async def test_message_send_media_only_allowed(adapter, ws_client): + ws, _ = ws_client + terminal = await upload_file(ws, "mu_only", PNG_1X1) + assert terminal["type"] == "media.upload.ack" + captured: list = [] + adapter.handle_message = AsyncMock(side_effect=lambda event: captured.append(event)) + await ws.send( + json.dumps( + { + "v": 1, + "id": 7, + "type": "message.send", + "payload": {"text": "", "media_refs": ["mu_only"]}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + assert frames[-1]["payload"]["text"] == "" + assert len(captured) == 1 + assert captured[0].message_type.name == "PHOTO" + + +# ── Auto-threading (message.send auto_thread, docs/06 §6.3) ──────────────── + + +@pytest.mark.asyncio +async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client, monkeypatch): + """auto_thread in a channel's flat lane mints a fresh thread named from + the user's opening message (derived title); the echo and the agent event + carry the new thread_id.""" + ws, _ = ws_client + import agent.title_generator as tg + + monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None) + captured: list = [] + adapter.handle_message = AsyncMock(side_effect=lambda event: captured.append(event)) + + await ws.send( + json.dumps( + { + "v": 1, + "id": 40, + "type": "message.send", + "chat_id": "android:default", + "payload": {"text": "fix the login bug please", "auto_thread": True}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + 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"]["name"] == "fix the login bug please" + thread_id = created["payload"]["chat_id"] + + echo = frames[-1] + assert echo["chat_id"] == "android:default" + assert echo["thread_id"] == thread_id + + assert len(captured) == 1 + assert captured[0].source.chat_id == "android:default" + assert captured[0].source.thread_id == thread_id + + +@pytest.mark.asyncio +async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client, monkeypatch): + """Stage 2 of the two-stage titling: the background LLM call renames the + auto-created thread and broadcasts channel.renamed.""" + ws, _ = ws_client + import agent.title_generator as tg + + monkeypatch.setattr(tg, "generate_title", lambda text, **kw: "Fix login bug") + adapter.handle_message = AsyncMock() + + await ws.send( + json.dumps( + { + "v": 1, + "id": 41, + "type": "message.send", + "chat_id": "android:default", + "payload": {"text": "fix the login bug please", "auto_thread": True}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed") + renamed = frames[-1] + assert renamed["payload"]["kind"] == "thread" + assert renamed["payload"]["name"] == "Fix login bug" + assert "auto" not in renamed["payload"] + + +@pytest.mark.asyncio +async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws_client, monkeypatch): + """auto_thread is a no-op when the message already targets a thread.""" + ws, _ = ws_client + import agent.title_generator as tg + + monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None) + adapter.handle_message = AsyncMock() + + await ws.send( + json.dumps( + { + "v": 1, + "id": 42, + "type": "message.send", + "chat_id": "android:default", + "thread_id": "t_9", + "payload": {"text": "follow up", "auto_thread": True}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + assert not any(f.get("type") == "channel.created" for f in frames) + assert frames[-1]["thread_id"] == "t_9" + + +@pytest.mark.asyncio +async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_client, monkeypatch): + """Slash commands are session-scoped, not conversation starters: no + thread is minted for them.""" + ws, _ = ws_client + import agent.title_generator as tg + + monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None) + adapter.handle_message = AsyncMock() + + await ws.send( + json.dumps( + { + "v": 1, + "id": 43, + "type": "message.send", + "chat_id": "android:default", + "payload": {"text": "/new", "auto_thread": True}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + assert not any(f.get("type") == "channel.created" for f in frames) + assert frames[-1].get("thread_id") is None + + +@pytest.mark.asyncio +async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client, monkeypatch): + """Media-only sends have no text to title from: they stay in the flat + lane (no nameless threads).""" + ws, _ = ws_client + import agent.title_generator as tg + + monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None) + terminal = await upload_file(ws, "mu_flat", PNG_1X1) + assert terminal["type"] == "media.upload.ack" + adapter.handle_message = AsyncMock() + + await ws.send( + json.dumps( + { + "v": 1, + "id": 44, + "type": "message.send", + "chat_id": "android:default", + "payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + assert not any(f.get("type") == "channel.created" for f in frames) + assert frames[-1].get("thread_id") is None + + +# ── History / outbox persistence (restart restore) ───────────────────────── + + +@pytest.mark.asyncio +async def test_user_echo_parked_in_outbox(adapter, ws_client): + """The user-message echo must be appended to the outbox (not just + broadcast) so it survives a process death and is restored by sync / + history. Regression: echoes were a bare broadcast, so user messages + vanished on reconnect while bot messages survived.""" + ws, _ = ws_client + adapter.handle_message = AsyncMock() + await ws.send( + json.dumps( + { + "v": 1, + "id": 20, + "type": "message.send", + "chat_id": "android:default", + "payload": {"text": "persist me"}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + 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) + 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) + assert parked["role"] == "user" + assert parked["text"] == "persist me" + + +@pytest.mark.asyncio +async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_client): + """A ``history`` request reconstructs the final messages for a chat + (user echoes + assistant finals, streaming or not) in chronological + order, skipping intermediate streaming/tool frames.""" + ws, _ = ws_client + protocol = plugin.protocol + adapter.handle_message = AsyncMock() + chat_id = "android:default" + # Two user turns, each with a (non-streaming) assistant final. + for i, text in enumerate(["one", "two"]): + await ws.send( + json.dumps( + { + "v": 1, + "id": 30 + i, + "type": "message.send", + "chat_id": chat_id, + "payload": {"text": text}, + } + ) + ) + await recv_until( + ws, + lambda f, t=text: f.get("type") == "message" + and f.get("payload", {}).get("role") == "user" + and f.get("payload", {}).get("text") == t, + ) + # Park a final assistant message for the turn. + await adapter._broadcast_or_log( + chat_id, + protocol.message( + chat_id=chat_id, + message_id=f"asst_{i}", + role=protocol.ROLE_ASSISTANT, + text=f"reply {text}", + ts=1_000_000_000_000 + i, + ), + ) + page = adapter._outbox.history(chat_id, limit=50) + roles = [m["role"] for m in page["messages"]] + texts = [m["text"] for m in page["messages"]] + # Chronological: user one, asst one, user two, asst two. + assert roles == ["user", "assistant", "user", "assistant"] + assert texts == ["one", "reply one", "two", "reply two"] + assert page["has_more"] is False + assert page["oldest_message_id"] is not None + + +@pytest.mark.asyncio +async def test_history_paginates_older_pages(plugin, adapter, ws_client): + """``before_message_id`` returns the page of messages older than the + anchor; ``has_more`` signals whether older pages exist.""" + ws, _ = ws_client + protocol = plugin.protocol + adapter.handle_message = AsyncMock() + chat_id = "android:default" + for i in range(5): + await adapter._broadcast_or_log( + chat_id, + protocol.message( + chat_id=chat_id, + message_id=f"m_{i}", + role=protocol.ROLE_USER if i % 2 == 0 else protocol.ROLE_ASSISTANT, + text=f"msg {i}", + ts=1_000_000_000_000 + i, + ), + ) + newest = adapter._outbox.history(chat_id, limit=2) + assert [m["message_id"] for m in newest["messages"]] == ["m_3", "m_4"] + assert newest["has_more"] is True + older = adapter._outbox.history(chat_id, before_message_id=newest["oldest_message_id"], limit=2) + assert [m["message_id"] for m in older["messages"]] == ["m_1", "m_2"] + assert older["has_more"] is True + oldest = adapter._outbox.history(chat_id, before_message_id=older["oldest_message_id"], limit=2) + assert [m["message_id"] for m in oldest["messages"]] == ["m_0"] + assert oldest["has_more"] is False + + +@pytest.mark.asyncio +async def test_history_frame_roundtrip(plugin, adapter, ws_client): + """The ``history`` request/response frame round-trips over the wire: the + server answers a ``history`` request with a paged ``history`` frame.""" + ws, _ = ws_client + protocol = plugin.protocol + adapter.handle_message = AsyncMock() + chat_id = "android:default" + await adapter._broadcast_or_log( + chat_id, + protocol.message( + chat_id=chat_id, + message_id="wire_1", + role=protocol.ROLE_USER, + text="over the wire", + ts=1_000_000_000_000, + ), + ) + await ws.send( + json.dumps( + { + "v": 1, + "id": 40, + "type": "history", + "chat_id": chat_id, + "payload": {"limit": 10}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "history" and f.get("id") == 40) + resp = frames[-1] + assert resp["chat_id"] == chat_id + messages = resp["payload"]["messages"] + assert any(m["message_id"] == "wire_1" for m in messages) + assert "has_more" in resp["payload"] + + +# ── Message deletion ──────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter, ws_client): + """A ``message.delete`` request removes the message from the outbox (so + ``history`` no longer returns it) and broadcasts ``message.deleted`` to + every device (the response carries the request id).""" + ws, _ = ws_client + protocol = plugin.protocol + adapter.handle_message = AsyncMock() + chat_id = "android:default" + await adapter._broadcast_or_log( + chat_id, + protocol.message( + chat_id=chat_id, + message_id="del_1", + role=protocol.ROLE_USER, + text="delete me", + ts=1_000_000_000_000, + ), + ) + page = adapter._outbox.history(chat_id, limit=50) + assert any(m["message_id"] == "del_1" for m in page["messages"]) + await ws.send( + json.dumps( + { + "v": 1, + "id": 50, + "type": "message.delete", + "chat_id": chat_id, + "payload": {"message_ids": ["del_1"]}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 50) + resp = frames[-1] + assert resp["chat_id"] == chat_id + assert resp["payload"]["message_ids"] == ["del_1"] + # The message is gone from the outbox: history no longer returns it. + page = adapter._outbox.history(chat_id, limit=50) + assert not any(m["message_id"] == "del_1" for m in page["messages"]) + + +@pytest.mark.asyncio +async def test_message_delete_idempotent(plugin, adapter, ws_client): + """Deleting a message that is already gone (unknown / pruned) is a no-op + on the outbox but still emits ``message.deleted`` so live caches drop it.""" + ws, _ = ws_client + adapter.handle_message = AsyncMock() + chat_id = "android:default" + await ws.send( + json.dumps( + { + "v": 1, + "id": 51, + "type": "message.delete", + "chat_id": chat_id, + "payload": {"message_ids": ["never_existed"]}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 51) + assert frames[-1]["payload"]["message_ids"] == ["never_existed"] + + +@pytest.mark.asyncio +async def test_message_delete_requires_message_ids(adapter, ws_client): + """A ``message.delete`` with no message_ids is rejected with ``unsupported``.""" + ws, _ = ws_client + await ws.send( + json.dumps( + { + "v": 1, + "id": 52, + "type": "message.delete", + "chat_id": "android:default", + "payload": {}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 52) + assert frames[-1]["payload"]["code"] == "unsupported" + + +def _make_state_db(path: Path) -> None: + """Create a minimal hermes session store (sessions + messages) at *path*.""" + import sqlite3 + + conn = sqlite3.connect(str(path)) + try: + conn.execute( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, chat_id TEXT, thread_id TEXT)" + ) + conn.execute( + "CREATE TABLE messages (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, " + "role TEXT, content TEXT, timestamp REAL)" + ) + conn.commit() + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_message_delete_purges_session_store(plugin, adapter, ws_client): + """A ``message.delete`` also removes the message from the hermes session + store (state.db), so no search trace survives and it is not recoverable.""" + ws, _ = ws_client + protocol = plugin.protocol + adapter.handle_message = AsyncMock() + from hermes_constants import get_hermes_home + + chat_id = "android:default" + db = get_hermes_home() / "state.db" + _make_state_db(db) + import sqlite3 + + conn = sqlite3.connect(str(db)) + try: + conn.execute( + "INSERT INTO sessions (id, chat_id, thread_id) VALUES ('s1', ?, NULL)", + (chat_id,), + ) + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES ('s1', 'user', 'delete me', 1000.0)" + ) + conn.commit() + finally: + conn.close() + # The outbox frame carries the same text + ts so the session row matches. + await adapter._broadcast_or_log( + chat_id, + protocol.message( + chat_id=chat_id, + message_id="purge_1", + role=protocol.ROLE_USER, + text="delete me", + ts=1_000_000, + ), + ) + await ws.send( + json.dumps( + { + "v": 1, + "id": 53, + "type": "message.delete", + "chat_id": chat_id, + "payload": {"message_ids": ["purge_1"]}, + } + ) + ) + await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 53) + conn = sqlite3.connect(str(db)) + try: + n = conn.execute("SELECT COUNT(*) FROM messages WHERE content = 'delete me'").fetchone()[0] + finally: + conn.close() + assert n == 0 # purged from the session store (no search trace) + + +@pytest.mark.asyncio +async def test_channel_delete_wipes_session_store(plugin, adapter, ws_client): + """A ``channel.delete`` wipes the lane's messages + session from the hermes + session store (state.db) as well as the outbox, so nothing is recoverable.""" + ws, _ = ws_client + protocol = plugin.protocol + adapter.handle_message = AsyncMock() + from hermes_constants import get_hermes_home + + # Create a channel via the wire. + await ws.send(json.dumps({"v": 1, "id": 60, "type": "channel.create", "payload": {"name": "Secret"}})) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.created" and f.get("id") == 60) + chat_id = frames[-1]["payload"]["chat_id"] + db = get_hermes_home() / "state.db" + _make_state_db(db) + import sqlite3 + + conn = sqlite3.connect(str(db)) + try: + conn.execute( + "INSERT INTO sessions (id, chat_id, thread_id) VALUES ('s9', ?, NULL)", + (chat_id,), + ) + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES ('s9', 'user', 'top secret', 2000.0)" + ) + conn.commit() + finally: + conn.close() + await adapter._broadcast_or_log( + chat_id, + protocol.message( + chat_id=chat_id, + message_id="chan_1", + role=protocol.ROLE_USER, + text="top secret", + ts=2_000_000, + ), + ) + await ws.send( + json.dumps({"v": 1, "id": 61, "type": "channel.delete", "chat_id": chat_id, "payload": {}}) + ) + await recv_until(ws, lambda f: f.get("type") == "notification" and f["payload"]["kind"] == "channel_deleted") + # Outbox is wiped for the channel. + page = adapter._outbox.history(chat_id, limit=50) + assert not any(m["message_id"] == "chan_1" for m in page["messages"]) + # Session store is wiped: the message and its session are gone. + conn = sqlite3.connect(str(db)) + try: + n_msgs = conn.execute("SELECT COUNT(*) FROM messages WHERE content = 'top secret'").fetchone()[0] + n_sess = conn.execute("SELECT COUNT(*) FROM sessions WHERE chat_id = ?", (chat_id,)).fetchone()[0] + finally: + conn.close() + assert n_msgs == 0 + assert n_sess == 0 + + +# ── Outbound offers + pull ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_send_video_emits_offer_with_message_association(adapter, ws_client): + ws, _ = ws_client + from gateway.platforms.base import get_video_cache_dir + + video = get_video_cache_dir() / "clip.mp4" + 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}) + 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)) + assert res2.success + frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") + offer = frames[-1]["payload"] + assert offer["kind"] == "video" + assert offer["mime"] == "video/mp4" + assert offer["size"] == len(b"fake-video-bytes") + assert offer["filename"] == "clip.mp4" + assert offer["media_id"] == res2.message_id + assert offer["media_id"].startswith("md_") + assert offer["message_id"] == msg_id + + +@pytest.mark.asyncio +async def test_send_document_uses_caller_filename(adapter, ws_client): + ws, _ = ws_client + from gateway.platforms.base import get_document_cache_dir + + 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" + ) + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") + offer = frames[-1]["payload"] + assert offer["kind"] == "document" + assert offer["filename"] == "My Report.pdf" + assert offer["mime"] == "application/pdf" + + +@pytest.mark.asyncio +async def test_send_image_file_offers_image(adapter, ws_client): + ws, _ = ws_client + from gateway.platforms.base import get_image_cache_dir + + img = get_image_cache_dir() / "shot.png" + img.write_bytes(PNG_1X1) + res = await adapter.send_image_file("android:default", str(img)) + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") + offer = frames[-1]["payload"] + assert offer["kind"] == "image" + assert offer["mime"] == "image/png" + + +@pytest.mark.asyncio +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") + assert not res.success + # Nothing was offered (a failed offer is silent, like other platforms). + try: + raw = await asyncio.wait_for(ws.recv(), timeout=1.0) + frame = json.loads(raw) if isinstance(raw, str) else None + assert frame is None or frame.get("type") != "media.offer" + except asyncio.TimeoutError: + pass # silence is the expected outcome + + +@pytest.mark.asyncio +async def test_pull_serves_allowed_path(adapter, ws_client): + ws, _ = ws_client + from gateway.platforms.base import get_image_cache_dir + + img = get_image_cache_dir() / "pull_test.png" + img.write_bytes(PNG_1X1) + entry = adapter._media.register_outbound( + str(img), "image", "image/png", "pull_test.png", len(PNG_1X1) + ) + + await ws.send( + json.dumps( + {"v": 1, "id": 9, "type": "media.pull", "payload": {"media_id": entry.media_id}} + ) + ) + chunks: list[bytes] = [] + terminal = None + while terminal is None: + raw = await asyncio.wait_for(ws.recv(), timeout=10) + if isinstance(raw, (bytes, bytearray)): + chunks.append(bytes(raw)) + continue + frame = json.loads(raw) + if frame.get("type") == "media.pull.end": + terminal = frame + assert terminal["id"] == 9 + assert terminal["payload"]["ok"] is True + assert b"".join(chunks) == PNG_1X1 + + +@pytest.mark.asyncio +async def test_pull_rejects_unknown_and_denied(adapter, ws_client): + ws, _ = ws_client + # Unknown media_id. + await ws.send( + json.dumps({"v": 1, "id": 1, "type": "media.pull", "payload": {"media_id": "md_nope"}}) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1) + assert frames[-1]["payload"]["code"] == "not_found" + + # Known id, but the path fails delivery validation (denylist). + entry = adapter._media.register_outbound( + "/etc/passwd", "document", "text/plain", "passwd", 100 + ) + await ws.send( + json.dumps( + {"v": 1, "id": 2, "type": "media.pull", "payload": {"media_id": entry.media_id}} + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2) + assert frames[-1]["payload"]["code"] == "not_found" + + # Known id, file deleted since the offer. + from gateway.platforms.base import get_image_cache_dir + + img = get_image_cache_dir() / "gone.png" + img.write_bytes(PNG_1X1) + entry2 = adapter._media.register_outbound( + str(img), "image", "image/png", "gone.png", len(PNG_1X1) + ) + img.unlink() + await ws.send( + json.dumps( + {"v": 1, "id": 3, "type": "media.pull", "payload": {"media_id": entry2.media_id}} + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 3) + assert frames[-1]["payload"]["code"] == "not_found" + + +# ── M5: push backends (pure) ─────────────────────────────────────────────── + + +class _FakeResponse: + def __init__(self, status=200, body="", json_data=None): + self.status_code = status + self.text = body + self._json = json_data if json_data is not None else {} + + def json(self): + return self._json + + +class _FakeHttpClient: + """Stands in for ``httpx.AsyncClient`` (async CM + ``post``).""" + + def __init__(self, responder): + self._responder = responder # (url, kwargs) -> _FakeResponse + self.calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + return self._responder(url, kwargs) + + +def _patch_httpx(plugin, monkeypatch, responder): + fake = _FakeHttpClient(responder) + monkeypatch.setattr(plugin.push.httpx, "AsyncClient", lambda *a, **k: fake) + return fake + + +def test_push_backend_selection(plugin): + push = plugin.push + assert isinstance(push.build_push_backend("fcm"), push.FcmBackend) + assert isinstance(push.build_push_backend("ntfy"), push.NtfyBackend) + assert isinstance(push.build_push_backend(None), push.FcmBackend) # default + assert isinstance(push.build_push_backend(" NTFY "), push.NtfyBackend) + + assert push.build_push_backend("ntfy", ntfy_topic="my-topic").configured() is True + assert push.build_push_backend("ntfy").configured() is False + assert push.build_push_backend("fcm", fcm_server_key="key-123").configured() is True + assert push.build_push_backend("fcm").configured() is False + + +def test_push_backend_fcm_service_account_configured(plugin, tmp_path): + push = plugin.push + sa = tmp_path / "sa.json" + sa.write_text( + json.dumps( + { + "client_email": "x@y.iam.gserviceaccount.com", + "private_key": "k", + "project_id": "p", + } + ) + ) + assert ( + push.build_push_backend("fcm", fcm_service_account=str(sa)).configured() + is True + ) + assert ( + push.build_push_backend( + "fcm", fcm_service_account=str(tmp_path / "missing.json") + ).configured() + is False + ) + + +@pytest.mark.asyncio +async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch): + push = plugin.push + backend = push.NtfyBackend(topic="iris-topic", server_url="https://ntfy.example/") + fake = _patch_httpx( + plugin, monkeypatch, lambda url, kw: _FakeResponse(200, '{"id":"1"}') + ) + ok = await backend.send( + device_id="d1", + chat_id="android:default", + title="Iris", + body="hello", + data={"chat_id": "android:default", "kind": "message", "cursor": "7"}, + token="iris-topic", + ) + assert ok is True + url, kw = fake.calls[0] + assert url == "https://ntfy.example/iris-topic" + headers = kw["headers"] + assert headers["X-Title"] == "Iris" + assert headers["X-Priority"] == "3" + assert json.loads(headers["X-Data"])["cursor"] == "7" + assert kw["content"] == b"hello" + + fake2 = _patch_httpx(plugin, monkeypatch, lambda url, kw: _FakeResponse(200)) + await backend.send( + device_id="d1", chat_id="c", title="t", body="b", data={}, + token="t", priority="high", + ) + assert fake2.calls[0][1]["headers"]["X-Priority"] == "5" + + fake3 = _patch_httpx(plugin, monkeypatch, lambda url, kw: _FakeResponse(404, "nope")) + assert ( + await backend.send( + device_id="d1", chat_id="c", title="t", body="b", data={}, token="t" + ) + is False + ) + + +@pytest.mark.asyncio +async def test_fcm_backend_legacy_server_key(plugin, monkeypatch): + push = plugin.push + backend = push.FcmBackend(server_key="legacy-key") + fake = _patch_httpx( + plugin, monkeypatch, lambda url, kw: _FakeResponse(200, '{"name":"m/1"}') + ) + ok = await backend.send( + device_id="d1", + chat_id="android:default", + title="Iris", + body="hi", + data={"chat_id": "android:default", "kind": "message", "cursor": "3"}, + token="fcm-token-1", + ) + assert ok is True + url, kw = fake.calls[0] + assert url == push.FCM_LEGACY_SEND_URL + assert kw["headers"]["Authorization"] == "Bearer legacy-key" + assert kw["json"]["to"] == "fcm-token-1" + assert kw["json"]["notification"] == {"title": "Iris", "body": "hi"} + assert kw["json"]["data"]["cursor"] == "3" + + +@pytest.mark.asyncio +async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + sa = tmp_path / "sa.json" + sa.write_text( + json.dumps( + { + "project_id": "iris-test-project", + "client_email": "svc@iris-test-project.iam.gserviceaccount.com", + "private_key_id": "kid-1", + "private_key": pem, + } + ) + ) + backend = plugin.push.FcmBackend(service_account=str(sa)) + assert backend.configured() is True + + def responder(url, kw): + if url == plugin.push.FCM_TOKEN_URL: + assert kw["data"]["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" + return _FakeResponse(200, "", {"access_token": "at-123", "expires_in": 3600}) + return _FakeResponse(200, '{"name":"projects/iris-test-project/messages/9"}') + + fake = _patch_httpx(plugin, monkeypatch, responder) + ok = await backend.send( + device_id="d1", + chat_id="android:default", + title="Iris", + body="hi", + data={"chat_id": "android:default", "kind": "cron", "cursor": "4"}, + token="fcm-token-2", + priority="high", + ) + assert ok is True + token_url, token_kw = fake.calls[0] + send_url, send_kw = fake.calls[1] + assert token_url == plugin.push.FCM_TOKEN_URL + assert send_url == ( + "https://fcm.googleapis.com/v1/projects/iris-test-project/messages:send" + ) + assert send_kw["headers"]["Authorization"] == "Bearer at-123" + msg = send_kw["json"]["message"] + assert msg["token"] == "fcm-token-2" + assert msg["android"]["priority"] == "high" + assert msg["data"]["kind"] == "cron" + # The access token is cached: the next send does not re-exchange. + await backend.send( + device_id="d1", chat_id="c", title="t", body="b", data={}, token="fcm-token-2" + ) + assert len(fake.calls) == 3 + + +# ── M5: push firing (integration) ────────────────────────────────────────── + + +class _FakePush: + name = "fake" + token_field = "fcm_token" + + def __init__(self, configured=True): + self.calls = [] + self._configured = configured + self.fail_next = False + + def configured(self): + return self._configured + + async def send(self, **kwargs): + self.calls.append(kwargs) + if self.fail_next: + self.fail_next = False + return False + return True + + +@pytest.mark.asyncio +async def test_push_fires_when_no_live_subscriber(adapter): + fake = _FakePush() + adapter._push = fake + adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") + + res = await adapter.send( + "android: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["data"]["kind"] == "message" + assert call["data"]["chat_id"] == "android:default" + assert call["data"]["cursor"] == "1" + assert call["priority"] == "normal" + # The frame is parked in the outbox for sync. + assert adapter._outbox.latest_cursor() == 1 + + +@pytest.mark.asyncio +async def test_push_not_fired_when_live(adapter, ws_client): + ws, _ = ws_client + fake = _FakePush() + adapter._push = fake + adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") + + await adapter.send("android: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 == [] + + +@pytest.mark.asyncio +async def test_live_delivered_frame_still_parked_for_sync(adapter, ws_client): + """A frame delivered to a live subscriber is still appended to the outbox, + so a reconnecting app (whose in-memory ChatStore was reset, e.g. after + 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}) + 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. + assert adapter._outbox.latest_cursor() == 1 + rows = adapter._outbox.replay(0) + assert [r["frame"]["payload"]["text"] for r in rows] == ["live reply"] + + +@pytest.mark.asyncio +async def test_user_echo_parked_for_sync(adapter, ws_client): + """The user-message echo is appended to the outbox so a reconnecting app + (whose in-memory ChatStore was reset by a process death / activity + recreation) restores the user's own message via the sync replay. + Regression: user messages vanished after closing/multitasking while bot + messages (already parked) survived.""" + ws, _ = ws_client + adapter.handle_message = AsyncMock() + + await ws.send( + json.dumps( + { + "v": 1, + "id": 90, + "type": "message.send", + "payload": {"text": "remember me"}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ) + echo = frames[-1] + assert echo["payload"]["text"] == "remember me" + + # The echo is parked in the outbox for sync catch-up, carrying the same + # server-assigned id the live echo used (so the app dedupes on replay). + rows = adapter._outbox.replay(0) + user_rows = [ + r + for r in rows + if r["frame"].get("type") == "message" + and r["frame"].get("payload", {}).get("role") == "user" + ] + assert len(user_rows) == 1 + assert user_rows[0]["frame"]["payload"]["text"] == "remember me" + assert user_rows[0]["frame"]["payload"]["message_id"] == echo["payload"]["message_id"] + + +@pytest.mark.asyncio +async def test_intermediate_frames_park_without_push(adapter): + fake = _FakePush() + 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") + assert adapter._outbox.latest_cursor() == 2 + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_high_priority_notification_pushes_even_when_live(plugin, adapter, ws_client): + ws, _ = ws_client + fake = _FakePush() + adapter._push = fake + adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") + + await adapter._broadcast_or_log( + "android:default", + plugin.protocol.notification( + "android:default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body" + ), + ) + frames = await recv_until(ws, lambda f: f.get("type") == "notification") + assert frames[-1]["payload"]["kind"] == "cron" + assert len(fake.calls) == 1 + assert fake.calls[0]["priority"] == "high" + assert fake.calls[0]["data"]["kind"] == "cron" + + +@pytest.mark.asyncio +async def test_push_skipped_when_device_has_no_token(adapter): + fake = _FakePush() + adapter._push = fake + adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token + + await adapter.send("android:default", "no token", metadata={"notify": True}) + assert fake.calls == [] + assert adapter._outbox.latest_cursor() == 1 # still parked for sync + + +@pytest.mark.asyncio +async def test_push_skipped_when_backend_unconfigured(adapter): + fake = _FakePush(configured=False) + adapter._push = fake + adapter._devices.upsert(DEVICE_ID, "Test", {}) + + await adapter.send("android:default", "unconfigured", metadata={"notify": True}) + assert fake.calls == [] + + +# ── M5: fcm.register ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client): + ws, _ = ws_client + await ws.send( + json.dumps( + { + "v": 1, + "type": "fcm.register", + "payload": {"fcm_token": "rotated-token", "ntfy_topic": "dev-topic"}, + } + ) + ) + # Ordering barrier: WS frames are processed in order, so by the time the + # channel.list response arrives, fcm.register has been handled. + await ws.send( + json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}}) + ) + await recv_until(ws, lambda f: f.get("type") == "channel.list") + dev = adapter._devices.get(DEVICE_ID) + assert dev["fcm_token"] == "rotated-token" + assert dev["ntfy_topic"] == "dev-topic" + conn = adapter._ws_server.connection(DEVICE_ID) + assert conn.fcm_token == "rotated-token" + assert conn.ntfy_topic == "dev-topic" + + # The next offline push targets the rotated token. + await ws.close() + for _ in range(100): + if not adapter._ws_server.has_devices(): + break + await asyncio.sleep(0.01) + fake = _FakePush() + adapter._push = fake + await adapter.send("android:default", "after rotation", metadata={"notify": True}) + assert len(fake.calls) == 1 + assert fake.calls[0]["token"] == "rotated-token" + + +# ── M5: notification banners ─────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_channel_events_emit_notification_banners(adapter, ws_client): + ws, _ = ws_client + await ws.send( + json.dumps( + { + "v": 1, + "id": 1, + "type": "channel.create", + "payload": {"name": "Cron Reports"}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "notification") + notif = frames[-1] + assert notif["payload"]["kind"] == "channel_created" + assert "Cron Reports" in notif["payload"]["body"] + chat_id = notif["chat_id"] + + await ws.send( + json.dumps( + { + "v": 1, + "id": 2, + "type": "channel.rename", + "chat_id": chat_id, + "payload": {"name": "Reports"}, + } + ) + ) + frames = await recv_until( + ws, + lambda f: f.get("type") == "notification" + and f["payload"]["kind"] == "channel_renamed", + ) + assert frames[-1]["payload"]["body"] == "Renamed to Reports" + + await ws.send( + json.dumps( + { + "v": 1, + "id": 3, + "type": "channel.delete", + "chat_id": chat_id, + "payload": {}, + } + ) + ) + frames = await recv_until( + ws, + lambda f: f.get("type") == "notification" + and f["payload"]["kind"] == "channel_deleted", + ) + assert "Reports" in frames[-1]["payload"]["body"] + + +# ── M3: channel favorite / icon (cosmetic, synced) ───────────────────────── + + +async def _create_channel(ws, name: str, req_id: int) -> str: + await ws.send( + json.dumps({"v": 1, "id": req_id, "type": "channel.create", "payload": {"name": name}}) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.created" and f.get("id") == req_id) + return frames[-1]["payload"]["chat_id"] + + +@pytest.mark.asyncio +async def test_channel_favorite_toggle(adapter, ws_client): + ws, _ = ws_client + chat_id = await _create_channel(ws, "Work", 1) + # Fresh channel is not a favorite. + await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.list", "payload": {}})) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 2) + entry = next(c for c in frames[-1]["payload"]["channels"] if c["chat_id"] == chat_id) + assert "favorite" not in entry + + # Favorite it -> the renamed-shaped response carries favorite=true. + await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.favorite", "chat_id": chat_id, "payload": {"on": True}})) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3) + assert frames[-1]["payload"]["favorite"] is True + + # Unfavorite it -> the flag is cleared (absent on the wire). + await ws.send(json.dumps({"v": 1, "id": 4, "type": "channel.favorite", "chat_id": chat_id, "payload": {"on": False}})) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 4) + assert "favorite" not in frames[-1]["payload"] + + +@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}})) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1) + assert frames[-1]["payload"]["code"] == "not_found" + + +@pytest.mark.asyncio +async def test_channel_icon_set_and_clear(adapter, ws_client): + ws, _ = ws_client + chat_id = await _create_channel(ws, "Bugs", 1) + icon = "aGVsbG8=" # base64 of "hello" + await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.icon", "chat_id": chat_id, "payload": {"icon": icon, "color": "#FF0000"}})) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 2) + assert frames[-1]["payload"]["icon"] == icon + assert frames[-1]["payload"]["color"] == "#FF0000" + + # Clear both (empty payload) -> icon/color absent on the wire. + await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.icon", "chat_id": chat_id, "payload": {}})) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3) + assert "icon" not in frames[-1]["payload"] + assert "color" not in frames[-1]["payload"] + + +@pytest.mark.asyncio +async def test_channel_icon_too_large_rejected(adapter, ws_client): + ws, _ = ws_client + chat_id = await _create_channel(ws, "Big", 1) + await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.icon", "chat_id": chat_id, "payload": {"icon": "x" * (512 * 1024 + 1)}})) + frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2) + assert frames[-1]["payload"]["code"] == "unsupported" + + +@pytest.mark.asyncio +async def test_channel_list_orders_favorites_first(adapter, ws_client): + ws, _ = ws_client + alpha = await _create_channel(ws, "Alpha", 1) + beta = await _create_channel(ws, "Beta", 2) + # Favorite the later-created channel; it must sort ahead of Alpha. + await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.favorite", "chat_id": beta, "payload": {"on": True}})) + await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3) + await ws.send(json.dumps({"v": 1, "id": 4, "type": "channel.list", "payload": {}})) + frames = await recv_until(ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 4) + names = [c["name"] for c in frames[-1]["payload"]["channels"]] + assert names.index("Beta") < names.index("Alpha") + + +@pytest.mark.asyncio +async def test_cron_delivery_emits_banner_and_message(adapter, ws_client): + ws, _ = ws_client + wrapped = ( + "Cronjob Response: My Job\n" + "(job_id: abc123)\n" + "-------------\n\n" + "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"}) + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "message") + msg = frames[-1] + assert msg["payload"]["text"] == "hello from cron" + assert msg["payload"]["role"] == "assistant" + notif = [f for f in frames if f.get("type") == "notification"] + assert len(notif) == 1 + assert notif[0]["payload"]["kind"] == "cron" + assert notif[0]["payload"]["title"] == "Cron: My Job" + 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"}) + assert res2.success + frames = await recv_until( + ws, + lambda f: f.get("type") == "message" + and f["payload"].get("text") == "raw cron output", + ) + notif2 = [f for f in frames if f.get("type") == "notification"] + assert notif2[0]["payload"]["title"] == "Cron: j2" + + +@pytest.mark.asyncio +async def test_clarify_emits_banner_and_message(adapter, ws_client): + ws, _ = ws_client + from tools import clarify_gateway as cg + + cg.register("cl_1", "sk", "Which one?", ["A", "B"]) + try: + res = await adapter.send_clarify( + "android:default", "Which one?", ["A", "B"], "cl_1", "sk" + ) + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "message") + msg = frames[-1] + assert "❓ Which one?" in msg["payload"]["text"] + assert "1. A" in msg["payload"]["text"] + notif = [f for f in frames if f.get("type") == "notification"] + assert notif[0]["payload"]["kind"] == "clarify" + assert notif[0]["payload"]["body"] == "Which one?" + entry = cg._entries.get("cl_1") + assert entry is not None and entry.awaiting_text is True + finally: + cg._entries.pop("cl_1", None) + cg._session_index.pop("sk", None) + + +# ── M5: sync catch-up + outbox row cap ───────────────────────────────────── + + +@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}) + assert adapter._outbox.latest_cursor() == 2 + + from websockets.asyncio.client import connect + + await adapter.connect() + try: + port = adapter._ws_server._server.sockets[0].getsockname()[1] + async with connect(f"ws://127.0.0.1:{port}/ws") as ws: + ack = await _hello(ws) + assert ack["payload"]["sync_cursor"] == 2 + await ws.send( + json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}}) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "sync.done") + texts = [f["payload"]["text"] for f in frames if f.get("type") == "message"] + assert texts == ["one", "two"] + done = frames[-1] + assert done["id"] == 10 + assert done["payload"]["cursor"] == 2 + # A sync at the current cursor replays nothing. + await ws.send( + json.dumps({"v": 1, "id": 11, "type": "sync", "payload": {"cursor": 2}}) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "sync.done" and f.get("id") == 11 + ) + assert len(frames) == 1 + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_hello_ack_last_pushed_cursor_default_zero(adapter): + """A device that never received a push reports last_pushed_cursor=0 in + hello.ack (docs/08 §8.7 dedupe watermark).""" + from websockets.asyncio.client import connect + + await adapter.connect() + try: + port = adapter._ws_server._server.sockets[0].getsockname()[1] + async with connect(f"ws://127.0.0.1:{port}/ws") as ws: + ack = await _hello(ws) + assert ack["payload"]["last_pushed_cursor"] == 0 + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_push_success_advances_last_pushed_cursor(adapter): + """A successful push advances the device's last_pushed_cursor, and the + next hello.ack reports it — the app uses it to skip re-notifying + sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same + chat coalesce into one push; a failed push does not advance the cursor.""" + from websockets.asyncio.client import connect + + fake = _FakePush() + adapter._push = fake + adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") + + await adapter.send("android: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}) + 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}) + 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}) + assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3 + + await adapter.connect() + try: + port = adapter._ws_server._server.sockets[0].getsockname()[1] + async with connect(f"ws://127.0.0.1:{port}/ws") as ws: + ack = await _hello(ws) + assert ack["payload"]["last_pushed_cursor"] == 3 + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +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.""" + from websockets.asyncio.client import connect + + await adapter.send("android:default", "one", metadata={"notify": True}) + await adapter.send("android:default", "two", metadata={"notify": True}) + + await adapter.connect() + try: + port = adapter._ws_server._server.sockets[0].getsockname()[1] + async with connect(f"ws://127.0.0.1:{port}/ws") as ws: + await _hello(ws) + await ws.send( + json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}}) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "sync.done") + msgs = [f for f in frames if f.get("type") == "message"] + assert [f["cursor"] for f in msgs] == [1, 2] + # sync.done itself carries no envelope cursor. + assert "cursor" not in frames[-1] + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +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}) + frames = await recv_until(ws, lambda f: f.get("type") == "message") + assert "cursor" not in frames[-1] + + +def test_outbox_row_cap_prunes_oldest(plugin, tmp_path): + outbox = plugin.outbox.Outbox(tmp_path / "ob.db", max_rows=5) + try: + for i in range(7): + outbox.append( + "android:default", + json.dumps({"v": 1, "type": "message", "payload": {"n": i}}), + ) + assert outbox.latest_cursor() == 7 # cursor stays monotonic + assert outbox.take_overflow_pruned() == 2 + rows = outbox.replay(0) + assert [r["frame"]["payload"]["n"] for r in rows] == [2, 3, 4, 5, 6] + assert outbox.take_overflow_pruned() == 0 + finally: + outbox.close() + + +def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path): + """Outbox.delete_message removes every frame for a message_id (any type) + 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" + # 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"}})) + outbox.append(chat, json.dumps({"v": 1, "type": "message.update", "payload": {"message_id": "m1", "text": "hi"}})) + outbox.append(chat, json.dumps({"v": 1, "type": "message.stop", "payload": {"message_id": "m1", "final_text": "hi"}})) + outbox.append(chat, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "m2", "role": "user", "text": "keep"}})) + # A same-id message in a different thread must survive a flat-lane delete. + outbox.append(chat, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "m1", "role": "user", "text": "thread"}})) + removed = outbox.delete_message(chat, "m1") + assert removed == 3 # the three flat-lane frames; the t_1 one is scoped out + rows = outbox.replay(0) + ids = [r["frame"].get("payload", {}).get("message_id") for r in rows] + assert "m2" in ids + assert ids.count("m1") == 1 # only the t_1 frame remains + # Deleting it with the thread scope removes the last one. + assert outbox.delete_message(chat, "m1", thread_id="t_1") == 1 + # Idempotent: deleting again removes nothing. + assert outbox.delete_message(chat, "m1") == 0 + finally: + outbox.close() + + +def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path): + """Outbox.delete_lane wipes a whole lane: a channel delete (thread_id=None) + removes every frame for the chat (flat + all threads); a thread delete is + scoped to that thread's frames only.""" + outbox = plugin.outbox.Outbox(tmp_path / "ob.db") + try: + chan = "android: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"}})) + # Thread delete: only t_1's frame goes. + assert outbox.delete_lane(chan, thread_id="t_1") == 1 + rows = outbox.replay(0) + mids = [r["frame"].get("payload", {}).get("message_id") for r in rows] + assert "b" not in mids and "a" in mids and "c" in mids and "z" in mids + # Channel delete: the rest of the channel (flat + t_2) goes, chan_8 stays. + assert outbox.delete_lane(chan) == 2 + rows = outbox.replay(0) + mids = [r["frame"].get("payload", {}).get("message_id") for r in rows] + assert mids == ["z"] + finally: + outbox.close() + + +def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path): + """ChannelDirectory.delete removes the row entirely (not archived) and, for + 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") + 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. + entry = d.delete(chan["chat_id"]) + assert entry is not None and entry["chat_id"] == chan["chat_id"] + assert d.get(chan["chat_id"]) is None # hard-deleted, not archived + assert d.get(t1["chat_id"]) is None # child thread gone too + assert chan["chat_id"] not in [c["chat_id"] for c in d.list()] + # Deleting a thread removes just that thread. + chan2 = d.create("Ops", kind="channel") + t2 = d.create("Bug", kind="thread", parent_chat_id=chan2["chat_id"]) + assert d.delete(t2["chat_id"]) is not None + 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 + # Unknown id -> None. + assert d.delete("android:chan_nope") is None + finally: + d.close() + + +# ── Pairing sanity (M1 regression guard for the media-capable server) ────── + + +@pytest.mark.asyncio +async def test_wrong_token_rejected(adapter): + from websockets.asyncio.client import connect + + await adapter.connect() + try: + port = adapter._ws_server._server.sockets[0].getsockname()[1] + async with connect(f"ws://127.0.0.1:{port}/ws") as ws: + await ws.send( + json.dumps( + { + "v": 1, + "type": "hello", + "payload": { + "token": "wrong-token", + "device_id": DEVICE_ID, + "device_name": "Bad", + "caps": {}, + }, + } + ) + ) + raw = await asyncio.wait_for(ws.recv(), timeout=5) + err = json.loads(raw) + assert err["type"] == "error" + assert err["payload"]["code"] == "auth" + finally: + await adapter.disconnect() + + +# ── M2: tool-detail capture (verbose args + post_tool_call output) ───────── + + +def test_extract_verbose_args(plugin): + a = plugin.adapter + content = '🔍 web_search(["query"])\n{"query": "hermes agent"}' + line = '🔍 web_search(["query"])' + assert a._extract_verbose_args(line, content) == {"query": "hermes agent"} + # Non-verbose line (no "(keys)" header) -> None. + assert a._extract_verbose_args('🔍 web_search: "x"', content) is None + # Header with no JSON body on the following line -> None. + assert a._extract_verbose_args(line, line) is None + + +def test_short_preview_from_args(plugin): + a = plugin.adapter + assert a._short_preview_from_args({"query": "hermes agent"}) == "hermes agent" + # First non-empty string value wins (skips non-strings). + assert a._short_preview_from_args({"n": 5, "path": "/tmp/x"}) == "/tmp/x" + assert a._short_preview_from_args({}) is None + prev = a._short_preview_from_args({"cmd": "a" * 100}) + assert prev is not None and prev.endswith("…") and len(prev) <= 60 + + +def test_tool_end_fields_from_hook(plugin): + a = plugin.adapter + a._reset_tool_results() + try: + # No captured record yet -> empty (caller keeps ok=True default). + assert a._tool_end_fields("terminal") == {} + # Capture a completed call, then consume it. + a._on_post_tool_call( + tool_name="terminal", + result='{"ok": true, "stdout": "hello"}', + duration_ms=1234, + status="ok", + ) + fields = a._tool_end_fields("terminal") + assert fields["ok"] is True + assert fields["output_preview"] == '{"ok": true, "stdout": "hello"}' + assert fields["duration"] == 1.234 + # Consumed -> a second lookup is empty. + assert a._tool_end_fields("terminal") == {} + # Error status -> ok False; zero duration omitted. + a._on_post_tool_call(tool_name="terminal", result="boom", duration_ms=0, status="error") + f2 = a._tool_end_fields("terminal") + assert f2["ok"] is False and "duration" not in f2 + finally: + a._reset_tool_results() + + +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( + '🔍 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( + '🔍 web_search: "x"', '🔍 web_search: "x"' + ) + assert name2 == "web_search" and preview2 == "x" and args2 is None + + +# ── Slash-command catalog (the app's "/" drawer) ────────────────────────── + + +def test_slash_catalog_shape_and_gateway_filtering(plugin): + """The catalog is the gateway-available COMMAND_REGISTRY subset: every + entry carries the wire shape, cli_only commands are excluded, aliases + keep the leading slash.""" + a = plugin.adapter + catalog = a._slash_command_catalog() + assert catalog, "expected a non-empty slash catalog" + names = {e["name"] for e in catalog} + # A core gateway command is present with its alias. + assert "/new" in names + new = next(e for e in catalog if e["name"] == "/new") + assert "/reset" in new["aliases"] + # A cli_only command is not surfaced to the gateway platform. + assert "/clear" not in names + for e in catalog: + assert e["name"].startswith("/") + assert isinstance(e["description"], str) and e["description"] + assert isinstance(e["args_hint"], str) + assert isinstance(e["category"], str) and e["category"] + assert all(alias.startswith("/") for alias in e["aliases"]) + + +@pytest.mark.asyncio +async def test_commands_catalog_frame_roundtrip(plugin, adapter, ws_client): + """A ``commands.catalog`` request is answered with the catalog on the + same id; the response carries the wire shape the app's drawer consumes.""" + ws, _ = ws_client + await ws.send( + json.dumps( + { + "v": 1, + "id": 42, + "type": "commands.catalog", + "payload": {}, + } + ) + ) + frames = await recv_until(ws, lambda f: f.get("type") == "commands.catalog") + resp = frames[-1] + assert resp["id"] == 42 + commands = resp["payload"]["commands"] + assert commands, "expected a non-empty catalog" + names = {c["name"] for c in commands} + assert "/new" in names + assert "/clear" not in names # cli_only + for c in commands: + assert c["name"].startswith("/") + assert "description" in c and "args_hint" in c and "category" in c + assert isinstance(c["aliases"], list) \ No newline at end of file diff --git a/scripts/make_release_keystore.sh b/scripts/make_release_keystore.sh new file mode 100755 index 0000000..ece19a4 --- /dev/null +++ b/scripts/make_release_keystore.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# One-time setup: create the Android release keystore and print the values to +# paste into Gitea (repo -> Settings -> Actions -> Secrets). +# +# Usage: scripts/make_release_keystore.sh [output-file] +# (default: ~/iris-release.keystore) +# +# WARNING: back up the keystore file immediately. If it is lost, the app can +# never be updated on users' phones (a new key = a brand-new app as far as +# Android is concerned). +set -euo pipefail + +OUT="${1:-$HOME/iris-release.keystore}" +if [ -e "$OUT" ]; then + echo "Refusing to overwrite existing file: $OUT" >&2 + exit 1 +fi + +STORE_PASS="$(openssl rand -base64 18 | tr -d '/+=')" +KEY_PASS="$(openssl rand -base64 18 | tr -d '/+=')" + +keytool -genkeypair -v \ + -keystore "$OUT" -storetype PKCS12 \ + -alias iris -keyalg RSA -keysize 2048 -validity 10000 \ + -storepass "$STORE_PASS" -keypass "$KEY_PASS" \ + -dname "CN=Iris Release, OU=Mobile, O=Iris, C=DE" + +echo +echo "Keystore written to: $OUT" +echo ">>> Back it up NOW (password manager / cloud storage). <<<" +echo +echo "Add these four secrets in Gitea (repo -> Settings -> Actions -> Secrets):" +echo +echo " ANDROID_KEYSTORE_BASE64 = $(base64 -w0 "$OUT")" +echo +echo " ANDROID_KEYSTORE_PASSWORD = $STORE_PASS" +echo " ANDROID_KEY_ALIAS = iris" +echo " ANDROID_KEY_PASSWORD = $KEY_PASS" \ No newline at end of file