M7: polish + E2E + docs (layout pass, theming, states, e2e driver, schema, setup.md, security)
This commit is contained in:
1 parent
0cc8b7aafe
commit
bf6bf7e8bd
26 files changed
+2225
-327
No files matched your search
@@ -0,0 +1,15 @@
|
|||||||
|
# Committed pre-commit config so a fresh clone gets the hermes-agent/ guard
|
||||||
|
# without manual hook installation (scripts/guard_hermes_agent.sh --staged
|
||||||
|
# fails the commit if any hermes-agent/ path is staged — that directory is a
|
||||||
|
# read-only research reference, see .gitignore and docs/00-overview.md).
|
||||||
|
#
|
||||||
|
# Install: pre-commit install
|
||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: guard-hermes-agent
|
||||||
|
name: guard hermes-agent/ (read-only research reference)
|
||||||
|
entry: scripts/guard_hermes_agent.sh --staged
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
@@ -15,6 +15,21 @@ A **native Android + Desktop** experience for [hermes-agent](https://github.com/
|
|||||||
📚 **The full implementation reference library is in [`docs/`](docs/README.md).**
|
📚 **The full implementation reference library is in [`docs/`](docs/README.md).**
|
||||||
Read `docs/00-overview.md` first, then follow the numbered docs.
|
Read `docs/00-overview.md` first, then follow the numbered docs.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Full walkthrough: [`docs/setup.md`](docs/setup.md).
|
||||||
|
|
||||||
|
1. **Gateway:** install the plugin (`ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android`), run `hermes gateway setup` (generates the pairing token, prints the server URL), then `hermes gateway`.
|
||||||
|
2. **App:** `cd app && ./gradlew :androidApp:installDebug` (Android; ADB device connected) or `./gradlew :desktopApp:run` (desktop).
|
||||||
|
3. **Pair:** on the app's Connect screen, enter the server URL (`ws://<gateway-ip>:8790/ws`) + pairing token, then **Test & Connect**.
|
||||||
|
4. **Chat.**
|
||||||
|
|
||||||
|
### Pairing notes
|
||||||
|
|
||||||
|
- **Token location:** `~/.hermes/.env` on the gateway host (`ANDROID_TOKEN`), or the `hermes gateway setup` output (printed once, at generation).
|
||||||
|
- **Manual entry only:** the app has no QR scanner yet — the server prints a QR payload, but you type the URL + token.
|
||||||
|
- **Default bind is `127.0.0.1`:** for a phone on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
||||||
|
|
||||||
## The three deliverables (this monorepo)
|
## The three deliverables (this monorepo)
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
@@ -28,7 +43,7 @@ project** (`app/`) with a shared KMP module (`app/shared`).
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- **Phase:** Planning complete → ready to implement (Milestone M0).
|
- **Phase:** M0–M6 complete; M7 (polish + E2E + docs) in progress.
|
||||||
- **Milestones:** see [`docs/14-milestones.md`](docs/14-milestones.md).
|
- **Milestones:** see [`docs/14-milestones.md`](docs/14-milestones.md).
|
||||||
- **Locked decisions:** see [`docs/16-open-questions.md`](docs/16-open-questions.md).
|
- **Locked decisions:** see [`docs/16-open-questions.md`](docs/16-open-questions.md).
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ kotlin {
|
|||||||
}
|
}
|
||||||
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
// M7: EncryptedSharedPreferences for the pairing token
|
||||||
|
// (docs/09 §9.7; AndroidSecureStore).
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0")
|
||||||
implementation("androidx.media3:media3-exoplayer:1.3.1")
|
implementation("androidx.media3:media3-exoplayer:1.3.1")
|
||||||
implementation("androidx.media3:media3-ui:1.3.1")
|
implementation("androidx.media3:media3-ui:1.3.1")
|
||||||
// SAF picker (rememberLauncherForActivityResult).
|
// SAF picker (rememberLauncherForActivityResult).
|
||||||
|
|||||||
@@ -1,16 +1,64 @@
|
|||||||
package iris.platform
|
package iris.platform
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Android pairing storage. M1: SharedPreferences (dev). M5 moves the token
|
* Android pairing storage. M7: EncryptedSharedPreferences (MasterKey
|
||||||
* to EncryptedSharedPreferences per docs/10 §10.2.
|
* AES256_GCM) per docs/09 §9.7. The M1/M5 plain "iris" SharedPreferences
|
||||||
|
* values are migrated on first run (read old key, write encrypted, delete
|
||||||
|
* old key) so an upgrade never loses the pairing.
|
||||||
*/
|
*/
|
||||||
class AndroidSecureStore(context: Context) : SecureStore {
|
class AndroidSecureStore(context: Context) : SecureStore {
|
||||||
private val prefs = context.applicationContext.getSharedPreferences("iris", Context.MODE_PRIVATE)
|
private val appContext = context.applicationContext
|
||||||
|
private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||||
|
appContext,
|
||||||
|
SECURE_PREFS_NAME,
|
||||||
|
MasterKey.Builder(appContext)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build(),
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
migratePlainValues()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time migration of the M1/M5 plain values into the encrypted store. */
|
||||||
|
private fun migratePlainValues() {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
var migrated = false
|
||||||
|
for (key in listOf(KEY_URL, KEY_TOKEN, KEY_DEVICE_ID, KEY_FCM_TOKEN, KEY_NTFY_TOPIC, KEY_NTFY_SERVER)) {
|
||||||
|
val old = plainPrefs.getString(key, null)
|
||||||
|
if (old != null && !prefs.contains(key)) {
|
||||||
|
editor.putString(key, old)
|
||||||
|
migrated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val oldCursor = plainPrefs.getLong(KEY_SYNC_CURSOR, 0L)
|
||||||
|
if (oldCursor != 0L && !prefs.contains(KEY_SYNC_CURSOR)) {
|
||||||
|
editor.putLong(KEY_SYNC_CURSOR, oldCursor)
|
||||||
|
migrated = true
|
||||||
|
}
|
||||||
|
if (migrated) editor.apply()
|
||||||
|
// The plain store must not keep a copy of any value.
|
||||||
|
plainPrefs.edit()
|
||||||
|
.remove(KEY_URL)
|
||||||
|
.remove(KEY_TOKEN)
|
||||||
|
.remove(KEY_DEVICE_ID)
|
||||||
|
.remove(KEY_SYNC_CURSOR)
|
||||||
|
.remove(KEY_FCM_TOKEN)
|
||||||
|
.remove(KEY_NTFY_TOPIC)
|
||||||
|
.remove(KEY_NTFY_SERVER)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
override var serverUrl: String
|
override var serverUrl: String
|
||||||
get() = prefs.getString(KEY_URL, "").orEmpty()
|
get() = prefs.getString(KEY_URL, "").orEmpty()
|
||||||
@@ -59,6 +107,8 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val PLAIN_PREFS_NAME = "iris"
|
||||||
|
const val SECURE_PREFS_NAME = "iris_secure"
|
||||||
const val KEY_URL = "server_url"
|
const val KEY_URL = "server_url"
|
||||||
const val KEY_TOKEN = "token"
|
const val KEY_TOKEN = "token"
|
||||||
const val KEY_DEVICE_ID = "device_id"
|
const val KEY_DEVICE_ID = "device_id"
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package iris
|
package iris
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -11,21 +9,16 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.setActiveController
|
import iris.platform.setActiveController
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import iris.ui.screens.ChatScreen
|
import iris.ui.screens.ChatScreen
|
||||||
import iris.ui.screens.ConnectScreen
|
import iris.ui.screens.ConnectScreen
|
||||||
|
import iris.ui.screens.ConnectingScreen
|
||||||
private val IrisDark = darkColorScheme(
|
import iris.ui.theme.IrisColors
|
||||||
background = Color(0xFF1B1E28),
|
import iris.ui.theme.IrisTheme
|
||||||
surface = Color(0xFF222634),
|
import iris.util.hostFromUrl
|
||||||
onBackground = Color(0xFFE8EAF0),
|
|
||||||
onSurface = Color(0xFFE8EAF0),
|
|
||||||
primary = Color(0xFF4F7CFF),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root composable shared by the Android and Desktop shells.
|
* Root composable shared by the Android and Desktop shells.
|
||||||
@@ -57,8 +50,8 @@ fun IrisApp(
|
|||||||
}
|
}
|
||||||
val state by controller.client.state.collectAsState()
|
val state by controller.client.state.collectAsState()
|
||||||
|
|
||||||
MaterialTheme(colorScheme = IrisDark) {
|
IrisTheme {
|
||||||
Surface(modifier = Modifier.background(IrisDark.background)) {
|
Surface(modifier = Modifier.background(IrisColors.background)) {
|
||||||
val s = state
|
val s = state
|
||||||
when (s) {
|
when (s) {
|
||||||
GatewayClient.State.Disconnected ->
|
GatewayClient.State.Disconnected ->
|
||||||
@@ -70,6 +63,9 @@ fun IrisApp(
|
|||||||
prefillToken = store.token,
|
prefillToken = store.token,
|
||||||
initialError = "Pairing rejected: ${s.message}",
|
initialError = "Pairing rejected: ${s.message}",
|
||||||
)
|
)
|
||||||
|
// M7: initial connect in flight — dedicated screen, not an empty chat.
|
||||||
|
GatewayClient.State.Connecting ->
|
||||||
|
ConnectingScreen(hostFromUrl(store.serverUrl))
|
||||||
else -> ChatScreen(controller)
|
else -> ChatScreen(controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ sealed interface ChatItem {
|
|||||||
val id: String
|
val id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delivery status of a user message (M5: read.receipt; M7: failed sends). */
|
||||||
|
enum class MsgStatus {
|
||||||
|
Pending, // optimistic, not yet acknowledged by the gateway
|
||||||
|
Sent, // gateway accepted it (echo received)
|
||||||
|
Read, // agent received and started processing it (read.receipt)
|
||||||
|
Failed, // send failed (error frame); tap the bubble to retry
|
||||||
|
}
|
||||||
|
|
||||||
/** A chat message (user / assistant / commentary / streaming bubble). */
|
/** A chat message (user / assistant / commentary / streaming bubble). */
|
||||||
data class MessageItem(
|
data class MessageItem(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
@@ -49,6 +57,7 @@ data class MessageItem(
|
|||||||
val text: String,
|
val text: String,
|
||||||
val ts: Long,
|
val ts: Long,
|
||||||
val pending: Boolean = false,
|
val pending: Boolean = false,
|
||||||
|
val status: MsgStatus = MsgStatus.Sent,
|
||||||
val reasoning: String? = null,
|
val reasoning: String? = null,
|
||||||
val isCommentary: Boolean = false,
|
val isCommentary: Boolean = false,
|
||||||
val streaming: Boolean = false,
|
val streaming: Boolean = false,
|
||||||
@@ -139,7 +148,7 @@ class ChatStore {
|
|||||||
localSeq++
|
localSeq++
|
||||||
val id = "local_$localSeq"
|
val id = "local_$localSeq"
|
||||||
updateLane(lane) {
|
updateLane(lane) {
|
||||||
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, media = media)
|
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -176,6 +185,7 @@ class ChatStore {
|
|||||||
reasoning = p.reasoning,
|
reasoning = p.reasoning,
|
||||||
pending = false,
|
pending = false,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
|
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
|
||||||
model = p.model,
|
model = p.model,
|
||||||
tokens = p.tokens,
|
tokens = p.tokens,
|
||||||
ts = p.ts ?: cur.ts,
|
ts = p.ts ?: cur.ts,
|
||||||
@@ -382,6 +392,55 @@ class ChatStore {
|
|||||||
if (changed) _lanes.value = map
|
if (changed) _lanes.value = map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** M5: mark the user message [messageId] as read (read.receipt). */
|
||||||
|
fun markRead(messageId: String) {
|
||||||
|
val map = _lanes.value.toMutableMap()
|
||||||
|
var changed = false
|
||||||
|
for ((lane, list) in map) {
|
||||||
|
val updated = list.map { item ->
|
||||||
|
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||||
|
item.status != MsgStatus.Read
|
||||||
|
) {
|
||||||
|
item.copy(pending = false, status = MsgStatus.Read)
|
||||||
|
} else item
|
||||||
|
}
|
||||||
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M7: mark all pending user messages as failed (gateway error frame). */
|
||||||
|
fun failPending() {
|
||||||
|
val map = _lanes.value.toMutableMap()
|
||||||
|
var changed = false
|
||||||
|
for ((lane, list) in map) {
|
||||||
|
val updated = list.map { item ->
|
||||||
|
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
|
||||||
|
item.copy(pending = false, status = MsgStatus.Failed)
|
||||||
|
} else item
|
||||||
|
}
|
||||||
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M7: re-arm a failed user message for a retry send. */
|
||||||
|
fun rearmForRetry(lane: String, messageId: String) {
|
||||||
|
updateLane(lane) { list ->
|
||||||
|
list.map { item ->
|
||||||
|
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
|
||||||
|
item.copy(pending = true, status = MsgStatus.Pending)
|
||||||
|
} else item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_lanes.value = emptyMap()
|
_lanes.value = emptyMap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,10 @@ class GatewayClient(
|
|||||||
}
|
}
|
||||||
TYPE_ERROR -> {
|
TYPE_ERROR -> {
|
||||||
val err = frame.payloadAs<ErrorPayload>()
|
val err = frame.payloadAs<ErrorPayload>()
|
||||||
authError.complete(err?.message ?: "auth failed")
|
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed")
|
||||||
|
// M7: post-connect error frames are app events, not
|
||||||
|
// auth failures — let the controller react.
|
||||||
|
_events.tryEmit(frame)
|
||||||
}
|
}
|
||||||
TYPE_PONG -> Unit
|
TYPE_PONG -> Unit
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ const val TYPE_MEDIA_OFFER = "media.offer"
|
|||||||
const val TYPE_MEDIA_PULL = "media.pull"
|
const val TYPE_MEDIA_PULL = "media.pull"
|
||||||
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||||
|
|
||||||
// M5 — push / notifications
|
// M5 — push / notifications / read receipt / gateway status
|
||||||
const val TYPE_NOTIFICATION = "notification"
|
const val TYPE_NOTIFICATION = "notification"
|
||||||
const val TYPE_FCM_REGISTER = "fcm.register"
|
const val TYPE_FCM_REGISTER = "fcm.register"
|
||||||
|
const val TYPE_READ_RECEIPT = "read.receipt"
|
||||||
|
const val TYPE_STATUS = "status"
|
||||||
|
|
||||||
// M3 — channels / threads / search / sync
|
// M3 — channels / threads / search / sync
|
||||||
const val TYPE_CHANNEL_CREATE = "channel.create"
|
const val TYPE_CHANNEL_CREATE = "channel.create"
|
||||||
@@ -383,6 +385,16 @@ data class FcmRegisterPayload(
|
|||||||
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── M5: read receipt / gateway status (server -> app) ─────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReadReceiptPayload(
|
||||||
|
@SerialName("message_id") val messageId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatusPayload(val state: String)
|
||||||
|
|
||||||
// ── Frame builders ──────────────────────────────────────────────────────
|
// ── Frame builders ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun helloFrame(
|
fun helloFrame(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package iris.state
|
|||||||
import iris.data.ChatStore
|
import iris.data.ChatStore
|
||||||
import iris.data.ChannelStore
|
import iris.data.ChannelStore
|
||||||
import iris.data.MediaItem
|
import iris.data.MediaItem
|
||||||
|
import iris.data.MessageItem
|
||||||
|
import iris.data.MsgStatus
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.media.MediaCache
|
import iris.media.MediaCache
|
||||||
import iris.media.kindFromMime
|
import iris.media.kindFromMime
|
||||||
@@ -16,10 +18,13 @@ import iris.protocol.MediaOfferPayload
|
|||||||
import iris.protocol.MessagePayload
|
import iris.protocol.MessagePayload
|
||||||
import iris.protocol.MessageStopPayload
|
import iris.protocol.MessageStopPayload
|
||||||
import iris.protocol.NotificationPayload
|
import iris.protocol.NotificationPayload
|
||||||
|
import iris.protocol.ReadReceiptPayload
|
||||||
import iris.protocol.ROLE_ASSISTANT
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
import iris.protocol.SearchHit
|
import iris.protocol.SearchHit
|
||||||
import iris.protocol.SearchResultsPayload
|
import iris.protocol.SearchResultsPayload
|
||||||
|
import iris.protocol.StatusPayload
|
||||||
import iris.protocol.SyncDonePayload
|
import iris.protocol.SyncDonePayload
|
||||||
|
import iris.protocol.TYPE_ERROR
|
||||||
import iris.protocol.TYPE_NOTIFICATION
|
import iris.protocol.TYPE_NOTIFICATION
|
||||||
import iris.protocol.TYPE_CHANNEL_CREATED
|
import iris.protocol.TYPE_CHANNEL_CREATED
|
||||||
import iris.protocol.TYPE_CHANNEL_DELETED
|
import iris.protocol.TYPE_CHANNEL_DELETED
|
||||||
@@ -31,7 +36,9 @@ import iris.protocol.TYPE_MESSAGE
|
|||||||
import iris.protocol.TYPE_MESSAGE_START
|
import iris.protocol.TYPE_MESSAGE_START
|
||||||
import iris.protocol.TYPE_MESSAGE_STOP
|
import iris.protocol.TYPE_MESSAGE_STOP
|
||||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||||
|
import iris.protocol.TYPE_READ_RECEIPT
|
||||||
import iris.protocol.TYPE_SEARCH_RESULTS
|
import iris.protocol.TYPE_SEARCH_RESULTS
|
||||||
|
import iris.protocol.TYPE_STATUS
|
||||||
import iris.protocol.TYPE_SYNC_DONE
|
import iris.protocol.TYPE_SYNC_DONE
|
||||||
import iris.protocol.TYPE_TOOL_END
|
import iris.protocol.TYPE_TOOL_END
|
||||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||||
@@ -89,6 +96,10 @@ class IrisController(
|
|||||||
private val _homeChannel = MutableStateFlow("android:default")
|
private val _homeChannel = MutableStateFlow("android:default")
|
||||||
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
||||||
|
|
||||||
|
/** Gateway health state (M5: status frame; null = never received). */
|
||||||
|
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||||
|
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
||||||
|
|
||||||
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
||||||
private val _threadsEnabled = MutableStateFlow(false)
|
private val _threadsEnabled = MutableStateFlow(false)
|
||||||
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
|
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
|
||||||
@@ -259,6 +270,18 @@ class IrisController(
|
|||||||
TYPE_TYPING -> {
|
TYPE_TYPING -> {
|
||||||
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
||||||
}
|
}
|
||||||
|
TYPE_READ_RECEIPT -> {
|
||||||
|
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
|
||||||
|
}
|
||||||
|
TYPE_STATUS -> {
|
||||||
|
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
|
||||||
|
}
|
||||||
|
TYPE_ERROR -> {
|
||||||
|
// M7: error frames are global, not per-message — fail
|
||||||
|
// any optimistic sends still in flight so they don't
|
||||||
|
// sit at "sending…" forever.
|
||||||
|
chat.failPending()
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +398,16 @@ class IrisController(
|
|||||||
_attachments.value = emptyList()
|
_attachments.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** M7: resend a failed user message (tap on the failed bubble). */
|
||||||
|
fun retrySend(messageId: String) {
|
||||||
|
val lane = chat.currentLane.value
|
||||||
|
val (chatId, threadId) = chat.parseLane(lane)
|
||||||
|
val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return
|
||||||
|
if (item.status != MsgStatus.Failed) return
|
||||||
|
chat.rearmForRetry(lane, messageId)
|
||||||
|
client.sendMessage(chatId, item.text, threadId, item.media.map { it.mediaId })
|
||||||
|
}
|
||||||
|
|
||||||
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
|
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
|
||||||
|
|
||||||
/** Stage a picked file: upload it, then keep it as a pending attachment. */
|
/** Stage a picked file: upload it, then keep it as a pending attachment. */
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
|||||||
package iris.ui.screens
|
package iris.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -9,11 +10,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -24,10 +26,12 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
|
import iris.ui.theme.IrisColors
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,53 +68,61 @@ fun ConnectScreen(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
Column(
|
||||||
value = url,
|
modifier = Modifier
|
||||||
onValueChange = { url = it },
|
.fillMaxWidth()
|
||||||
label = { Text("Server URL") },
|
.clip(RoundedCornerShape(16.dp))
|
||||||
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
.background(IrisColors.surface)
|
||||||
singleLine = true,
|
.padding(16.dp),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = token,
|
|
||||||
onValueChange = { token = it },
|
|
||||||
label = { Text("Pairing token") },
|
|
||||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (busy) return@Button
|
|
||||||
busy = true
|
|
||||||
error = null
|
|
||||||
scope.launch {
|
|
||||||
val result = controller.connect(url.trim(), token.trim())
|
|
||||||
busy = false
|
|
||||||
if (result.isFailure) {
|
|
||||||
error = result.exceptionOrNull()?.message ?: "connection failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
) {
|
||||||
Text(if (busy) "Testing connection…" else "Test & Connect")
|
OutlinedTextField(
|
||||||
}
|
value = url,
|
||||||
|
onValueChange = { url = it },
|
||||||
if (error != null) {
|
label = { Text("Server URL") },
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
||||||
Text(
|
singleLine = true,
|
||||||
error!!,
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
color = MaterialTheme.colorScheme.error,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = { token = it },
|
||||||
|
label = { Text("Pairing token") },
|
||||||
|
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (busy) return@Button
|
||||||
|
busy = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
val result = controller.connect(url.trim(), token.trim())
|
||||||
|
busy = false
|
||||||
|
if (result.isFailure) {
|
||||||
|
error = result.exceptionOrNull()?.message ?: "connection failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (busy) "Testing connection…" else "Test & Connect")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
error!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
@@ -121,4 +133,22 @@ fun ConnectScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M7: shown while the initial connection is in flight (state == Connecting). */
|
||||||
|
@Composable
|
||||||
|
fun ConnectingScreen(host: String) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
"Connecting to $host…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package iris.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized palette (M7). Dark is the only theme; every screen reads colors
|
||||||
|
* from here instead of hard-coding literals.
|
||||||
|
*/
|
||||||
|
object IrisColors {
|
||||||
|
// Base
|
||||||
|
val background = Color(0xFF1B1E28)
|
||||||
|
val surface = Color(0xFF222634)
|
||||||
|
val onBackground = Color(0xFFE8EAF0)
|
||||||
|
val onSurface = Color(0xFFE8EAF0)
|
||||||
|
val primary = Color(0xFF4F7CFF)
|
||||||
|
|
||||||
|
// Text shades (brightest to dimmest)
|
||||||
|
val textBright = Color(0xFFD7DBE5)
|
||||||
|
val textTertiary = Color(0xFFC7CCD8)
|
||||||
|
val textSecondary = Color(0xFFB9C0D0)
|
||||||
|
val textMuted = Color(0xFF9AA3B5)
|
||||||
|
val textDim = Color(0xFF8A93A6)
|
||||||
|
|
||||||
|
// Bubbles
|
||||||
|
val bubbleUser = primary
|
||||||
|
val bubbleAssistant = Color(0xFF2A2E3B)
|
||||||
|
val bubbleCommentary = Color(0xFF23262F)
|
||||||
|
|
||||||
|
// Panels, chips, rows
|
||||||
|
val panel = Color(0xFF20242E)
|
||||||
|
val chip = Color(0xFF2A2E3B)
|
||||||
|
val chipSelected = Color(0xFF2A3550)
|
||||||
|
val rowSelected = Color(0xFF232838)
|
||||||
|
val reasoningPanel = Color(0xFF1E212B)
|
||||||
|
val divider = Color(0xFF2A2E3B)
|
||||||
|
|
||||||
|
// Status
|
||||||
|
val statusGrey = Color(0xFF9E9E9E)
|
||||||
|
val statusAmber = Color(0xFFFFC107)
|
||||||
|
val statusGreen = Color(0xFF4CAF50)
|
||||||
|
val statusRed = Color(0xFFF44336)
|
||||||
|
|
||||||
|
// M7: failed-send hint on the accent user bubble
|
||||||
|
val errorText = Color(0xFFFF8A80)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val IrisDark = darkColorScheme(
|
||||||
|
background = IrisColors.background,
|
||||||
|
surface = IrisColors.surface,
|
||||||
|
onBackground = IrisColors.onBackground,
|
||||||
|
onSurface = IrisColors.onSurface,
|
||||||
|
onSurfaceVariant = IrisColors.textDim,
|
||||||
|
primary = IrisColors.primary,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
error = IrisColors.statusRed,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IrisTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(colorScheme = IrisDark) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic pastel avatar color from a channel name (M7 rail/drawer). */
|
||||||
|
fun avatarColor(name: String): Color {
|
||||||
|
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
|
||||||
|
return Color.hsv(hue, 0.5f, 0.75f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package iris.util
|
||||||
|
|
||||||
|
/** Locale-aware day label for chat date separators (M7), e.g. "7. August". */
|
||||||
|
expect fun formatDayLabel(epochMillis: Long): String
|
||||||
|
|
||||||
|
/** Locale-aware "HH:mm" time for bubble timestamps (M7). */
|
||||||
|
expect fun formatTime(epochMillis: Long): String
|
||||||
|
|
||||||
|
/** Local calendar-day key used to detect date changes between messages (M7). */
|
||||||
|
expect fun localDayKey(epochMillis: Long): String
|
||||||
|
|
||||||
|
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
|
||||||
|
fun hostFromUrl(url: String): String {
|
||||||
|
val noScheme = url.trim().substringAfter("://")
|
||||||
|
return noScheme.substringBefore("/").ifBlank { url.trim() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package iris.util
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// jvmMain is the intermediate source set for both androidMain (minSdk 29,
|
||||||
|
// java.time available) and desktopMain, so one actual covers both targets.
|
||||||
|
|
||||||
|
actual fun formatDayLabel(epochMillis: Long): String {
|
||||||
|
if (epochMillis <= 0) return ""
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val date = Instant.ofEpochMilli(epochMillis).atZone(zone)
|
||||||
|
val now = Instant.now().atZone(zone)
|
||||||
|
val pattern = if (date.year == now.year) "d. MMMM" else "d. MMMM, yyyy"
|
||||||
|
return DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun formatTime(epochMillis: Long): String {
|
||||||
|
if (epochMillis <= 0) return ""
|
||||||
|
return DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault())
|
||||||
|
.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun localDayKey(epochMillis: Long): String =
|
||||||
|
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString()
|
||||||
@@ -183,10 +183,21 @@ Agent-sent media is available; app pulls bytes.
|
|||||||
"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,"filename":"clip.mp4"}}
|
"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,"filename":"clip.mp4"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `status`
|
### `read.receipt`
|
||||||
Gateway lifecycle / session info.
|
The gateway acknowledges that the agent has received and started processing
|
||||||
|
the user's message. The app uses it to show ✓✓ on user bubbles.
|
||||||
```json
|
```json
|
||||||
{"type":"status","payload":{"state":"online","session":{"chat_id":"…","model":"…","tokens":11}}}
|
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}}
|
||||||
|
```
|
||||||
|
Emitted to the originating connection when a `message.send` is accepted for
|
||||||
|
processing (at the moment it is handed to the agent), for user-originated
|
||||||
|
messages only.
|
||||||
|
|
||||||
|
### `status`
|
||||||
|
Gateway health state. Broadcast to all connected clients at startup
|
||||||
|
(`state: "online"`); `restarting` / `degraded` are reserved for future use.
|
||||||
|
```json
|
||||||
|
{"type":"status","payload":{"state":"online"}}
|
||||||
```
|
```
|
||||||
`state` ∈ `online | restarting | degraded`.
|
`state` ∈ `online | restarting | degraded`.
|
||||||
|
|
||||||
|
|||||||
@@ -92,4 +92,26 @@ security principal (the token is).
|
|||||||
- [ ] Redact all secrets in logs.
|
- [ ] Redact all secrets in logs.
|
||||||
- [ ] WSS + cert pinning for remote.
|
- [ ] WSS + cert pinning for remote.
|
||||||
- [ ] Outbox retention cap + prune.
|
- [ ] Outbox retention cap + prune.
|
||||||
- [ ] Fail-closed secret reads under multiplexing.
|
- [ ] Fail-closed secret reads under multiplexing.
|
||||||
|
|
||||||
|
## M7 verification (2026-08-20)
|
||||||
|
|
||||||
|
Status of the §9.7 hardening checklist plus the related gaps found in the
|
||||||
|
M7 research pass. "verified" = implemented and covered by
|
||||||
|
`hermes-agent/tests/gateway/test_android.py` (35 tests) or the app build;
|
||||||
|
"gap" = known limitation with the planned mitigation.
|
||||||
|
|
||||||
|
| # | Item | Status | Evidence / mitigation |
|
||||||
|
|---|------|--------|-----------------------|
|
||||||
|
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
|
||||||
|
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) |
|
||||||
|
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
|
||||||
|
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
|
||||||
|
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
|
||||||
|
| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default |
|
||||||
|
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
|
||||||
|
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
||||||
|
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap |
|
||||||
|
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
|
||||||
|
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
|
||||||
|
| 12 | Gap: in-app QR scanner | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later |
|
||||||
+33
-5
@@ -179,18 +179,46 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
|
|
||||||
## M7 — Polish + E2E + docs
|
## M7 — Polish + E2E + docs
|
||||||
**Goal:** ship-quality.
|
**Goal:** ship-quality.
|
||||||
- [ ] Telegram-style layout pass (per reference image): header, bubbles, date
|
- [x] Telegram-style layout pass (per reference image): header, bubbles, date
|
||||||
separators, ✓✓, model/token footer, banner, bottom bar.
|
separators, ✓✓, model/token footer, banner, bottom bar.
|
||||||
- [ ] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
|
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
|
||||||
reconnecting/degraded states with honest copy.
|
reconnecting/degraded states with honest copy.
|
||||||
- [ ] Full E2E suite (`13-testing.md` scenarios 1–12) automated where possible.
|
- [x] Full E2E suite (`13-testing.md` scenarios 1–12) automated where possible.
|
||||||
- [ ] Docs: `docs/protocol/frames.schema.json` finalized; `docs/setup.md`
|
- [x] Docs: `docs/protocol/frames.schema.json` finalized; `docs/setup.md`
|
||||||
(user-facing pairing + FCM/ntfy + remote access); root README.
|
(user-facing pairing + FCM/ntfy + remote access); root README.
|
||||||
- [ ] Security hardening checklist (`09-pairing-security.md`) verified.
|
- [x] Security hardening checklist (`09-pairing-security.md`) verified.
|
||||||
- **Demo:** end-to-end on phone + desktop simultaneously; cron into a channel;
|
- **Demo:** end-to-end on phone + desktop simultaneously; cron into a channel;
|
||||||
push; media; search.
|
push; media; search.
|
||||||
- **Accept:** all feature-checklist items pass on-device; E2E green; docs
|
- **Accept:** all feature-checklist items pass on-device; E2E green; docs
|
||||||
complete; `hermes-agent/` still never committed.
|
complete; `hermes-agent/` still never committed.
|
||||||
|
- **Status (2026-08-20):** Layout pass verified on-device (MIX 2S): header
|
||||||
|
with avatar + "Bot" subtitle + overflow menu (rename channel, forget
|
||||||
|
pairing), centered date-separator pill, bubbles with in-bubble timestamps,
|
||||||
|
user ✓/✓✓ driven by the new `read.receipt` frame (gateway emits it when the
|
||||||
|
agent takes the message; late-joining clients also get the current `status`
|
||||||
|
state on hello.ack), model/token footer, letter-avatar channel rail/drawer
|
||||||
|
with active highlight, restyled bottom bar. Theming centralized in
|
||||||
|
`ui/theme/Theme.kt` (dark default, single accent; all hard-coded colors
|
||||||
|
replaced). States: dedicated connecting screen, reconnecting +
|
||||||
|
degraded/restarting banners (new `status` frame), send-failure rollback
|
||||||
|
with tap-to-retry. E2E: `tests/e2e.py` driver automates scenarios 1–12
|
||||||
|
against the live gateway — 9 PASS / 2 PARTIAL (push device-notification
|
||||||
|
leg + gateway-kill leg are manual) / 1 SKIP (commentary is
|
||||||
|
model-dependent) / 0 FAIL; `ws_probe.py` gained `--assert-turn/
|
||||||
|
reasoning/tools/commentary/read-receipt/status` plus `--search`,
|
||||||
|
`--channel-*`, `--watch` modes. Docs: `frames.schema.json` finalized
|
||||||
|
(mirrors code exactly; 17 unimplemented frames moved to
|
||||||
|
`x-planned-frames`; 6 deltas + 3 drift fixes), `docs/setup.md` added
|
||||||
|
(pairing + FCM/ntfy + remote access + troubleshooting), root README
|
||||||
|
quickstart + status updated. Security: inbound JSON-frame rate limit
|
||||||
|
(20/s, burst 40, binary upload chunks exempt) with `rate_limited` error +
|
||||||
|
close; Android token moved to EncryptedSharedPreferences with one-time
|
||||||
|
plain→encrypted migration; `.pre-commit-config.yaml` commits the
|
||||||
|
hermes-agent guard; `09-pairing-security.md` M7 verification table (5
|
||||||
|
items verified, 3 documented gaps: in-app QR scan, WSS cert pinning,
|
||||||
|
mechanical log redaction). Known: commentary scenario is model-dependent
|
||||||
|
(SKIP); FCM path needs a Firebase project to exercise; M6's formal
|
||||||
|
desktop parity pass + macOS/Windows packaging remain open.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -62,6 +62,6 @@ project** (`app/`) with a shared KMP module (`app/shared`).
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- **Phase:** Planning complete → ready to implement (Milestone M0).
|
- **Phase:** M0–M6 complete; M7 (polish + E2E + docs) in progress.
|
||||||
- **Owner decisions locked:** see [`16-open-questions.md`](16-open-questions.md).
|
- **Owner decisions locked:** see [`16-open-questions.md`](16-open-questions.md).
|
||||||
- **Last updated:** 2026-08-19.
|
- **Last updated:** 2026-08-19.
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"hello.ack": {
|
"hello.ack": {
|
||||||
"description": "Pairing succeeded.",
|
"description": "Pairing succeeded.",
|
||||||
"payload": {
|
"payload": {
|
||||||
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "pickers": {"type":"boolean"} } },
|
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } },
|
||||||
"sync_cursor": { "type": "integer" },
|
"sync_cursor": { "type": "integer" },
|
||||||
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
|
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
|
||||||
}
|
}
|
||||||
@@ -41,30 +41,21 @@
|
|||||||
},
|
},
|
||||||
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
|
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
|
||||||
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
|
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
|
||||||
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" } } },
|
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } },
|
||||||
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
||||||
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
|
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
|
||||||
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
|
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
|
||||||
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
|
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
|
||||||
"typing": { "payload": { "on": { "type": "boolean" } } },
|
"typing": { "payload": { "on": { "type": "boolean" } } },
|
||||||
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
|
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
|
||||||
"picker.model": { "payload": { "picker_id": { "type": "string" }, "current_model": { "type": "string" }, "current_provider": { "type": "string" }, "providers": { "type": "array", "items": { "type": "object", "properties": { "id": {"type":"string"}, "label": {"type":"string"}, "models": { "type": "array", "items": { "type": "object", "properties": { "id": {"type":"string"}, "label": {"type":"string"} } } } } } } } },
|
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
|
||||||
"picker.choice": { "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object", "properties": { "value": {"type":"string"}, "label": {"type":"string"}, "is_current": {"type":"boolean"} } } } } },
|
|
||||||
"picker.clarify": { "payload": { "picker_id": { "type": "string" }, "question": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object" } } } },
|
|
||||||
"picker.approval": { "payload": { "picker_id": { "type": "string" }, "command": { "type": "string" }, "description": { "type": "string" } } },
|
|
||||||
"picker.confirm": { "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "message": { "type": "string" } } },
|
|
||||||
"channel.list": { "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
|
|
||||||
"channel.created": { "payload": { "$ref": "#/definitions/channel" } },
|
"channel.created": { "payload": { "$ref": "#/definitions/channel" } },
|
||||||
"channel.renamed": { "payload": { "chat_id": { "type": "string" }, "name": { "type": "string" } } },
|
"channel.renamed": { "description": "Also the response to channel.set_default (carries the full entry incl. is_default).", "payload": { "$ref": "#/definitions/channel" } },
|
||||||
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
|
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
|
||||||
"history": { "description": "Response to history request; page of messages oldest→newest.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string"}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"} } } }, "has_more": { "type": "boolean" }, "oldest_message_id": { "type": "string" } } },
|
|
||||||
"commands.catalog": { "description": "Full slash-command catalog.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": {"type":"string"}, "description": {"type":"string"}, "args_hint": {"type":"string"}, "category": {"type":"string"} } } } } },
|
|
||||||
"commands.complete": { "description": "Autocomplete matches for a typed prefix.", "payload": { "prefix": { "type": "string" }, "matches": { "type": "array", "items": { "type": "object", "properties": { "name": {"type":"string"}, "description": {"type":"string"}, "args_hint": {"type":"string"} } } } } },
|
|
||||||
"agent.busy": { "description": "Agent is processing; app shows thinking indicator.", "payload": { "reason": { "type": "string", "enum": ["processing", "tool", "waiting_input", "cron"] } } },
|
|
||||||
"agent.idle": { "description": "Agent turn complete; clear thinking indicator.", "payload": {} },
|
|
||||||
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
|
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
|
||||||
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
|
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
|
||||||
"status": { "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] }, "session": { "type": "object" } } },
|
"read.receipt": { "description": "Agent received and started processing the user's message; app shows ✓✓ on user bubbles. Emitted to the originating connection when a message.send is accepted for processing.", "payload": { "message_id": { "type": "string" } } },
|
||||||
|
"status": { "description": "Gateway health state; broadcast to all connected clients at startup (state=online).", "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] } } },
|
||||||
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
|
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
|
||||||
"pong": { "payload": { "ts": { "type": "integer" } } },
|
"pong": { "payload": { "ts": { "type": "integer" } } },
|
||||||
"sync.done": { "payload": { "cursor": { "type": "integer" } } },
|
"sync.done": { "payload": { "cursor": { "type": "integer" } } },
|
||||||
@@ -77,18 +68,12 @@
|
|||||||
"media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } },
|
"media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } },
|
||||||
"media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
|
"media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
|
||||||
"media.pull": { "payload": { "media_id": { "type": "string" } } },
|
"media.pull": { "payload": { "media_id": { "type": "string" } } },
|
||||||
"picker.select": { "payload": { "picker_id": { "type": "string" }, "value": { "type": "string" } } },
|
|
||||||
"channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
|
"channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
|
||||||
"channel.rename": { "payload": { "name": { "type": "string" } } },
|
"channel.rename": { "payload": { "name": { "type": "string" } } },
|
||||||
"channel.set_default": { "payload": {} },
|
"channel.set_default": { "payload": {} },
|
||||||
"channel.delete": { "payload": {} },
|
"channel.delete": { "payload": {} },
|
||||||
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" } } },
|
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
|
||||||
"history": { "description": "Load a page of messages (initial open / scroll-up).", "payload": { "before_message_id": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 200 } } },
|
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
||||||
"commands.catalog": { "description": "Fetch full slash-command catalog.", "payload": {} },
|
|
||||||
"commands.complete": { "description": "Autocomplete for typed /prefix.", "payload": { "prefix": { "type": "string" } } },
|
|
||||||
"agent.stop": { "description": "Abort current agent turn.", "payload": {} },
|
|
||||||
"agent.steer": { "description": "Inject steering message mid-turn.", "payload": { "text": { "type": "string" } } },
|
|
||||||
"read.receipt": { "description": "User viewed message; server stores + broadcasts to other devices.", "payload": { "chat_id": { "type": "string" }, "message_id": { "type": "string" } } },
|
|
||||||
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
||||||
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
||||||
"ping": { "payload": { "ts": { "type": "integer" } } }
|
"ping": { "payload": { "ts": { "type": "integer" } } }
|
||||||
@@ -96,12 +81,31 @@
|
|||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
|
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
|
||||||
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"} } },
|
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"} } },
|
||||||
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"} } }
|
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }
|
||||||
},
|
},
|
||||||
|
"x-planned-frames": [
|
||||||
|
{ "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." },
|
||||||
|
{ "name": "picker.choice", "direction": "server_to_app", "note": "Generic choice picker prompt. Planned, not implemented." },
|
||||||
|
{ "name": "picker.clarify", "direction": "server_to_app", "note": "Clarify picker prompt. Planned, not implemented (clarifies arrive as notification + message)." },
|
||||||
|
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
|
||||||
|
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
|
||||||
|
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
|
||||||
|
{ "name": "history", "direction": "server_to_app", "note": "Paged history response. Planned, not implemented (catch-up is sync/outbox replay)." },
|
||||||
|
{ "name": "history", "direction": "app_to_server", "note": "Paged history request. Planned, not implemented (catch-up is sync/outbox replay)." },
|
||||||
|
{ "name": "commands.catalog", "direction": "server_to_app", "note": "Slash-command catalog. Planned, not implemented." },
|
||||||
|
{ "name": "commands.catalog", "direction": "app_to_server", "note": "Slash-command catalog request. Planned, not implemented." },
|
||||||
|
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented." },
|
||||||
|
{ "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." },
|
||||||
|
{ "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." },
|
||||||
|
{ "name": "agent.idle", "direction": "server_to_app", "note": "Agent-idle indicator. Planned, not implemented." },
|
||||||
|
{ "name": "agent.stop", "direction": "app_to_server", "note": "Abort current agent turn. Planned, not implemented." },
|
||||||
|
{ "name": "agent.steer", "direction": "app_to_server", "note": "Steer the agent mid-turn. Planned, not implemented." },
|
||||||
|
{ "name": "read.receipt", "direction": "app_to_server", "note": "User-viewed receipt (multi-device read state). Planned, not implemented (read.receipt is server→app only)." }
|
||||||
|
],
|
||||||
"reliability": {
|
"reliability": {
|
||||||
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
|
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
|
||||||
"never_dropped": ["message", "message.stop", "tool.end", "notification", "picker.*", "channel.*", "agent.busy", "agent.idle", "history", "commands.catalog", "commands.complete", "search.results", "error"],
|
"never_dropped": ["message", "message.stop", "tool.end", "notification", "channel.*", "search.results", "error"],
|
||||||
"coalescable_under_backpressure": ["message.update", "tool.progress"],
|
"coalescable_under_backpressure": ["message.update", "tool.progress"],
|
||||||
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
|
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
|
||||||
}
|
}
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
# Setup — Pairing a Device
|
||||||
|
|
||||||
|
User-facing guide: get a phone or desktop talking to your hermes gateway in
|
||||||
|
under 10 minutes. Design rationale lives in the numbered docs
|
||||||
|
([`09-pairing-security.md`](09-pairing-security.md),
|
||||||
|
[`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is
|
||||||
|
just the steps.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Where | You need |
|
||||||
|
|---|---|
|
||||||
|
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) |
|
||||||
|
| Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device |
|
||||||
|
| Desktop build machine | JDK 17 only |
|
||||||
|
|
||||||
|
Gradle needs no system install — both apps use the project wrapper
|
||||||
|
(`./gradlew`). First-time machine setup: [`12-toolchain.md`](12-toolchain.md).
|
||||||
|
|
||||||
|
## 1. Gateway setup (on the gateway host)
|
||||||
|
|
||||||
|
Install the plugin into the live hermes home (dev: a symlink from the monorepo
|
||||||
|
root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.hermes/plugins
|
||||||
|
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
|
||||||
|
hermes gateway status # should list "android"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the interactive setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway setup
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
- Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in
|
||||||
|
`~/.hermes/.env` (it prints the token once, at generation).
|
||||||
|
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
|
||||||
|
and push backend (`fcm` or `ntfy`, default `fcm`).
|
||||||
|
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
|
||||||
|
string) and the server URL (`ws://<host>:8790/ws`).
|
||||||
|
|
||||||
|
Then start the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway # or: hermes gateway restart after config changes
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** the default bind host `127.0.0.1` only accepts connections from the
|
||||||
|
> gateway host itself (e.g. a desktop app on the same machine). For a phone on
|
||||||
|
> the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set
|
||||||
|
> `ANDROID_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
|
||||||
|
|
||||||
|
## 2. Android app
|
||||||
|
|
||||||
|
Build and install (ADB device connected):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
./gradlew :androidApp:installDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
First run opens the **Connect** screen:
|
||||||
|
|
||||||
|
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (the URL printed by
|
||||||
|
`hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone).
|
||||||
|
2. **Pairing token** — from the `hermes gateway setup` output, or
|
||||||
|
`grep ANDROID_TOKEN ~/.hermes/.env` on the gateway host.
|
||||||
|
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
|
||||||
|
pairing and connects.
|
||||||
|
|
||||||
|
> **Honest limitation:** QR scanning is **not** supported in the app yet. The
|
||||||
|
> server prints a QR payload, but pairing is manual URL + token entry only.
|
||||||
|
|
||||||
|
## 3. Desktop app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
./gradlew :desktopApp:run # dev run
|
||||||
|
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pairing is the same Connect screen (URL + token); the token is stored in the OS
|
||||||
|
keyring (with an encrypted-file fallback). Desktop push is tray icon + OS
|
||||||
|
notifications (no FCM).
|
||||||
|
|
||||||
|
> **Known issue:** on Linux with JDK 17 the jpackage launcher prints a
|
||||||
|
> non-fatal `pure virtual method called` warning (JDK-8348560, a
|
||||||
|
> jpackage/Linux launcher bug). The app runs and connects regardless.
|
||||||
|
|
||||||
|
## 4. Push notifications
|
||||||
|
|
||||||
|
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
||||||
|
outbox, so nothing is lost. Push fires when the device is offline, plus for
|
||||||
|
high-priority events (approvals, clarifies, cron) even when a device is live.
|
||||||
|
|
||||||
|
### FCM (default; needs a Firebase project)
|
||||||
|
|
||||||
|
1. Create a Firebase project (console.firebase.google.com) and add an Android
|
||||||
|
app with the app's applicationId; download `google-services.json` into
|
||||||
|
`app/androidApp/`.
|
||||||
|
2. Create a service account (Project settings → Service accounts → Generate new
|
||||||
|
private key) and store the JSON path in `~/.hermes/.env`:
|
||||||
|
`ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
|
||||||
|
3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default).
|
||||||
|
|
||||||
|
Without a Firebase project the FCM path is **inert** (the app's FCM service
|
||||||
|
does nothing) — use ntfy below, or add Firebase later.
|
||||||
|
|
||||||
|
**What you see:** system notifications for new messages when the app is
|
||||||
|
backgrounded; tapping one deep-links to the chat.
|
||||||
|
|
||||||
|
### ntfy (zero-config fallback)
|
||||||
|
|
||||||
|
```
|
||||||
|
ANDROID_PUSH_BACKEND=ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
|
||||||
|
the server publishes to it.
|
||||||
|
- `NTFY_SERVER_URL` defaults to `https://ntfy.sh`. **Self-hosted ntfy is
|
||||||
|
recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its
|
||||||
|
web UI instead of the stream), while a self-hosted instance gives reliable
|
||||||
|
SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`.
|
||||||
|
|
||||||
|
**What you see:** a low-priority foreground "ntfy listener" notification while
|
||||||
|
the app is off; incoming pushes trigger a silent sync.
|
||||||
|
|
||||||
|
## 5. Remote access
|
||||||
|
|
||||||
|
- **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP;
|
||||||
|
the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
|
||||||
|
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
|
||||||
|
the edge, forward the WebSocket to `127.0.0.1:8790`.
|
||||||
|
- **WSS:** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY` (paths, in
|
||||||
|
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
|
||||||
|
|
||||||
|
> **Honest limitation:** the app has **no certificate pinning** yet, so
|
||||||
|
> self-signed certs won't work — remote access requires **CA-signed** WSS for
|
||||||
|
> now. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the default.
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause / fix |
|
||||||
|
|---|---|
|
||||||
|
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `ANDROID_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
|
||||||
|
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
||||||
|
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
|
||||||
|
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
|
||||||
|
|
||||||
|
Smoke test without the app (from the gateway host):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
import asyncio, json, websockets
|
||||||
|
async def main():
|
||||||
|
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
||||||
|
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
||||||
|
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe",
|
||||||
|
"caps":{"min_protocol":1}}}))
|
||||||
|
print("recv:", await ws.recv())
|
||||||
|
asyncio.run(main())
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
||||||
|
wrong.
|
||||||
@@ -691,6 +691,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
self.max_upload_bytes = int(
|
self.max_upload_bytes = int(
|
||||||
extra.get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES)
|
extra.get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES)
|
||||||
)
|
)
|
||||||
|
self._gateway_status = protocol.STATUS_ONLINE
|
||||||
|
|
||||||
# Home channel: the core hook turns the env-seeded ``home_channel``
|
# Home channel: the core hook turns the env-seeded ``home_channel``
|
||||||
# dict into a HomeChannel dataclass on the config; config.yaml may
|
# dict into a HomeChannel dataclass on the config; config.yaml may
|
||||||
@@ -796,6 +797,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
self._connected = False
|
self._connected = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# M5: announce gateway health to connected clients (none yet at
|
||||||
|
# startup; the frame + plumbing exist for future transitions).
|
||||||
|
await self._ws_server.broadcast(protocol.status(self._gateway_status))
|
||||||
|
|
||||||
# M3: ensure the default (home) channel exists in the directory so the
|
# M3: ensure the default (home) channel exists in the directory so the
|
||||||
# app's channel list and cron home delivery have a stable anchor.
|
# app's channel list and cron home delivery have a stable anchor.
|
||||||
try:
|
try:
|
||||||
@@ -1542,6 +1547,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
media_types=media_types,
|
media_types=media_types,
|
||||||
)
|
)
|
||||||
await self.handle_message(event)
|
await self.handle_message(event)
|
||||||
|
# M5: acknowledge the user message to the originating device (the
|
||||||
|
# app shows ✓✓) at the moment it is handed to the agent.
|
||||||
|
await self._ws_server.send_to(
|
||||||
|
device_id,
|
||||||
|
protocol.read_receipt(chat_id, message_id),
|
||||||
|
)
|
||||||
|
|
||||||
# ── M4: inbound media (app -> agent) ──────────────────────────────────
|
# ── M4: inbound media (app -> agent) ──────────────────────────────────
|
||||||
#
|
#
|
||||||
@@ -2023,6 +2034,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
# ── hello.ack helpers ─────────────────────────────────────────────────
|
# ── hello.ack helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def gateway_status(self) -> str:
|
||||||
|
"""Current gateway health state (sent to each pairing connection)."""
|
||||||
|
return self._gateway_status
|
||||||
|
|
||||||
def server_caps(self) -> Dict[str, Any]:
|
def server_caps(self) -> Dict[str, Any]:
|
||||||
"""Capability flags advertised in ``hello.ack`` (M4 surface)."""
|
"""Capability flags advertised in ``hello.ack`` (M4 surface)."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Milestone M2: message.start/update/stop, reasoning (on message /
|
|||||||
message.stop), tool.start/progress/end, commentary.
|
message.stop), tool.start/progress/end, commentary.
|
||||||
Milestone M3: channel.*, search, sync.
|
Milestone M3: channel.*, search, sync.
|
||||||
Milestone M4: media.*.
|
Milestone M4: media.*.
|
||||||
Milestone M5: notification, fcm.register, read.receipt.
|
Milestone M5: notification, fcm.register, read.receipt, status.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -79,6 +79,10 @@ TYPE_MEDIA_PULL_END = "media.pull.end"
|
|||||||
# Push / notifications (M5)
|
# Push / notifications (M5)
|
||||||
TYPE_NOTIFICATION = "notification"
|
TYPE_NOTIFICATION = "notification"
|
||||||
TYPE_FCM_REGISTER = "fcm.register"
|
TYPE_FCM_REGISTER = "fcm.register"
|
||||||
|
TYPE_READ_RECEIPT = "read.receipt"
|
||||||
|
|
||||||
|
# Gateway health (M5)
|
||||||
|
TYPE_STATUS = "status"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Error codes (``error`` frame payload.code)
|
# Error codes (``error`` frame payload.code)
|
||||||
@@ -116,6 +120,14 @@ NOTIF_GENERIC = "generic"
|
|||||||
# it decides whether to also show an in-app banner).
|
# it decides whether to also show an in-app banner).
|
||||||
HIGH_PRIORITY_NOTIF_KINDS = frozenset({NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON})
|
HIGH_PRIORITY_NOTIF_KINDS = frozenset({NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gateway health states (``status`` frame payload.state)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_ONLINE = "online"
|
||||||
|
STATUS_RESTARTING = "restarting"
|
||||||
|
STATUS_DEGRADED = "degraded"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Envelope
|
# Envelope
|
||||||
@@ -507,6 +519,25 @@ def fcm_register(
|
|||||||
return Frame(type=TYPE_FCM_REGISTER, payload=payload)
|
return Frame(type=TYPE_FCM_REGISTER, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def read_receipt(chat_id: str, message_id: str) -> Frame:
|
||||||
|
"""Ack to the originating device: the agent received and started
|
||||||
|
processing the user's message (the app shows ✓✓ on the user bubble)."""
|
||||||
|
return Frame(
|
||||||
|
type=TYPE_READ_RECEIPT,
|
||||||
|
chat_id=chat_id,
|
||||||
|
payload={"message_id": message_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gateway health frame (M5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def status(state: str) -> Frame:
|
||||||
|
"""Gateway health state (``state`` is one of the ``STATUS_*`` constants)."""
|
||||||
|
return Frame(type=TYPE_STATUS, payload={"state": state})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Media frames (M4)
|
# Media frames (M4)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,4 +4,73 @@ Run via hermes's hermetic runner (never bare pytest)::
|
|||||||
|
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
|
|
||||||
See ``docs/13-testing.md`` for the scenario list.
|
See ``docs/13-testing.md`` for the scenario list.
|
||||||
|
|
||||||
|
## WS probe (`ws_probe.py`)
|
||||||
|
|
||||||
|
Manual test-client harness: connects to the **real running gateway** and
|
||||||
|
drives a turn, printing every frame. Run with the hermes venv python
|
||||||
|
(needs `websockets`); the gateway must already be up::
|
||||||
|
|
||||||
|
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
|
||||||
|
--token <ANDROID_TOKEN> --send "hello"
|
||||||
|
|
||||||
|
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
||||||
|
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`,
|
||||||
|
`--timeout`), the probe has assertion and request modes:
|
||||||
|
|
||||||
|
- `--assert-turn` — assert the turn produced `message.start` → ≥1
|
||||||
|
`message.update` → `message.stop` (scenario 2).
|
||||||
|
- `--assert-reasoning` — assert the final `message.stop` carries a
|
||||||
|
non-empty `reasoning` field (scenario 3).
|
||||||
|
- `--assert-tools` — assert ≥1 `tool.start` with a matching `tool.end`
|
||||||
|
(matched by `index`; scenario 4).
|
||||||
|
- `--assert-commentary` — assert ≥1 `commentary` frame (scenario 5).
|
||||||
|
- `--assert-read-receipt` — assert a `read.receipt` frame arrives after
|
||||||
|
the sent message (new M7 frame; requires `--send`). **SKIPs** (exit 0,
|
||||||
|
prints `== SKIP: …`) when the frame never arrives, e.g. against a
|
||||||
|
gateway that predates the M7 frames.
|
||||||
|
- `--assert-status` — assert a `status` frame is received (new M7 frame;
|
||||||
|
**SKIPs** when absent).
|
||||||
|
- `--search QUERY [--scope all|chat] [--chat-id C]` — send a `search`
|
||||||
|
frame (`{query, scope, limit}`) and assert ≥1 hit in `search.results`
|
||||||
|
(scenario 8). With `--send`, the turn is driven first, then the search.
|
||||||
|
- `--channel-create NAME` / `--channel-delete CHAT_ID` /
|
||||||
|
`--channel-list` — M3 channel directory management; create prints
|
||||||
|
`== channel created: <chat_id>` for scripting.
|
||||||
|
- `--watch CHAT_ID` — wait up to `--timeout` for a message to land in
|
||||||
|
`CHAT_ID` (cron delivery E2E, scenario 7).
|
||||||
|
- `--offer-grace S` — with `--pull-offer`, keep listening S seconds after
|
||||||
|
the final message for a `media.offer` (offers are emitted post-turn,
|
||||||
|
right after the final; default 15).
|
||||||
|
|
||||||
|
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
|
||||||
|
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
|
||||||
|
acked, `6` timeout, `7` no final message, `8` upload/sync fail,
|
||||||
|
`9` pull fail, `10` assert-turn fail, `11` assert-reasoning fail,
|
||||||
|
`12` assert-tools fail, `13` assert-commentary fail, `14` search fail
|
||||||
|
(error or zero hits), `15` channel.create/list fail, `16` channel.delete
|
||||||
|
fail, `17` watch timeout, `18` read.receipt arrived before the sent
|
||||||
|
message, `19` status frame with empty payload.
|
||||||
|
|
||||||
|
## E2E driver (`e2e.py`)
|
||||||
|
|
||||||
|
Runs the `docs/13-testing.md` §13.4 scenarios 1–12 automated-where-
|
||||||
|
possible against the live gateway, invoking `ws_probe.py` (and the
|
||||||
|
`hermes` CLI for cron) as subprocesses. Prints PASS / PARTIAL / SKIP /
|
||||||
|
FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
|
||||||
|
|
||||||
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
|
||||||
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
||||||
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
||||||
|
|
||||||
|
The token is read from `$ANDROID_TOKEN`, else `hermes-agent/.env`, else
|
||||||
|
`~/.hermes/.env`. The gateway must already be running (the driver never
|
||||||
|
starts or stops it). It is idempotent: channels/jobs it creates are
|
||||||
|
cleaned up even on failure, and leftover `e2e-*` channels/jobs from
|
||||||
|
earlier runs are removed at start.
|
||||||
|
|
||||||
|
Scenario notes: 3 (reasoning) and 5 (commentary) are model-dependent and
|
||||||
|
SKIP rather than FAIL when the current model does not emit them; 11
|
||||||
|
(push) and 12 (reconnect/sync) are PARTIAL by design — the WS leg is
|
||||||
|
automated, the device-notification / gateway-kill leg is manual.
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""E2E driver: docs/13-testing.md §13.4 scenarios 1-12 against the live gateway.
|
||||||
|
|
||||||
|
Drives ws_probe.py (and the hermes CLI for cron) as subprocesses. For each
|
||||||
|
scenario prints PASS / PARTIAL / SKIP / FAIL with a one-line reason, then a
|
||||||
|
summary table. Exit 0 if no FAIL, 1 otherwise.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
|
||||||
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
||||||
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
||||||
|
|
||||||
|
The token is read from $ANDROID_TOKEN, else hermes-agent/.env, else
|
||||||
|
~/.hermes/.env. The gateway must already be running (this driver never
|
||||||
|
starts or stops it). Idempotent: channels/jobs it creates are cleaned up
|
||||||
|
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are
|
||||||
|
removed at start.
|
||||||
|
|
||||||
|
Scenario notes:
|
||||||
|
3 (reasoning) and 5 (commentary) are model-dependent: they SKIP (not
|
||||||
|
FAIL) when the current model does not emit reasoning / commentary.
|
||||||
|
11 (push) and 12 (reconnect) are PARTIAL by design: the WS leg is
|
||||||
|
automated, the device-notification / gateway-kill leg is manual.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
REPO = HERE.parent.parent
|
||||||
|
PY = REPO / "hermes-agent" / ".venv" / "bin" / "python"
|
||||||
|
PROBE = HERE / "ws_probe.py"
|
||||||
|
HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes"
|
||||||
|
DEFAULT_URL = "ws://127.0.0.1:8790/ws"
|
||||||
|
|
||||||
|
PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def find_token(cli_token: str) -> str:
|
||||||
|
if cli_token:
|
||||||
|
return cli_token
|
||||||
|
env = os.getenv("ANDROID_TOKEN")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
|
||||||
|
try:
|
||||||
|
for line in p.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("ANDROID_TOKEN="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe(env, url, token, *args, timeout=300):
|
||||||
|
cmd = [str(PY), str(PROBE), "--url", url, "--token", token, *args]
|
||||||
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
|
||||||
|
return p.returncode, p.stdout, p.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def run_hermes(env, *args, timeout=120):
|
||||||
|
cmd = [str(HERMES), *args]
|
||||||
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
|
||||||
|
return p.returncode, p.stdout, p.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def write_png(path: Path, color, size: int = 200) -> None:
|
||||||
|
"""Write a solid-color RGB PNG using only the stdlib (no PIL needed)."""
|
||||||
|
raw = b"".join(b"\x00" + bytes(color) * size for _ in range(size))
|
||||||
|
|
||||||
|
def chunk(tag: bytes, data: bytes) -> bytes:
|
||||||
|
return (struct.pack(">I", len(data)) + tag + data
|
||||||
|
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
||||||
|
|
||||||
|
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||||
|
path.write_bytes(
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
+ chunk(b"IHDR", ihdr)
|
||||||
|
+ chunk(b"IDAT", zlib.compress(raw))
|
||||||
|
+ chunk(b"IEND", b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_created_chat_id(out: str) -> str | None:
|
||||||
|
m = re.search(r"== channel created: (\S+)", out)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_leftovers(env, url, token) -> None:
|
||||||
|
"""Remove e2e-* channels / cron jobs left behind by earlier runs."""
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--channel-list")
|
||||||
|
if rc == 0:
|
||||||
|
for m in re.finditer(r"== channel: (\S+) name='(e2e-[^']*)'", out):
|
||||||
|
chat_id, name = m.group(1), m.group(2)
|
||||||
|
print(f" cleanup: removing leftover channel {chat_id} ({name})")
|
||||||
|
run_probe(env, url, token, "--channel-delete", chat_id)
|
||||||
|
rc, out, _ = run_hermes(env, "cron", "list")
|
||||||
|
if rc == 0:
|
||||||
|
for m in re.finditer(
|
||||||
|
r"(\S+) \[(?:active|paused)\]\s*\n\s*Name:\s+(e2e-cron-[^ \n]*)", out
|
||||||
|
):
|
||||||
|
job_id, name = m.group(1), m.group(2)
|
||||||
|
print(f" cleanup: removing leftover cron job {job_id} ({name})")
|
||||||
|
run_hermes(env, "cron", "remove", job_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scenarios (docs/13-testing.md §13.4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def s1_pair(env, url, token):
|
||||||
|
rc, _, _ = run_probe(env, url, "definitely-wrong-token", "--authfail", "--send", "")
|
||||||
|
if rc != 0:
|
||||||
|
return FAIL, f"wrong token was not rejected (rc={rc})"
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--send", "")
|
||||||
|
if rc != 0:
|
||||||
|
return FAIL, f"valid token did not pair (rc={rc})"
|
||||||
|
return PASS, "wrong token rejected; hello.ack on valid token"
|
||||||
|
|
||||||
|
|
||||||
|
def s2_text(env, url, token):
|
||||||
|
prompt = "Write a short poem about the ocean, at least 8 lines"
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
||||||
|
"--assert-turn", "--timeout", "120")
|
||||||
|
if rc == 0:
|
||||||
|
return PASS, "message.start -> >=1 message.update -> message.stop"
|
||||||
|
if rc == 10:
|
||||||
|
return FAIL, "no ordered start/update/stop segment"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
def s3_reasoning(env, url, token):
|
||||||
|
prompt = "Work out step by step: what is 17 * 23? Show your reasoning."
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
||||||
|
"--assert-reasoning", "--timeout", "120")
|
||||||
|
if rc == 0:
|
||||||
|
return PASS, "final message.stop carries non-empty reasoning"
|
||||||
|
if rc == 11:
|
||||||
|
return SKIP, "model returned no reasoning (model-dependent)"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
def s4_tools(env, url, token):
|
||||||
|
prompt = ("List the files in your current working directory using your "
|
||||||
|
"shell tool, then tell me how many there are")
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
||||||
|
"--assert-tools", "--timeout", "150")
|
||||||
|
if rc == 0:
|
||||||
|
return PASS, "tool.start with a matching tool.end"
|
||||||
|
if rc == 12:
|
||||||
|
return FAIL, "no tool.start/tool.end pair"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
def s5_commentary(env, url, token):
|
||||||
|
prompt = ("Research task: (1) use your shell tool to list the top-level "
|
||||||
|
"directories in /tmp, (2) report your findings so far, "
|
||||||
|
"(3) use your shell tool to count files in /tmp, "
|
||||||
|
"(4) report those findings too, (5) give a final summary of both")
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
||||||
|
"--assert-commentary", "--timeout", "150")
|
||||||
|
if rc == 0:
|
||||||
|
return PASS, "commentary frame observed"
|
||||||
|
if rc == 13:
|
||||||
|
return SKIP, "no commentary (model/agent-dependent per M2)"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
def s6_channels(env, url, token):
|
||||||
|
name = f"e2e-chan-{uuid.uuid4().hex[:6]}"
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--channel-create", name)
|
||||||
|
if rc != 0:
|
||||||
|
return FAIL, f"channel.create failed (rc={rc})"
|
||||||
|
chat_id = parse_created_chat_id(out)
|
||||||
|
if not chat_id:
|
||||||
|
return FAIL, "channel.created received but chat_id not parseable"
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--channel-delete", chat_id)
|
||||||
|
if rc != 0:
|
||||||
|
run_probe(env, url, token, "--channel-delete", chat_id) # best-effort
|
||||||
|
return FAIL, f"channel.delete failed (rc={rc})"
|
||||||
|
return PASS, f"created {chat_id} + deleted (cleanup)"
|
||||||
|
|
||||||
|
|
||||||
|
def s7_cron(env, url, token):
|
||||||
|
chan_name = f"e2e-cron-chan-{uuid.uuid4().hex[:6]}"
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--channel-create", chan_name)
|
||||||
|
if rc != 0:
|
||||||
|
return SKIP, f"could not create cron target channel (rc={rc})"
|
||||||
|
chat_id = parse_created_chat_id(out)
|
||||||
|
if not chat_id:
|
||||||
|
return FAIL, "channel.created received but chat_id not parseable"
|
||||||
|
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
|
||||||
|
deliver = f"android:{chat_id}"
|
||||||
|
rc, out, err = run_hermes(
|
||||||
|
env, "cron", "create", "1m",
|
||||||
|
"Reply with exactly: e2e cron delivery OK",
|
||||||
|
"--deliver", deliver, "--name", job_name,
|
||||||
|
)
|
||||||
|
job_id = None
|
||||||
|
if rc == 0:
|
||||||
|
m = re.search(r"Created job: (\S+)", out)
|
||||||
|
job_id = m.group(1) if m else None
|
||||||
|
try:
|
||||||
|
if rc != 0:
|
||||||
|
return SKIP, f"hermes cron create failed: {(err or out).strip()[:120]}"
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--watch", chat_id,
|
||||||
|
"--timeout", "330", timeout=400)
|
||||||
|
if rc == 0:
|
||||||
|
return PASS, f"one-shot cron job fired; message landed in {chat_id}"
|
||||||
|
return FAIL, f"no message in {chat_id} within 330s (probe rc={rc})"
|
||||||
|
finally:
|
||||||
|
if job_id:
|
||||||
|
run_hermes(env, "cron", "remove", job_id)
|
||||||
|
else:
|
||||||
|
# create succeeded but the id was not parseable: find by name.
|
||||||
|
_, list_out, _ = run_hermes(env, "cron", "list")
|
||||||
|
m = re.search(r"(\S+) \[active\]\s*\n\s*Name:\s+" + re.escape(job_name),
|
||||||
|
list_out)
|
||||||
|
if m:
|
||||||
|
run_hermes(env, "cron", "remove", m.group(1))
|
||||||
|
run_probe(env, url, token, "--channel-delete", chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
def s8_search(env, url, token):
|
||||||
|
marker = f"e2emarker{uuid.uuid4().hex[:8]}"
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--send",
|
||||||
|
f"Remember this marker phrase: {marker}. "
|
||||||
|
"Just acknowledge it briefly.",
|
||||||
|
"--timeout", "120")
|
||||||
|
if rc != 0:
|
||||||
|
return FAIL, f"setup message failed (rc={rc})"
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--send", "", "--search", marker)
|
||||||
|
if rc == 0:
|
||||||
|
return PASS, f"search for {marker!r} returned >=1 hit"
|
||||||
|
if rc == 14:
|
||||||
|
return FAIL, f"search for {marker!r} returned 0 hits"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
def s9_media_in(env, url, token):
|
||||||
|
png = Path(f"/tmp/e2e_in_{uuid.uuid4().hex[:6]}.png")
|
||||||
|
write_png(png, (30, 120, 220))
|
||||||
|
try:
|
||||||
|
rc, _, _ = run_probe(env, url, token, "--upload", str(png),
|
||||||
|
"--send", "describe this image briefly",
|
||||||
|
"--timeout", "120")
|
||||||
|
if rc == 0:
|
||||||
|
return PASS, "upload + vision reply (final message)"
|
||||||
|
if rc == 8:
|
||||||
|
return FAIL, "media upload failed"
|
||||||
|
if rc == 7:
|
||||||
|
return FAIL, "no final message after upload"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
finally:
|
||||||
|
png.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def s10_media_out(env, url, token):
|
||||||
|
prompt = ("Create a 100x100 orange square PNG in /tmp with your tools. "
|
||||||
|
"In your final reply, include the MEDIA:/absolute/path tag for "
|
||||||
|
"that file so it is delivered to me.")
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--send", prompt,
|
||||||
|
"--pull-offer", "--timeout", "150")
|
||||||
|
m = re.search(r"== pulled (\d+) bytes", out)
|
||||||
|
if rc == 0 and m and int(m.group(1)) > 0:
|
||||||
|
return PASS, f"media.offer pulled ({m.group(1)} bytes)"
|
||||||
|
if rc == 9:
|
||||||
|
return FAIL, "media pull failed"
|
||||||
|
return FAIL, "no media.offer pulled (agent did not deliver an image)"
|
||||||
|
|
||||||
|
|
||||||
|
def s11_push(env, url, token):
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--fcm-token", "test-token-123",
|
||||||
|
"--fcm-reg", "--send", "")
|
||||||
|
if rc != 0:
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
if "<- error" in out:
|
||||||
|
return FAIL, "error frame after fcm.register"
|
||||||
|
return PARTIAL, ("fcm.register accepted (no error frame); "
|
||||||
|
"device-notification leg is manual")
|
||||||
|
|
||||||
|
|
||||||
|
def s12_sync(env, url, token):
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--sync", "0")
|
||||||
|
if rc == 0 and "sync done" in out:
|
||||||
|
return PARTIAL, ("sync replay + sync.done verified; "
|
||||||
|
"gateway-kill/restart leg is manual")
|
||||||
|
if rc == 8:
|
||||||
|
return FAIL, "sync failed"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
SCENARIOS = [
|
||||||
|
(1, "pair", s1_pair),
|
||||||
|
(2, "text round-trip", s2_text),
|
||||||
|
(3, "reasoning", s3_reasoning),
|
||||||
|
(4, "tools", s4_tools),
|
||||||
|
(5, "commentary", s5_commentary),
|
||||||
|
(6, "channels", s6_channels),
|
||||||
|
(7, "cron delivery", s7_cron),
|
||||||
|
(8, "search", s8_search),
|
||||||
|
(9, "media in", s9_media_in),
|
||||||
|
(10, "media out", s10_media_out),
|
||||||
|
(11, "push", s11_push),
|
||||||
|
(12, "reconnect/sync", s12_sync),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", DEFAULT_URL))
|
||||||
|
p.add_argument("--token", default="")
|
||||||
|
p.add_argument("--skip", default="",
|
||||||
|
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
token = find_token(args.token)
|
||||||
|
if not token:
|
||||||
|
print("!! ANDROID_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
|
||||||
|
return 1
|
||||||
|
skip = {int(x) for x in args.skip.split(",") if x.strip()}
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["ANDROID_TOKEN"] = token
|
||||||
|
|
||||||
|
print(f"== e2e: url={args.url} token={token[:6]}…")
|
||||||
|
sweep_leftovers(env, args.url, token)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for num, name, fn in SCENARIOS:
|
||||||
|
if num in skip:
|
||||||
|
results.append((num, name, SKIP, "skipped by --skip"))
|
||||||
|
print(f"[{num:2d}] {name:<18} {SKIP:<7} skipped by --skip")
|
||||||
|
continue
|
||||||
|
print(f"[{num:2d}] {name:<18} running…", flush=True)
|
||||||
|
try:
|
||||||
|
status, reason = fn(env, args.url, token)
|
||||||
|
except Exception as e:
|
||||||
|
status, reason = FAIL, f"driver error: {e}"
|
||||||
|
results.append((num, name, status, reason))
|
||||||
|
print(f"[{num:2d}] {name:<18} {status:<7} {reason}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 78)
|
||||||
|
print(f"{'#':<3} {'scenario':<18} {'status':<8} reason")
|
||||||
|
print("-" * 78)
|
||||||
|
for num, name, status, reason in results:
|
||||||
|
print(f"{num:<3} {name:<18} {status:<8} {reason}")
|
||||||
|
print("-" * 78)
|
||||||
|
counts = {s: sum(1 for r in results if r[2] == s)
|
||||||
|
for s in (PASS, PARTIAL, SKIP, FAIL)}
|
||||||
|
print(f"total: {len(results)} PASS={counts[PASS]} PARTIAL={counts[PARTIAL]} "
|
||||||
|
f"SKIP={counts[SKIP]} FAIL={counts[FAIL]}")
|
||||||
|
return 1 if counts[FAIL] else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -18,14 +18,64 @@ Options:
|
|||||||
--send TEXT send this message after pairing (default: "hello")
|
--send TEXT send this message after pairing (default: "hello")
|
||||||
--upload F M4: upload F (chunked media.upload) and attach it to the
|
--upload F M4: upload F (chunked media.upload) and attach it to the
|
||||||
message.send via media_refs
|
message.send via media_refs
|
||||||
--pull-offer M4: when a media.offer arrives during the turn, pull the
|
--pull-offer M4: when a media.offer arrives during the turn, pull the
|
||||||
media (chunked) and verify the byte count
|
media (chunked) and verify the byte count. Offers are
|
||||||
|
emitted right AFTER the final message (MEDIA: tag
|
||||||
|
extraction runs post-turn), so after the final the probe
|
||||||
|
keeps listening for --offer-grace seconds for one.
|
||||||
--sync C M5: after pairing, send sync {cursor: C} and print the
|
--sync C M5: after pairing, send sync {cursor: C} and print the
|
||||||
replay + sync.done (no turn is driven)
|
replay + sync.done (no turn is driven)
|
||||||
--fcm-token M5: attach this FCM token to the hello payload
|
--fcm-token M5: attach this FCM token to the hello payload
|
||||||
--fcm-reg M5: after pairing, send fcm.register with --fcm-token
|
--fcm-reg M5: after pairing, send fcm.register with --fcm-token
|
||||||
--timeout S seconds to wait for the final reply (default 120)
|
--timeout S seconds to wait for the final reply (default 120)
|
||||||
--authfail expect an auth rejection (wrong token) and exit 0 on it
|
--authfail expect an auth rejection (wrong token) and exit 0 on it
|
||||||
|
|
||||||
|
Assertion modes (checked after the turn; see exit codes below):
|
||||||
|
--assert-turn M2: the turn produced message.start -> >=1
|
||||||
|
message.update -> message.stop (scenario 2)
|
||||||
|
--assert-reasoning M2: the final message.stop carries a non-empty
|
||||||
|
reasoning field (scenario 3)
|
||||||
|
--assert-tools M2: >=1 tool.start with a matching tool.end
|
||||||
|
(matched by index; scenario 4)
|
||||||
|
--assert-commentary M2: >=1 commentary frame (scenario 5)
|
||||||
|
--assert-read-receipt M7: a read.receipt frame arrives after the sent
|
||||||
|
message. SKIPs (exit 0) when the frame never
|
||||||
|
arrives (old gateway without the M7 frame).
|
||||||
|
--assert-status M7: a status frame is received. SKIPs (exit 0)
|
||||||
|
when the frame never arrives.
|
||||||
|
|
||||||
|
Request modes (no turn driven unless --send/--upload also given):
|
||||||
|
--search Q [--scope all|chat] [--chat-id C]
|
||||||
|
M3: send search {query, scope, limit} and assert >=1 hit
|
||||||
|
in search.results (scenario 8). With --send, the turn is
|
||||||
|
driven first, then the search runs.
|
||||||
|
--channel-create NAME M3: send channel.create, print the new chat_id
|
||||||
|
("== channel created: <chat_id>"), exit
|
||||||
|
--channel-delete CHAT M3: send channel.delete, assert channel.deleted
|
||||||
|
--channel-list M3: send channel.list, print the directory
|
||||||
|
--watch CHAT_ID wait up to --timeout for a message to land in
|
||||||
|
CHAT_ID (cron delivery E2E, scenario 7)
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 ok (incl. SKIP for absent M7 frames)
|
||||||
|
2 connect failed
|
||||||
|
3 no hello.ack
|
||||||
|
4 expected hello.ack, got something else
|
||||||
|
5 --authfail but the token was accepted
|
||||||
|
6 timeout waiting for the final message
|
||||||
|
7 no final assistant message
|
||||||
|
8 upload/sync failed
|
||||||
|
9 media pull failed
|
||||||
|
10 --assert-turn failed (no ordered start/update/stop segment)
|
||||||
|
11 --assert-reasoning failed (final has no non-empty reasoning)
|
||||||
|
12 --assert-tools failed (no tool.start with a matching tool.end)
|
||||||
|
13 --assert-commentary failed (no commentary frame)
|
||||||
|
14 --search failed (error or zero hits)
|
||||||
|
15 --channel-create / --channel-list failed
|
||||||
|
16 --channel-delete failed
|
||||||
|
17 --watch timed out (no message landed in the channel)
|
||||||
|
18 --assert-read-receipt failed (frame arrived before the sent message)
|
||||||
|
19 --assert-status failed (status frame arrived with an empty payload)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -105,6 +155,19 @@ def _print_frame(raw):
|
|||||||
extra = f" cursor={payload.get('cursor')}"
|
extra = f" cursor={payload.get('cursor')}"
|
||||||
elif ftype == "sync.done":
|
elif ftype == "sync.done":
|
||||||
extra = f" cursor={payload.get('cursor')}"
|
extra = f" cursor={payload.get('cursor')}"
|
||||||
|
elif ftype == "search.results":
|
||||||
|
hits = payload.get("hits") or []
|
||||||
|
extra = f" query={payload.get('query')!r} scope={payload.get('scope')} hits={len(hits)}"
|
||||||
|
elif ftype == "channel.created":
|
||||||
|
extra = f" chat_id={payload.get('chat_id')} name={payload.get('name')!r}"
|
||||||
|
elif ftype == "channel.deleted":
|
||||||
|
extra = f" chat_id={payload.get('chat_id')}"
|
||||||
|
elif ftype == "channel.list":
|
||||||
|
extra = f" channels={len(payload.get('channels') or [])}"
|
||||||
|
elif ftype == "read.receipt":
|
||||||
|
extra = f" payload={ {k: payload[k] for k in list(payload)[:4]} }"
|
||||||
|
elif ftype == "status":
|
||||||
|
extra = f" payload={ {k: payload[k] for k in list(payload)[:4]} }"
|
||||||
scope = f" chat={chat}" if chat else ""
|
scope = f" chat={chat}" if chat else ""
|
||||||
idpart = f" id={fid}" if fid is not None else ""
|
idpart = f" id={fid}" if fid is not None else ""
|
||||||
print(f" <- {ftype}{idpart}{scope}{extra}")
|
print(f" <- {ftype}{idpart}{scope}{extra}")
|
||||||
@@ -189,6 +252,228 @@ async def pull_media(ws, media_id: str, request_id: int, expected_size: int | No
|
|||||||
raise RuntimeError(f"pull failed: {data['payload']}")
|
raise RuntimeError(f"pull failed: {data['payload']}")
|
||||||
|
|
||||||
|
|
||||||
|
class _TurnState:
|
||||||
|
"""Assertion-relevant facts collected while driving a turn."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.seq: list[tuple[str, str | None]] = [] # (type, message_id)
|
||||||
|
self.tool_starts: set[int] = set()
|
||||||
|
self.tool_ends: set[int] = set()
|
||||||
|
self.commentary = 0
|
||||||
|
self.final_stop_reasoning: str | None = None
|
||||||
|
self.final_message_reasoning: str | None = None
|
||||||
|
self.user_echo_seen = False
|
||||||
|
self.read_receipt: bool | None = None # None = never arrived
|
||||||
|
self.status_seen = False
|
||||||
|
self.status_empty = False
|
||||||
|
self.pulled = False
|
||||||
|
|
||||||
|
def track(self, ftype: str, payload: dict) -> None:
|
||||||
|
if ftype in ("message.start", "message.update", "message.stop"):
|
||||||
|
self.seq.append((ftype, payload.get("message_id")))
|
||||||
|
if ftype == "message.stop":
|
||||||
|
r = payload.get("reasoning")
|
||||||
|
if isinstance(r, str) and r.strip():
|
||||||
|
self.final_stop_reasoning = r
|
||||||
|
if ftype == "message" and payload.get("role") == "assistant":
|
||||||
|
r = payload.get("reasoning")
|
||||||
|
if isinstance(r, str) and r.strip():
|
||||||
|
self.final_message_reasoning = r
|
||||||
|
if ftype == "message" and payload.get("role") == "user":
|
||||||
|
self.user_echo_seen = True
|
||||||
|
if ftype == "tool.start" and isinstance(payload.get("index"), int):
|
||||||
|
self.tool_starts.add(payload["index"])
|
||||||
|
if ftype == "tool.end" and isinstance(payload.get("index"), int):
|
||||||
|
self.tool_ends.add(payload["index"])
|
||||||
|
if ftype == "commentary":
|
||||||
|
self.commentary += 1
|
||||||
|
if ftype == "read.receipt":
|
||||||
|
self.read_receipt = self.user_echo_seen
|
||||||
|
if ftype == "status":
|
||||||
|
self.status_seen = True
|
||||||
|
if not payload:
|
||||||
|
self.status_empty = True
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_assertions(args, st: _TurnState) -> list[tuple[int, bool, str]]:
|
||||||
|
"""Evaluate the enabled assertion modes. Returns (exit_code, ok, message)
|
||||||
|
per failed-or-passed assertion; SKIPs are printed here and not returned."""
|
||||||
|
results: list[tuple[int, bool, str]] = []
|
||||||
|
if args.assert_turn:
|
||||||
|
ok = False
|
||||||
|
for mid in {m for _, m in st.seq if m is not None}:
|
||||||
|
events = [t for t, m in st.seq if m == mid]
|
||||||
|
if "message.start" in events and "message.stop" in events:
|
||||||
|
i_start = events.index("message.start")
|
||||||
|
i_stop = events.index("message.stop")
|
||||||
|
if any(i_start < i < i_stop
|
||||||
|
for i, e in enumerate(events) if e == "message.update"):
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
results.append((10, ok,
|
||||||
|
"assert-turn: no message.start -> >=1 message.update -> message.stop"))
|
||||||
|
if args.assert_reasoning:
|
||||||
|
reasoning = st.final_stop_reasoning or st.final_message_reasoning
|
||||||
|
results.append((11, bool(reasoning),
|
||||||
|
"assert-reasoning: final message has no non-empty reasoning"))
|
||||||
|
if args.assert_tools:
|
||||||
|
ok = bool(st.tool_starts) and bool(st.tool_starts & st.tool_ends)
|
||||||
|
results.append((12, ok,
|
||||||
|
"assert-tools: no tool.start with a matching tool.end"))
|
||||||
|
if args.assert_commentary:
|
||||||
|
results.append((13, st.commentary >= 1,
|
||||||
|
"assert-commentary: no commentary frame"))
|
||||||
|
if args.assert_read_receipt:
|
||||||
|
if st.read_receipt is None:
|
||||||
|
print("== SKIP: no read.receipt frame (M7 frame not live on this gateway)")
|
||||||
|
elif not st.read_receipt:
|
||||||
|
results.append((18, False,
|
||||||
|
"assert-read-receipt: read.receipt arrived before the sent message"))
|
||||||
|
if args.assert_status:
|
||||||
|
if not st.status_seen:
|
||||||
|
print("== SKIP: no status frame (M7 frame not live on this gateway)")
|
||||||
|
elif st.status_empty:
|
||||||
|
results.append((19, False,
|
||||||
|
"assert-status: status frame arrived with an empty payload"))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def _recv_frames(ws, timeout: float):
|
||||||
|
"""Yield parsed frames (dicts) until *timeout* seconds elapse."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
continue
|
||||||
|
data = _print_frame(raw)
|
||||||
|
if data is not None:
|
||||||
|
yield data
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_mode(ws, args, next_id: int) -> int:
|
||||||
|
"""M3: send a search frame, wait for search.results, assert >=1 hit."""
|
||||||
|
req_id = next_id
|
||||||
|
payload = {"query": args.search, "scope": args.scope, "limit": 20}
|
||||||
|
if args.scope == "chat":
|
||||||
|
payload["chat_id"] = args.chat_id
|
||||||
|
await ws.send(json.dumps({"v": 1, "id": req_id, "type": "search", "payload": payload}))
|
||||||
|
print(f" -> search id={req_id} query={args.search!r} scope={args.scope}")
|
||||||
|
async for data in _recv_frames(ws, timeout=30):
|
||||||
|
if data.get("type") == "search.results" and data.get("id") == req_id:
|
||||||
|
hits = (data.get("payload") or {}).get("hits") or []
|
||||||
|
print(f"== search: {len(hits)} hit(s)")
|
||||||
|
for h in hits[:10]:
|
||||||
|
print(f" hit chat={h.get('chat_id')} role={h.get('role')} "
|
||||||
|
f"snippet={str(h.get('snippet'))[:100]!r}")
|
||||||
|
if hits:
|
||||||
|
return 0
|
||||||
|
print("!! search: no hits")
|
||||||
|
return 14
|
||||||
|
if data.get("type") == "error":
|
||||||
|
print(f"!! search failed: {data.get('payload')}")
|
||||||
|
return 14
|
||||||
|
print("!! search: no search.results within 30s")
|
||||||
|
return 14
|
||||||
|
|
||||||
|
|
||||||
|
async def _channel_create_mode(ws, args) -> int:
|
||||||
|
"""M3: channel.create -> channel.created; print the new chat_id."""
|
||||||
|
req_id = 1
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"v": 1, "id": req_id, "type": "channel.create",
|
||||||
|
"payload": {"name": args.channel_create},
|
||||||
|
}))
|
||||||
|
print(f" -> channel.create id={req_id} name={args.channel_create!r}")
|
||||||
|
async for data in _recv_frames(ws, timeout=30):
|
||||||
|
if data.get("type") == "channel.created" and data.get("id") == req_id:
|
||||||
|
chat_id = (data.get("payload") or {}).get("chat_id")
|
||||||
|
print(f"== channel created: {chat_id}")
|
||||||
|
await ws.close()
|
||||||
|
return 0
|
||||||
|
if data.get("type") == "error":
|
||||||
|
print(f"!! channel.create failed: {data.get('payload')}")
|
||||||
|
await ws.close()
|
||||||
|
return 15
|
||||||
|
print("!! channel.create: no channel.created within 30s")
|
||||||
|
await ws.close()
|
||||||
|
return 15
|
||||||
|
|
||||||
|
|
||||||
|
async def _channel_delete_mode(ws, args) -> int:
|
||||||
|
"""M3: channel.delete -> channel.deleted."""
|
||||||
|
req_id = 1
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"v": 1, "id": req_id, "type": "channel.delete",
|
||||||
|
"payload": {"chat_id": args.channel_delete},
|
||||||
|
}))
|
||||||
|
print(f" -> channel.delete id={req_id} chat_id={args.channel_delete!r}")
|
||||||
|
async for data in _recv_frames(ws, timeout=30):
|
||||||
|
if data.get("type") == "channel.deleted" and data.get("id") == req_id:
|
||||||
|
print(f"== channel deleted: {args.channel_delete}")
|
||||||
|
await ws.close()
|
||||||
|
return 0
|
||||||
|
if data.get("type") == "error":
|
||||||
|
print(f"!! channel.delete failed: {data.get('payload')}")
|
||||||
|
await ws.close()
|
||||||
|
return 16
|
||||||
|
print("!! channel.delete: no channel.deleted within 30s")
|
||||||
|
await ws.close()
|
||||||
|
return 16
|
||||||
|
|
||||||
|
|
||||||
|
async def _channel_list_mode(ws, args) -> int:
|
||||||
|
"""M3: channel.list -> print the directory."""
|
||||||
|
req_id = 1
|
||||||
|
await ws.send(json.dumps({"v": 1, "id": req_id, "type": "channel.list", "payload": {}}))
|
||||||
|
print(" -> channel.list")
|
||||||
|
async for data in _recv_frames(ws, timeout=30):
|
||||||
|
if data.get("type") == "channel.list" and data.get("id") == req_id:
|
||||||
|
for c in (data.get("payload") or {}).get("channels") or []:
|
||||||
|
print(f"== channel: {c.get('chat_id')} name={c.get('name')!r} "
|
||||||
|
f"default={bool(c.get('is_default'))}")
|
||||||
|
await ws.close()
|
||||||
|
return 0
|
||||||
|
if data.get("type") == "error":
|
||||||
|
print(f"!! channel.list failed: {data.get('payload')}")
|
||||||
|
await ws.close()
|
||||||
|
return 15
|
||||||
|
print("!! channel.list: no response within 30s")
|
||||||
|
await ws.close()
|
||||||
|
return 15
|
||||||
|
|
||||||
|
|
||||||
|
async def _watch_mode(ws, args) -> int:
|
||||||
|
"""Wait up to --timeout for a message to land in args.watch (cron E2E)."""
|
||||||
|
print(f"== watching {args.watch} for a message (timeout {args.timeout:.0f}s)")
|
||||||
|
deadline = time.time() + args.timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f"!! timeout after {args.timeout:.0f}s watching {args.watch}")
|
||||||
|
await ws.close()
|
||||||
|
return 17
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
continue
|
||||||
|
data = _print_frame(raw)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if data.get("chat_id") != args.watch:
|
||||||
|
continue
|
||||||
|
ftype = data.get("type")
|
||||||
|
payload = data.get("payload") or {}
|
||||||
|
if ftype == "message" and payload.get("role") in ("assistant", "cron"):
|
||||||
|
print(f"== message landed in {args.watch}: {str(payload.get('text'))[:120]!r}")
|
||||||
|
await ws.close()
|
||||||
|
return 0
|
||||||
|
print(f"!! no message landed in {args.watch}")
|
||||||
|
await ws.close()
|
||||||
|
return 17
|
||||||
|
|
||||||
|
|
||||||
async def run(args) -> int:
|
async def run(args) -> int:
|
||||||
url = args.url
|
url = args.url
|
||||||
token = args.token
|
token = args.token
|
||||||
@@ -266,8 +551,18 @@ async def run(args) -> int:
|
|||||||
await ws.close()
|
await ws.close()
|
||||||
return 8
|
return 8
|
||||||
|
|
||||||
if not args.send and not args.upload:
|
# M3: request modes (no turn driven).
|
||||||
print("== paired OK (no --send/--upload; exiting)")
|
if args.channel_create:
|
||||||
|
return await _channel_create_mode(ws, args)
|
||||||
|
if args.channel_delete:
|
||||||
|
return await _channel_delete_mode(ws, args)
|
||||||
|
if args.channel_list:
|
||||||
|
return await _channel_list_mode(ws, args)
|
||||||
|
if args.watch:
|
||||||
|
return await _watch_mode(ws, args)
|
||||||
|
|
||||||
|
if not args.send and not args.upload and not args.search:
|
||||||
|
print("== paired OK (no --send/--upload/--search; exiting)")
|
||||||
await ws.close()
|
await ws.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -284,63 +579,114 @@ async def run(args) -> int:
|
|||||||
return 8
|
return 8
|
||||||
media_refs.append(media_ref)
|
media_refs.append(media_ref)
|
||||||
|
|
||||||
# Drive a turn.
|
# Drive a turn (if --send or --upload).
|
||||||
msg_id = next_id
|
st = _TurnState()
|
||||||
send_payload: dict = {"text": args.send or ""}
|
|
||||||
if media_refs:
|
|
||||||
send_payload["media_refs"] = media_refs
|
|
||||||
send_frame = {
|
|
||||||
"v": 1,
|
|
||||||
"id": msg_id,
|
|
||||||
"type": "message.send",
|
|
||||||
"chat_id": "android:default",
|
|
||||||
"payload": send_payload,
|
|
||||||
}
|
|
||||||
await ws.send(json.dumps(send_frame))
|
|
||||||
print(f" -> message.send id={msg_id} text={args.send!r} media_refs={media_refs}")
|
|
||||||
|
|
||||||
deadline = time.time() + args.timeout
|
|
||||||
got_final = False
|
got_final = False
|
||||||
seen_final_frame = False
|
if args.send or args.upload:
|
||||||
while time.time() < deadline:
|
msg_id = next_id
|
||||||
try:
|
send_payload: dict = {"text": args.send or ""}
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
|
if media_refs:
|
||||||
except asyncio.TimeoutError:
|
send_payload["media_refs"] = media_refs
|
||||||
print(f"!! timeout after {args.timeout}s waiting for final message")
|
send_frame = {
|
||||||
await ws.close()
|
"v": 1,
|
||||||
return 6
|
"id": msg_id,
|
||||||
data = _print_frame(raw)
|
"type": "message.send",
|
||||||
if data is None:
|
"chat_id": "android:default",
|
||||||
continue
|
"payload": send_payload,
|
||||||
ftype = data.get("type")
|
}
|
||||||
payload = data.get("payload") or {}
|
await ws.send(json.dumps(send_frame))
|
||||||
# M4: fetch offered media live (outbound direction).
|
print(f" -> message.send id={msg_id} text={args.send!r} media_refs={media_refs}")
|
||||||
if ftype == "media.offer" and args.pull_offer and payload.get("media_id"):
|
|
||||||
|
deadline = time.time() + args.timeout
|
||||||
|
seen_final_frame = False
|
||||||
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
next_id = await pull_media(
|
raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
|
||||||
ws, payload.get("media_id"), next_id, payload.get("size")
|
except asyncio.TimeoutError:
|
||||||
)
|
print(f"!! timeout after {args.timeout}s waiting for final message")
|
||||||
except Exception as e:
|
|
||||||
print(f"!! pull failed: {e}")
|
|
||||||
await ws.close()
|
await ws.close()
|
||||||
return 9
|
return 6
|
||||||
# A standalone assistant `message` (non-streaming) is immediately final.
|
data = _print_frame(raw)
|
||||||
if ftype == "message" and payload.get("role") == "assistant":
|
if data is None:
|
||||||
got_final = True
|
continue
|
||||||
break
|
ftype = data.get("type")
|
||||||
# A `message.stop` finalizes a streaming segment; the turn is done once
|
payload = data.get("payload") or {}
|
||||||
# typing stops afterwards (multi-segment turns have several stops).
|
st.track(ftype, payload)
|
||||||
if ftype == "message.stop":
|
# M4: fetch offered media live (outbound direction).
|
||||||
seen_final_frame = True
|
if ftype == "media.offer" and args.pull_offer and payload.get("media_id"):
|
||||||
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
|
try:
|
||||||
got_final = True
|
await pull_media(
|
||||||
break
|
ws, payload["media_id"], next_id, payload.get("size")
|
||||||
await ws.close()
|
)
|
||||||
if got_final:
|
next_id += 1
|
||||||
|
st.pulled = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"!! pull failed: {e}")
|
||||||
|
await ws.close()
|
||||||
|
return 9
|
||||||
|
# A standalone assistant `message` (non-streaming) is immediately final.
|
||||||
|
if ftype == "message" and payload.get("role") == "assistant":
|
||||||
|
got_final = True
|
||||||
|
break
|
||||||
|
# A `message.stop` finalizes a streaming segment; the turn is done
|
||||||
|
# once typing stops afterwards (multi-segment turns have several
|
||||||
|
# stops).
|
||||||
|
if ftype == "message.stop":
|
||||||
|
seen_final_frame = True
|
||||||
|
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
|
||||||
|
got_final = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# M4: media offers are emitted right AFTER the final message (the
|
||||||
|
# MEDIA: tag is extracted post-turn); give them a grace window.
|
||||||
|
if got_final and args.pull_offer and not st.pulled:
|
||||||
|
grace_deadline = time.time() + args.offer_grace
|
||||||
|
while time.time() < grace_deadline:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(
|
||||||
|
ws.recv(), timeout=grace_deadline - time.time()
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
continue
|
||||||
|
data = _print_frame(raw)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if data.get("type") == "media.offer" and (data.get("payload") or {}).get("media_id"):
|
||||||
|
try:
|
||||||
|
await pull_media(
|
||||||
|
ws, data["payload"]["media_id"], next_id,
|
||||||
|
data["payload"].get("size"),
|
||||||
|
)
|
||||||
|
next_id += 1
|
||||||
|
st.pulled = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"!! pull failed: {e}")
|
||||||
|
await ws.close()
|
||||||
|
return 9
|
||||||
|
break
|
||||||
|
if not st.pulled:
|
||||||
|
print(f"== no media.offer within {args.offer_grace:.0f}s grace")
|
||||||
|
|
||||||
|
if not got_final:
|
||||||
|
print("!! no final assistant message")
|
||||||
|
await ws.close()
|
||||||
|
return 7
|
||||||
print("== final assistant message received")
|
print("== final assistant message received")
|
||||||
return 0
|
|
||||||
print("!! no final assistant message")
|
# M3: optional search (standalone, or after the turn).
|
||||||
return 7
|
if args.search:
|
||||||
|
rc = await _search_mode(ws, args, next_id)
|
||||||
|
await ws.close()
|
||||||
|
return rc
|
||||||
|
|
||||||
|
await ws.close()
|
||||||
|
for code, ok, msg in _evaluate_assertions(args, st):
|
||||||
|
if not ok:
|
||||||
|
print(f"!! {msg}")
|
||||||
|
return code
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -362,9 +708,42 @@ def main() -> int:
|
|||||||
p.add_argument("--timeout", type=float, default=120.0)
|
p.add_argument("--timeout", type=float, default=120.0)
|
||||||
p.add_argument("--authfail", action="store_true",
|
p.add_argument("--authfail", action="store_true",
|
||||||
help="expect an auth rejection (wrong token)")
|
help="expect an auth rejection (wrong token)")
|
||||||
|
p.add_argument("--assert-turn", action="store_true",
|
||||||
|
help="assert message.start -> >=1 message.update -> message.stop")
|
||||||
|
p.add_argument("--assert-reasoning", action="store_true",
|
||||||
|
help="assert the final message.stop carries non-empty reasoning")
|
||||||
|
p.add_argument("--assert-tools", action="store_true",
|
||||||
|
help="assert >=1 tool.start with a matching tool.end")
|
||||||
|
p.add_argument("--assert-commentary", action="store_true",
|
||||||
|
help="assert >=1 commentary frame")
|
||||||
|
p.add_argument("--assert-read-receipt", action="store_true",
|
||||||
|
help="assert a read.receipt arrives after the sent message "
|
||||||
|
"(SKIP if absent; M7)")
|
||||||
|
p.add_argument("--assert-status", action="store_true",
|
||||||
|
help="assert a status frame is received (SKIP if absent; M7)")
|
||||||
|
p.add_argument("--search", default="",
|
||||||
|
help="M3: send search {query, scope, limit}, assert >=1 hit")
|
||||||
|
p.add_argument("--scope", choices=("all", "chat"), default="all",
|
||||||
|
help="search scope (default all)")
|
||||||
|
p.add_argument("--chat-id", default="android:default",
|
||||||
|
help="chat_id for --scope chat (default android:default)")
|
||||||
|
p.add_argument("--channel-create", default="",
|
||||||
|
help="M3: create a channel, print its chat_id, exit")
|
||||||
|
p.add_argument("--channel-delete", default="",
|
||||||
|
help="M3: delete (archive) a channel, exit")
|
||||||
|
p.add_argument("--channel-list", action="store_true",
|
||||||
|
help="M3: list channels, exit")
|
||||||
|
p.add_argument("--watch", default="",
|
||||||
|
help="wait up to --timeout for a message to land in this chat_id")
|
||||||
|
p.add_argument("--offer-grace", type=float, default=15.0,
|
||||||
|
help="seconds to wait for a media.offer after the final "
|
||||||
|
"message when --pull-offer (default 15)")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
if not args.token and not args.authfail:
|
if not args.token and not args.authfail:
|
||||||
p.error("--token (or $ANDROID_TOKEN) is required")
|
p.error("--token (or $ANDROID_TOKEN) is required")
|
||||||
|
if args.assert_read_receipt and not args.send:
|
||||||
|
p.error("--assert-read-receipt requires --send (the receipt must follow "
|
||||||
|
"the sent message)")
|
||||||
return asyncio.run(run(args))
|
return asyncio.run(run(args))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ Per-connection handler:
|
|||||||
2. On success: register in the device registry (SQLite) + connection
|
2. On success: register in the device registry (SQLite) + connection
|
||||||
registry (``device_id -> {ws, caps, fcm_token}``), send
|
registry (``device_id -> {ws, caps, fcm_token}``), send
|
||||||
``hello.ack {server_caps, sync_cursor, channels[]}``.
|
``hello.ack {server_caps, sync_cursor, channels[]}``.
|
||||||
3. Loop: decode frames, dispatch to adapter inbound handlers.
|
3. Loop: decode frames, dispatch to adapter inbound handlers. Inbound JSON
|
||||||
|
frames are rate-limited per connection (token bucket, ``INBOUND_RATE_PER_S``
|
||||||
|
/ ``INBOUND_BURST``); binary media-upload chunks are exempt.
|
||||||
4. On close: deregister.
|
4. On close: deregister.
|
||||||
|
|
||||||
Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
|
Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
|
||||||
@@ -44,12 +46,46 @@ HELLO_TIMEOUT_S = 10.0
|
|||||||
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
|
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
|
||||||
SEND_TIMEOUT_S = 10.0
|
SEND_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
|
# Inbound JSON control-frame rate limit (per connection, token bucket).
|
||||||
|
# A legitimate app sends pings + occasional user-initiated requests — far
|
||||||
|
# below 20/s sustained. Binary media-upload chunks are EXEMPT (see
|
||||||
|
# ``_on_frame``): a 100 MB upload is 400 x 256 KiB frames in a tight loop
|
||||||
|
# and would exhaust any sane bucket; uploads are bounded instead by the
|
||||||
|
# per-frame ``max_size`` and the per-upload total cap (``media.py``).
|
||||||
|
INBOUND_RATE_PER_S = 20.0
|
||||||
|
INBOUND_BURST = 40
|
||||||
|
|
||||||
# Close codes (4000-4999 are reserved for applications).
|
# Close codes (4000-4999 are reserved for applications).
|
||||||
CLOSE_AUTH_FAILED = 4401
|
CLOSE_AUTH_FAILED = 4401
|
||||||
CLOSE_REPLACED = 4402
|
CLOSE_REPLACED = 4402
|
||||||
|
CLOSE_RATE_LIMITED = 4403
|
||||||
CLOSE_SHUTDOWN = 1001
|
CLOSE_SHUTDOWN = 1001
|
||||||
|
|
||||||
|
|
||||||
|
class _TokenBucket:
|
||||||
|
"""Minimal token bucket (stdlib only). One instance per connection."""
|
||||||
|
|
||||||
|
__slots__ = ("rate", "burst", "tokens", "updated_at")
|
||||||
|
|
||||||
|
def __init__(self, rate: float, burst: int):
|
||||||
|
self.rate = rate
|
||||||
|
self.burst = burst
|
||||||
|
self.tokens = float(burst)
|
||||||
|
self.updated_at = time.monotonic()
|
||||||
|
|
||||||
|
def consume(self) -> bool:
|
||||||
|
"""Try to take one token. Refills at ``rate``/s up to ``burst``."""
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self.updated_at
|
||||||
|
if elapsed > 0:
|
||||||
|
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
||||||
|
self.updated_at = now
|
||||||
|
if self.tokens >= 1.0:
|
||||||
|
self.tokens -= 1.0
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeviceConnection:
|
class DeviceConnection:
|
||||||
"""One live, authenticated device socket."""
|
"""One live, authenticated device socket."""
|
||||||
@@ -61,6 +97,9 @@ class DeviceConnection:
|
|||||||
fcm_token: Optional[str] = None
|
fcm_token: Optional[str] = None
|
||||||
ntfy_topic: Optional[str] = None
|
ntfy_topic: Optional[str] = None
|
||||||
connected_at: float = field(default_factory=time.time)
|
connected_at: float = field(default_factory=time.time)
|
||||||
|
rate_bucket: _TokenBucket = field(
|
||||||
|
default_factory=lambda: _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WsServer:
|
class WsServer:
|
||||||
@@ -254,6 +293,9 @@ class WsServer:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await ws.send(ack.to_json())
|
await ws.send(ack.to_json())
|
||||||
|
# M7: tell late-joining clients the current gateway health state
|
||||||
|
# (the startup broadcast only reaches clients already connected).
|
||||||
|
await ws.send(protocol.status(self._adapter.gateway_status()).to_json())
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
logger.info("android: device paired: %s (%s)", device_name, device_id)
|
logger.info("android: device paired: %s (%s)", device_name, device_id)
|
||||||
@@ -261,7 +303,11 @@ class WsServer:
|
|||||||
# 3. frame loop ------------------------------------------------------
|
# 3. frame loop ------------------------------------------------------
|
||||||
try:
|
try:
|
||||||
async for raw in ws:
|
async for raw in ws:
|
||||||
await self._on_frame(ws, device_id, raw)
|
# ``_on_frame`` returns False once it has closed the socket
|
||||||
|
# (rate limit); stop draining the buffered frames so a
|
||||||
|
# flood doesn't re-trigger the error+close per frame.
|
||||||
|
if not await self._on_frame(ws, device_id, raw):
|
||||||
|
break
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -280,16 +326,38 @@ class WsServer:
|
|||||||
|
|
||||||
# ── Inbound dispatch ──────────────────────────────────────────────────
|
# ── Inbound dispatch ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> None:
|
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> bool:
|
||||||
|
"""Dispatch one inbound frame. Returns False once the socket has been
|
||||||
|
closed (rate limit) so the caller stops draining buffered frames."""
|
||||||
# M4: binary frames are media upload chunks (raw bytes, no JSON
|
# M4: binary frames are media upload chunks (raw bytes, no JSON
|
||||||
# envelope). Route them to the active upload session.
|
# envelope). Route them to the active upload session. They are
|
||||||
|
# EXEMPT from the inbound rate limit: a 100 MB upload is 400 x
|
||||||
|
# 256 KiB frames in a tight loop, which would exhaust any sane
|
||||||
|
# frame bucket. Uploads are bounded instead by the per-frame
|
||||||
|
# ``max_size`` and the per-upload total cap (``media.py``).
|
||||||
if isinstance(raw, (bytes, bytearray, memoryview)):
|
if isinstance(raw, (bytes, bytearray, memoryview)):
|
||||||
await self._adapter.on_media_chunk(device_id, bytes(raw))
|
await self._adapter.on_media_chunk(device_id, bytes(raw))
|
||||||
return
|
return True
|
||||||
|
|
||||||
|
# Inbound rate limit (JSON control frames only). On exceed: error +
|
||||||
|
# close, same pattern as auth rejection.
|
||||||
|
conn = self._connection_for(ws)
|
||||||
|
if conn is not None and not conn.rate_bucket.consume():
|
||||||
|
logger.warning(
|
||||||
|
"android: inbound rate limit exceeded for %s; closing", device_id
|
||||||
|
)
|
||||||
|
await self._send_quiet(
|
||||||
|
ws,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self._close_quiet(ws, CLOSE_RATE_LIMITED, "rate limited")
|
||||||
|
return False
|
||||||
|
|
||||||
frame = protocol.Frame.from_json(raw)
|
frame = protocol.Frame.from_json(raw)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
return # malformed JSON: ignore (forward-compat)
|
return True # malformed JSON: ignore (forward-compat)
|
||||||
|
|
||||||
if frame.type == protocol.TYPE_PING:
|
if frame.type == protocol.TYPE_PING:
|
||||||
ts = frame.payload.get("ts")
|
ts = frame.payload.get("ts")
|
||||||
@@ -319,9 +387,18 @@ class WsServer:
|
|||||||
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
||||||
await self._adapter.on_fcm_register(frame, device_id)
|
await self._adapter.on_fcm_register(frame, device_id)
|
||||||
# Unknown types are ignored (forward-compat).
|
# Unknown types are ignored (forward-compat).
|
||||||
|
return True
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _connection_for(self, ws: ServerConnection) -> Optional[DeviceConnection]:
|
||||||
|
"""The live registry entry for this exact socket (identity match, so
|
||||||
|
a replaced socket never consumes the new connection's bucket)."""
|
||||||
|
for conn in self._connections.values():
|
||||||
|
if conn.ws is ws:
|
||||||
|
return conn
|
||||||
|
return None
|
||||||
|
|
||||||
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
|
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
|
||||||
try:
|
try:
|
||||||
await ws.send(frame.to_json())
|
await ws.send(frame.to_json())
|
||||||
|
|||||||
Reference in new issue
Block a user