HTTP transport: drop WS server, offline send queue + dead-stream watchdog
CI / Gateway plugin tests (push) Successful in 5m9s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m55s

Gateway (docs/19):
- Remove ws_server.py; frame dispatch factored into dispatch.py
- http_server: media upload/pull, pairing over HTTP
- protocol: media frames mirrored; tests + ws_probe updated for HTTP

App:
- HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on
  network failure (PostResult ok=false / Result.failure) — uncaught
  SocketTimeoutException on Dispatchers.Default crashed the app
- GatewayClient: dead-stream watchdog (health probe every 10s, 2
  failures -> redial in ~20s instead of the 45s SSE read timeout);
  state flips to Reconnecting when the stream dies, restored from the
  last hello.ack on long-poll success; poke() + backoff reset on app
  resume (MainActivity.onResume)
- Offline sends: composer enabled while disconnected; a send with no
  response (status 0) stays queued (Pending) and is auto-resent on the
  next (re)connect after a 2s outbox-replay grace; gateway 4xx
  rejections fail the bubble (tap to retry, no auto-loop)
- ChatStore: echo-replace and thread-relocate also match Failed
  bubbles (POST response lost in a network drop); loadHistory dedupes
  local failed bubbles the server already has; failMessage()
- MainActivity: poke() on resume so a backgrounded app reconnects
  promptly instead of waiting out the backoff
This commit is contained in:
ARIA committed 2026-08-22 20:10:05 +02:00
1 parent 2349a95dd4
commit e6015033b6
22 files changed
+2717 -2352

No files matched your search

@@ -49,6 +49,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
AppBridge.foreground = true AppBridge.foreground = true
// Wake the connect loop's backoff: after a background stint the
// network is usually back, so re-probe immediately instead of making
// the user wait out the (up to 30 s) backoff with a Connecting banner.
AppBridge.controller?.client?.poke()
} }
override fun onPause() { override fun onPause() {
@@ -12,7 +12,7 @@ import iris.net.GatewayClient
* - [onNewToken]: persist the rotated token and push it to the server via * - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token). * `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: the data payload drives a silent sync. When the app * - [onMessageReceived]: the data payload drives a silent sync. When the app
* is foregrounded the WS path already delivered the frame (in-app banner), * is foregrounded the SSE path already delivered the frame (in-app banner),
* so we only post a system notification when backgrounded. * so we only post a system notification when backgrounded.
* *
* Inert without a Firebase project (no google-services.json): the service is * Inert without a Firebase project (no google-services.json): the service is
@@ -20,23 +20,32 @@ import iris.net.GatewayClient
* back to the ntfy listener. * back to the ntfy listener.
*/ */
class IrisFirebaseMessagingService : FirebaseMessagingService() { class IrisFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
val store = AndroidSecureStore(applicationContext) val store = AndroidSecureStore(applicationContext)
store.fcmToken = token store.fcmToken = token
// Push the rotation to the server if we're connected. // Push the rotation to the server if we're connected (the ntfy topic
// rides along so a wiped registry recovers both push tokens).
AppBridge.controller?.client?.sendFrame( AppBridge.controller?.client?.sendFrame(
iris.protocol.fcmRegisterFrame(fcmToken = token), iris.protocol.fcmRegisterFrame(
fcmToken = token,
ntfyTopic = store.ntfyTopic.ifBlank { null },
),
) )
} }
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
// Foreground + live WS: the in-app banner already showed this. // Foreground + live SSE: the in-app banner already showed this.
if (AppBridge.foreground) return if (AppBridge.foreground) return
// Live WS: the frame arrives over the socket and the controller // Live SSE: the frame arrives over the stream and the controller
// mirrors it to a system notification itself — posting here would // mirrors it to a system notification itself — posting here would
// duplicate it (docs/08 §8.7). // duplicate it (docs/08 §8.7).
if (AppBridge.controller?.client?.state?.value is GatewayClient.State.Connected) return if (AppBridge.controller
?.client
?.state
?.value is GatewayClient.State.Connected
) {
return
}
// Backgrounded/killed: FCM already displayed the `notification` // Backgrounded/killed: FCM already displayed the `notification`
// payload on our behalf (the data payload only carries sync // payload on our behalf (the data payload only carries sync
// metadata). Posting again would show a second notification with a // metadata). Posting again would show a second notification with a
@@ -249,10 +249,14 @@ class ChatStore {
) )
list.toMutableList().also { it[byId] = updated } list.toMutableList().also { it[byId] = updated }
} else if (p.role == ROLE_USER) { } else if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo). // Replace the matching optimistic bubble (server echo). Also
// matches a FAILED bubble: the send may have arrived after
// its POST response was lost in a network drop — the echo is
// the proof of delivery, so reconcile instead of duplicating.
val pendingIdx = val pendingIdx =
list.indexOfLast { list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed)
} }
if (pendingIdx >= 0) { if (pendingIdx >= 0) {
list.toMutableList().also { list.toMutableList().also {
@@ -315,7 +319,8 @@ class ChatStore {
val flatList = map[flatLane].orEmpty() val flatList = map[flatLane].orEmpty()
val idx = val idx =
flatList.indexOfLast { flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed)
} }
if (idx < 0) return if (idx < 0) return
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) } map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
@@ -613,6 +618,31 @@ class ChatStore {
if (changed) _lanes.value = map if (changed) _lanes.value = map
} }
/** M7: mark a single user message as failed (the send never reached the
* gateway — network drop, or the gateway rejected it); tap the bubble
* to retry. */
fun failMessage(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.Failed
) {
item.copy(pending = false, status = MsgStatus.Failed)
} else {
item
}
}
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/** /**
* Remove messages by id from every lane (a `message.deleted` frame). The * Remove messages by id from every lane (a `message.deleted` frame). The
* server is authoritative: the frame carries no lane, and a message id is * server is authoritative: the frame carries no lane, and a message id is
@@ -713,9 +743,24 @@ class ChatStore {
) { ) {
updateLane(lane) { list -> updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet() val historyIds = messages.map { it.id }.toSet()
// A local FAILED bubble whose text+media matches a history user
// message was actually delivered (the POST response was lost in
// the network drop) — the history copy is authoritative, so drop
// the local duplicate instead of showing the message twice.
val historyUser = messages.filter { it.role == ROLE_USER }
val preserved = val preserved =
list.filter { item -> list.filter { item ->
item !is MessageItem || item.id !in historyIds if (item !is MessageItem) return@filter true
if (item.id in historyIds) return@filter false
if (item.role == ROLE_USER && item.status == MsgStatus.Failed &&
historyUser.any {
it.text == item.text &&
it.media.map { m -> m.mediaId } == item.media.map { m -> m.mediaId }
}
) {
return@filter false
}
true
} }
// ts of every item in the current lane: ts-less items (commentary, // ts of every item in the current lane: ts-less items (commentary,
// tool cards) inherit the ts of the item before them, so a tool // tool cards) inherit the ts of the item before them, so a tool
@@ -6,10 +6,10 @@ package iris.data
* SharedPreferences for M1 dev, file on desktop). * SharedPreferences for M1 dev, file on desktop).
*/ */
interface SecureStore { interface SecureStore {
/** ws(s)://host:port/ws */ /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
var serverUrl: String var serverUrl: String
/** ANDROID_TOKEN presented in the hello frame. */ /** ANDROID_TOKEN presented in the auth header. */
var token: String var token: String
/** Stable app-generated device id (persisted). */ /** Stable app-generated device id (persisted). */
@@ -1,34 +1,19 @@
package iris.net package iris.net
import iris.data.SecureStore import iris.data.SecureStore
import iris.media.FileSource
import iris.media.Sha256
import iris.protocol.ChannelInfo import iris.protocol.ChannelInfo
import iris.protocol.ErrorPayload
import iris.protocol.Frame import iris.protocol.Frame
import iris.protocol.HelloAckPayload import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaPullEndPayload
import iris.protocol.MediaUploadAckPayload
import iris.protocol.ServerCaps import iris.protocol.ServerCaps
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HELLO_ACK
import iris.protocol.TYPE_MEDIA_PULL_END
import iris.protocol.TYPE_MEDIA_UPLOAD_ACK
import iris.protocol.TYPE_PONG
import iris.protocol.helloFrame
import iris.protocol.mediaPullFrame
import iris.protocol.mediaUploadEndFrame
import iris.protocol.mediaUploadStartFrame
import iris.protocol.messageSendFrame import iris.protocol.messageSendFrame
import iris.protocol.pingFrame
import iris.protocol.syncFrame import iris.protocol.syncFrame
import iris.util.IrisLog import iris.util.IrisLog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -44,25 +29,19 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/** /**
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3). * HTTP client for the hermes android gateway (docs/19).
* *
* - connect + hello (real auth leg), hello.ack * HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
*
* - connect: health probe + SSE hello (the HTTP hello.ack)
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect * - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - heartbeat: app-level ping every 20s; reap after ~60s of silence * - events: server frames on [events]
* - events: server frames (minus hello.ack) on [events] * - request/response correlation by id
* - request/response correlation by id (M2+ consumers)
*/ */
class GatewayClient( class GatewayClient(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -73,33 +52,14 @@ class GatewayClient(
data object Connecting : State data object Connecting : State
/** Common surface of [Connected] and [HttpFallback]: both carry the
* hello.ack data (caps, channels, push watermark). */
interface HelloInfo {
val caps: ServerCaps
val channels: List<ChannelInfo>
val lastPushedCursor: Long
}
data class Connected( data class Connected(
override val caps: ServerCaps, val caps: ServerCaps,
override val channels: List<ChannelInfo>, val channels: List<ChannelInfo>,
/** M5: highest outbox cursor already pushed to this device /** M5: highest outbox cursor already pushed to this device
* (from hello.ack; 0 = never). Sync-replayed frames at/below * (from hello.ack; 0 = never). Sync-replayed frames at/below
* it must not re-post system notifications (docs/08 §8.7). */ * it must not re-post system notifications (docs/08 §8.7). */
override val lastPushedCursor: Long = 0, val lastPushedCursor: Long = 0,
) : State, ) : State
HelloInfo
/** docs/19: the WS is down but the gateway is reachable over the
* HTTP leg — sendable (POST /v1/frame) + receiving (SSE/long-poll).
* Media is unavailable until the WS is back. */
data class HttpFallback(
override val caps: ServerCaps,
override val channels: List<ChannelInfo>,
override val lastPushedCursor: Long = 0,
) : State,
HelloInfo
data object Reconnecting : State data object Reconnecting : State
@@ -125,53 +85,42 @@ class GatewayClient(
.build() .build()
private var connectJob: Job? = null private var connectJob: Job? = null
private var socket: WebSocket? = null
private var nextRequestId = 1 private var nextRequestId = 1
private var attempt = 0 private var attempt = 0
// Set by poke() (app returned to the foreground): the connect loop's
// backoff waits in 500 ms slices and re-probes immediately when set.
@Volatile
private var wakeRequested = false
// True once a connection has been established this session; reset by // True once a connection has been established this session; reset by
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a // start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen. // drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>() private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// docs/19: HTTP fallback leg (the "HTTP leg"). [http] is created lazily // HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
// from the stored WS URL; [httpJob] runs the SSE/long-poll receive loop; // the resume cursor (SSE id / outbox high-water mark).
// [httpCursor] is the resume cursor (SSE id / outbox high-water mark).
private var http: HttpGateway? = null private var http: HttpGateway? = null
private var httpJob: Job? = null
private var httpCursor: Long = 0 private var httpCursor: Long = 0
private var sseFailures = 0 private var sseFailures = 0
private var usingLongPoll = false private var usingLongPoll = false
// Last hello.ack payload (WS or SSE) — used to restore State.Connected
// after a fallback/WS state race in the connect loop. // Last hello.ack payload — used to restore State.Connected after a
// reconnect state race in the connect loop.
private var lastAck: HelloAckPayload? = null private var lastAck: HelloAckPayload? = null
// M4: binary frames (media upload chunks / pull stream) have no per-frame
// id, so at most one binary session is active per socket. The gateway
// allows one upload per connection; pull is request/response.
private sealed interface BinarySession {
data class Pulling(
val requestId: Int,
val chunks: Channel<ByteArray>,
val end: CompletableDeferred<Frame>,
) : BinarySession
}
private var binarySession: BinarySession? = null
/** /**
* Fired promptly (on the WS thread) the moment `hello.ack` is received — * Fired promptly the moment the SSE hello (hello.ack) is received — on
* on every (re)connect. Used for time-critical work that must not wait for * every (re)connect. Used for time-critical work that must not wait for
* the state collector, which can be starved for seconds during app startup * the state collector, which can be starved for seconds during app
* (Dispatchers.Default) and would push a history request past a flaky * startup (Dispatchers.Default) and would push a history request past a
* network's window. Set before [start]. * flaky network's window. Set before [start].
*/ */
var onHelloAck: ((State.HelloInfo) -> Unit)? = null var onHelloAck: ((State.Connected) -> Unit)? = null
// M4: only one pull may be in flight at a time (binarySession is a single // Only one pull may be in flight at a time. Serialize concurrent offers so
// slot). Serialize concurrent offers so their byte streams don't interleave. // their byte streams don't interleave.
private val pullMutex = Mutex() private val pullMutex = Mutex()
// ── Lifecycle ───────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -184,16 +133,25 @@ class GatewayClient(
connectJob = scope.launch { connectLoop() } connectJob = scope.launch { connectLoop() }
} }
/** Stop the connect loop and close the socket. */ /** Stop the connect loop. */
fun stop() { fun stop() {
connectJob?.cancel() connectJob?.cancel()
connectJob = null connectJob = null
stopHttpLeg()
socket?.close(1000, "client shutdown")
socket = null
_state.value = State.Disconnected _state.value = State.Disconnected
} }
/**
* Call when the app returns to the foreground: if the connect loop is
* between attempts (backing off after failed health probes — up to 30 s),
* wake it so it re-probes immediately instead of making the user wait out
* the backoff with a "Connecting…" banner. No-op while connected.
*/
fun poke() {
if (_state.value is State.Connected) return
attempt = 0
wakeRequested = true
}
/** Re-pair: stop, then start fresh (used after saving new settings). */ /** Re-pair: stop, then start fresh (used after saving new settings). */
fun restart() { fun restart() {
stop() stop()
@@ -209,105 +167,87 @@ class GatewayClient(
return return
} }
_state.value = if (hasConnected) State.Reconnecting else State.Connecting _state.value = if (hasConnected) State.Reconnecting else State.Connecting
// docs/19: race the WS dial against the HTTP health probe. If the val gw = httpGateway() ?: continue
// gateway is alive over HTTP, the app can send immediately // Health probe: if the gateway is alive, open the SSE receive loop
// (fallback) without waiting out the WS dial timeout — the key // (which delivers the hello.ack). Otherwise back off and retry.
// UX fix (sendable in < 1 s on a dead WS port).
val dialDeferred = scope.async { dial(url, token) }
val healthOk = val healthOk =
withTimeoutOrNull(2_000) { try {
httpHealthy() gw.health()
} ?: false } catch (e: Exception) {
if (healthOk) enterHttpFallback() false
val dial = dialDeferred.await()
when (val result = dial.result) {
is DialResult.AuthFailed -> {
stopHttpLeg()
_state.value = State.AuthFailed(result.message)
dial.socket.close(1000, "auth failed")
return
} }
if (!healthOk) {
DialResult.Connected -> {
hasConnected = true
attempt = 0
// WS is up again: back to WS-only (media available).
stopHttpLeg()
lastLiveness = TimeSource.Monotonic.markNow()
// Restore Connected if the fallback state won the race
// (health landed before hello.ack).
lastAck?.let {
_state.value =
State.Connected(it.serverCaps, it.channels, it.lastPushedCursor)
}
dial.closed.await()
if (!currentCoroutineContext().isActive) return
// WS dropped: fall back to HTTP immediately (no backoff
// gate on the send path), then redial below.
enterHttpFallback()
}
is DialResult.Failed -> {
attempt++ attempt++
delay(backoffMs(attempt)) backoffOrWake(backoffMs(attempt))
continue
}
attempt = 0
hasConnected = true
sseFailures = 0
usingLongPoll = false
httpCursor = store.syncCursor
// Provisional Connected state (previous caps/channels) until the
// SSE hello arrives with the real ones.
val prev = _state.value
_state.value =
State.Connected(
caps = (prev as? State.Connected)?.caps ?: ServerCaps(),
channels = (prev as? State.Connected)?.channels ?: emptyList(),
lastPushedCursor = (prev as? State.Connected)?.lastPushedCursor ?: 0,
)
// Run the HTTP receive loop (SSE/long-poll) until cancelled.
coroutineScope {
val receiveJob = launch { httpReceiveLoop(gw) }
// Dead-stream watchdog: the SSE read timeout (45 s) is the
// only in-stream dead-connection detector; probe /v1/health
// in parallel so a dropped network flips the state to
// Reconnecting within ~20 s (2 failed probes) instead of 45,
// and a stuck stream can't keep a stale "Connected".
var probeFailures = 0
while (receiveJob.isActive) {
delay(10_000)
val ok =
try {
gw.health()
} catch (e: Exception) {
false
}
probeFailures = if (ok) 0 else probeFailures + 1
if (probeFailures >= 2) {
receiveJob.cancel()
break
} }
} }
receiveJob.join()
}
// Terminal auth failure: don't redial with the same bad token.
if (_state.value is State.AuthFailed) return
} }
} }
// ── docs/19: HTTP fallback leg ──────────────────────────────────────── // ── HTTP receive leg ──────────────────────────────────────────────────
/** Lazily build the HTTP client from the stored WS URL. */ /** Lazily build the HTTP client from the stored URL. */
private fun httpGateway(): HttpGateway? { private fun httpGateway(): HttpGateway? {
val url = store.serverUrl.trim() val url = store.serverUrl.trim()
val token = store.token val token = store.token
if (url.isBlank() || token.isBlank()) return null if (url.isBlank() || token.isBlank()) return null
return http return http
?: HttpGateway(client, HttpGateway.deriveHttpUrl(url), token, store.deviceId) ?: HttpGateway(
.also { http = it } client,
} HttpGateway.deriveHttpUrl(url),
token,
private suspend fun httpHealthy(): Boolean = store.deviceId,
try { deviceName = store.deviceName,
httpGateway()?.health() ?: false fcmToken = { store.fcmToken.ifBlank { null } },
} catch (e: Exception) { ntfyTopic = { store.ntfyTopic.ifBlank { null } },
false ).also { http = it }
}
/**
* Enter [State.HttpFallback]: open the SSE (or long-poll) receive loop
* from the saved sync cursor. Idempotent; a no-op while WS-connected.
*/
private fun enterHttpFallback() {
if (_state.value is State.Connected) return
val gw = httpGateway() ?: return
sseFailures = 0
usingLongPoll = false
httpCursor = store.syncCursor
// Provisional state (previous caps/channels) until the SSE hello
// arrives with the real ones.
val prev = _state.value
_state.value =
State.HttpFallback(
caps = (prev as? State.HelloInfo)?.caps ?: ServerCaps(),
channels = (prev as? State.HelloInfo)?.channels ?: emptyList(),
lastPushedCursor = (prev as? State.HelloInfo)?.lastPushedCursor ?: 0,
)
httpJob?.cancel()
httpJob = scope.launch { httpReceiveLoop(gw) }
}
private fun stopHttpLeg() {
httpJob?.cancel()
httpJob = null
sseFailures = 0
usingLongPoll = false
} }
/** /**
* The HTTP receive loop: SSE by default; after two consecutive SSE open * The HTTP receive loop: SSE by default; after two consecutive SSE open
* failures (buffering proxy) it switches to long-poll until the next * failures (buffering proxy) it switches to long-poll until the next full
* full (re)connect (docs/19 §19.6). * (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
*/ */
private suspend fun httpReceiveLoop(gw: HttpGateway) { private suspend fun httpReceiveLoop(gw: HttpGateway) {
var backoff = 1_000L var backoff = 1_000L
@@ -317,7 +257,14 @@ class GatewayClient(
val res = gw.poll(httpCursor) val res = gw.poll(httpCursor)
res.frames.forEach { emitHttpFrame(it) } res.frames.forEach { emitHttpFrame(it) }
if (res.cursor > httpCursor) httpCursor = res.cursor if (res.cursor > httpCursor) httpCursor = res.cursor
// The poll answered: the link is back (long-poll has no
// hello — restore the Connected state from the last one).
restoreConnected()
} catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return
} catch (e: Exception) { } catch (e: Exception) {
markStreamLost()
IrisLog.w("http poll failed: ${e.message}") IrisLog.w("http poll failed: ${e.message}")
delay(backoff) delay(backoff)
backoff = minOf(backoff * 2, 15_000) backoff = minOf(backoff * 2, 15_000)
@@ -332,8 +279,12 @@ class GatewayClient(
) )
// Clean EOF: reconnect immediately. // Clean EOF: reconnect immediately.
backoff = 1_000L backoff = 1_000L
} catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return
} catch (e: Exception) { } catch (e: Exception) {
sseFailures++ sseFailures++
markStreamLost()
if (sseFailures >= 2) { if (sseFailures >= 2) {
// SSE seems blocked: switch to long-poll. // SSE seems blocked: switch to long-poll.
usingLongPoll = true usingLongPoll = true
@@ -350,12 +301,35 @@ class GatewayClient(
/** The SSE `event: hello` (the HTTP hello.ack). */ /** The SSE `event: hello` (the HTTP hello.ack). */
private fun onHttpHello(ack: HelloAckPayload) { private fun onHttpHello(ack: HelloAckPayload) {
lastAck = ack lastAck = ack
val fb = State.HttpFallback(ack.serverCaps, ack.channels, ack.lastPushedCursor) val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
_state.value = fb _state.value = connected
onHelloAck?.invoke(fb) // M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor
if (local < ack.syncCursor) {
val id = nextRequestId++
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
}
// Prompt fast path (before the possibly-starved state collector).
onHelloAck?.invoke(connected)
} }
/** Deliver an HTTP-leg frame to the same sinks as a WS frame. */ /** The receive stream just died: don't keep claiming "Connected" while
* between attempts (a stale green dot through a Wi-Fi drop). */
private fun markStreamLost() {
if (_state.value is State.Connected) _state.value = State.Reconnecting
}
/** The receive stream is open again (long-poll answered): the link is
* back. Long-poll has no hello, so restore the Connected state from the
* last hello.ack (the SSE path gets a fresh one). */
private fun restoreConnected() {
if (_state.value !is State.Reconnecting) return
_state.value =
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
?: State.Connected(ServerCaps(), emptyList())
}
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */
private fun emitHttpFrame(frame: Frame) { private fun emitHttpFrame(frame: Frame) {
_events.tryEmit(frame) _events.tryEmit(frame)
frame.id?.let { id -> frame.id?.let { id ->
@@ -363,207 +337,51 @@ class GatewayClient(
} }
} }
// ── Dial (one connect + hello) ────────────────────────────────────────
private sealed interface DialResult {
data object Connected : DialResult
data class AuthFailed(
val message: String,
) : DialResult
data class Failed(
val message: String,
) : DialResult
}
private data class Dial(
val result: DialResult,
val socket: WebSocket,
val closed: CompletableDeferred<Unit>,
)
private suspend fun dial(
url: String,
token: String,
): Dial {
val closed = CompletableDeferred<Unit>()
val helloAck = CompletableDeferred<HelloAckPayload>()
val authError = CompletableDeferred<String>()
val fail = CompletableDeferred<String>()
val request = Request.Builder().url(url).build()
val ws =
client.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
IrisLog.d("ws open (${response.code})")
webSocket.send(
helloFrame(
token = token,
deviceId = store.deviceId,
deviceName = store.deviceName,
fcmToken = store.fcmToken.ifBlank { null },
ntfyTopic = store.ntfyTopic.ifBlank { null },
).toWire(),
)
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
lastLiveness = TimeSource.Monotonic.markNow()
val frame =
try {
IrisJson.instance.decodeFromString(Frame.serializer(), text)
} catch (e: Exception) {
// A dropped frame is silent data loss — log it (the
// first bytes hint at which frame it was).
IrisLog.e("frame decode failed (${text.length}B): $e :: ${text.take(120)}")
return
}
when (frame.type) {
TYPE_HELLO_ACK -> {
val ack = frame.payloadAs<HelloAckPayload>()
if (ack != null) helloAck.complete(ack)
}
TYPE_ERROR -> {
val err = frame.payloadAs<ErrorPayload>()
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
}
else -> {
_events.tryEmit(frame)
frame.id?.let { id ->
pending[id]?.complete(frame)
// M4: terminal frame of a pull stream — close
// the chunk channel so the pull loop exits.
if (frame.type == TYPE_MEDIA_PULL_END) {
(binarySession as? BinarySession.Pulling)?.let {
it.chunks.close()
binarySession = null
}
}
}
}
}
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
lastLiveness = TimeSource.Monotonic.markNow()
// M4: binary frames belong to the active pull stream
// (uploads are outbound; stray inbound chunks are dropped).
(binarySession as? BinarySession.Pulling)
?.chunks
?.trySend(bytes.toByteArray())
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
IrisLog.w("ws closed code=$code reason=\"$reason\"")
closed.complete(Unit)
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
IrisLog.e("ws failure: ${t.javaClass.simpleName}: ${t.message} (http=${response?.code})")
fail.complete(t.message ?: "connection failed")
closed.complete(Unit)
}
},
)
socket = ws
val winner = CompletableDeferred<DialResult>()
helloAck.invokeOnCompletion { e ->
if (e == null) {
val ack = helloAck.getCompleted()
lastAck = ack
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
_state.value = connected
// M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor
if (local < ack.syncCursor) {
val id = nextRequestId++
ws.send(syncFrame(id, local).toWire())
}
// Prompt fast path (before the possibly-starved state collector).
onHelloAck?.invoke(connected)
winner.complete(DialResult.Connected)
}
}
authError.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
}
fail.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
}
val result =
withTimeoutOrNull(15_000) { winner.await() }
?: DialResult.Failed("timeout waiting for hello.ack")
return Dial(result, ws, closed)
}
// ── Outbound ────────────────────────────────────────────────────────── // ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back). /** Send a text message (fire-and-forget; the server echoes it back).
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs). * [mediaRefs] reference completed uploads (POST /v1/media refs).
* [autoThread] asks the gateway to mint a fresh thread for the message * [autoThread] asks the gateway to mint a fresh thread for the message
* (auto-threading, docs/06 §6.3). */ * (auto-threading, docs/06 §6.3).
* [onResult] is called with the POST's HTTP status — 0 means "no
* response" (not connected, or the network failed); 2xx means the
* gateway accepted it; 4xx is a gateway rejection (error frame already
* delivered via [events]). Used to fail the optimistic bubble instead
* of leaving it at "sending…" forever. */
fun sendMessage( fun sendMessage(
chatId: String, chatId: String,
text: String, text: String,
threadId: String? = null, threadId: String? = null,
mediaRefs: List<String> = emptyList(), mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false, autoThread: Boolean = false,
onResult: ((Int) -> Unit)? = null,
) { ) {
val ws = socket if (_state.value !is State.Connected) {
if (ws != null && _state.value is State.Connected) { onResult?.invoke(0)
val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
return return
} }
// docs/19: WS down — route over the HTTP leg. Media is WS-only in
// v1 (uploads need the live connection), so mediaRefs are dropped in
// fallback (the UI disables the attach button in that state).
if (_state.value is State.HttpFallback) {
val id = nextRequestId++ val id = nextRequestId++
scope.launch { scope.launch {
val res =
httpGateway()?.postFrame( httpGateway()?.postFrame(
messageSendFrame(id, chatId, text, threadId, emptyList(), autoThread), messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread),
) )
// The synchronous reply (e.g. the read receipt, or an error frame
// on 4xx) comes back in the POST body, not on the event stream —
// deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) }
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
} }
onResult?.invoke(res?.status ?: 0)
} }
} }
// ── M4: media upload / pull ─────────────────────────────────────────── // ── Media upload / pull ───────────────────────────────────────────────
/** /**
* Upload a local file as media (docs/07 §7.2): media.upload.start, * Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's * Returns the server's media_ref (for message.send media_refs) on success.
* media_ref (for message.send media_refs) on success.
*/ */
suspend fun uploadMedia( suspend fun uploadMedia(
path: String, path: String,
@@ -572,201 +390,132 @@ class GatewayClient(
filename: String, filename: String,
mediaRef: String, mediaRef: String,
): Result<String> { ): Result<String> {
val ws = socket ?: return Result.failure(IllegalStateException("not connected")) val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
val source = FileSource(path) return http.uploadMedia(path, mime, kind, filename, mediaRef)
val size = source.size()
if (size <= 0) {
source.close()
return Result.failure(IllegalStateException("empty file"))
}
val id = nextRequestId++
val reply = CompletableDeferred<Frame>()
pending[id] = reply
try {
ws.send(mediaUploadStartFrame(id, mediaRef, kind, mime, filename, size).toWire())
val sha = Sha256()
source.use {
val buf = ByteArray(UPLOAD_CHUNK_BYTES)
while (true) {
val n = it.read(buf)
if (n < 0) break
if (n == 0) continue
sha.update(buf, 0, n)
ws.send(buf.copyOfRange(0, n).toByteString())
}
}
ws.send(mediaUploadEndFrame(id, mediaRef, sha.hex()).toWire())
val frame = withTimeout(UPLOAD_TIMEOUT_MS) { reply.await() }
return when (frame.type) {
TYPE_MEDIA_UPLOAD_ACK -> {
val p = frame.payloadAs<MediaUploadAckPayload>()
if (p != null && p.ok) {
Result.success(p.mediaRef)
} else {
Result.failure(IllegalStateException("upload rejected by server"))
}
}
TYPE_ERROR -> {
val e = frame.payloadAs<ErrorPayload>()
Result.failure(IllegalStateException(e?.message ?: "upload failed"))
}
else -> {
Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
}
}
} catch (e: Exception) {
return Result.failure(e)
} finally {
pending.remove(id)
}
} }
/** /**
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until * Pull offered media via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
* media.pull.end. Each chunk is handed to [onChunk] (write to cache). * chunk is handed to [onChunk] (write to cache).
*/ */
suspend fun pullMedia( suspend fun pullMedia(
mediaId: String, mediaId: String,
onChunk: suspend (ByteArray) -> Unit, onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> = ): Result<Unit> =
pullMutex.withLock { pullMutex.withLock {
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected")) val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
val id = nextRequestId++ http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
val chunks = Channel<ByteArray>(Channel.UNLIMITED)
val end = CompletableDeferred<Frame>()
pending[id] = end
binarySession = BinarySession.Pulling(id, chunks, end)
try {
ws.send(mediaPullFrame(id, mediaId).toWire())
val frame =
withTimeout(PULL_TIMEOUT_MS) {
for (chunk in chunks) onChunk(chunk)
end.await()
}
when (frame.type) {
TYPE_MEDIA_PULL_END -> {
val p = frame.payloadAs<MediaPullEndPayload>()
if (p != null && p.ok) {
Result.success(Unit)
} else {
Result.failure(IllegalStateException("pull failed"))
}
}
TYPE_ERROR -> {
val e = frame.payloadAs<ErrorPayload>()
Result.failure(IllegalStateException(e?.message ?: "pull failed"))
}
else -> {
Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
}
}
} catch (e: Exception) {
Result.failure(e)
} finally {
pending.remove(id)
chunks.cancel()
val s = binarySession
if (s is BinarySession.Pulling && s.requestId == id) binarySession = null
}
} }
companion object { companion object {
/** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */
const val UPLOAD_CHUNK_BYTES = 256 * 1024
const val UPLOAD_TIMEOUT_MS = 120_000L const val UPLOAD_TIMEOUT_MS = 120_000L
const val PULL_TIMEOUT_MS = 300_000L const val PULL_TIMEOUT_MS = 300_000L
} }
/** /**
* Send an arbitrary frame with a fresh request id (fire-and-forget). * Send an arbitrary frame with a fresh request id (fire-and-forget). The
* The server replies (or broadcasts) a frame carrying the same id; the * server replies (or broadcasts) a frame carrying the same id; the app
* app reconciles from [events]. Returns the id used, or -1 if not connected. * reconciles from [events]. Returns the id used, or -1 if not connected.
*/ */
fun sendFrame(frame: Frame): Int { fun sendFrame(frame: Frame): Int {
if (_state.value !is State.Connected) return -1
val id = nextRequestId++ val id = nextRequestId++
val ws = socket
if (ws != null && _state.value is State.Connected) {
ws.send(frame.copy(id = id).toWire())
return id
}
// docs/19: WS down — route over the HTTP leg; the response (same id)
// arrives on the SSE/long-poll stream via [events].
if (_state.value is State.HttpFallback) {
scope.launch { scope.launch {
httpGateway()?.postFrame(frame.copy(id = id)) val res = httpGateway()?.postFrame(frame.copy(id = id))
// Single-frame responses (commands.catalog, channel.list, search,
// history, sync, errors) come back in the POST body, not on the
// event stream — deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) }
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
} }
return id return id
} }
return -1
}
/** Send a ping (heartbeat). */
fun ping() {
socket?.send(pingFrame().toWire())
}
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
fun isStale(timeoutMs: Long = 60_000): Boolean =
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
fun reapStale() {
if (isStale()) {
socket?.close(1000, "heartbeat timeout")
}
}
// ── One-shot hello test (Connect screen) ────────────────────────────── // ── One-shot hello test (Connect screen) ──────────────────────────────
/** /**
* Real `hello` test: dial, wait for hello.ack (or auth error), close. * Real connection test: health probe + SSE open. The auth leg is proven
* Exercises the auth leg, not just TCP (docs/10 §10.8). * by the stream being accepted (200 vs 401) — we do NOT wait for the
* hello event, because the server replays the outbox (up to 72 h of
* frames) before it and a large outbox would time out a healthy gateway
* (docs/10 §10.8).
*/ */
suspend fun testHello( suspend fun testHello(
url: String, url: String,
token: String, token: String,
): Result<Unit> { ): Result<Unit> {
val dial = dial(url, token) val gw =
return when (val result = dial.result) { HttpGateway(
DialResult.Connected -> { client,
dial.socket.close(1000, "test complete") HttpGateway.deriveHttpUrl(url),
token,
store.deviceId,
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
)
return try {
if (!gw.health()) {
Result.failure(IllegalStateException("gateway unreachable"))
} else {
// Open the SSE stream briefly: 200 = auth leg proven, 401 =
// bad token. Don't wait for the hello (outbox replay first).
val opened = CompletableDeferred<Unit>()
val job =
scope.launch {
try {
gw.events(
cursor = store.syncCursor,
onOpen = { opened.complete(Unit) },
onHello = { },
onFrame = { },
onCursor = { },
)
} catch (e: Exception) {
opened.completeExceptionally(e)
}
}
try {
if (withTimeoutOrNull(10_000) { opened.await() } == null) {
Result.failure(IllegalStateException("timeout opening the event stream"))
} else {
Result.success(Unit) Result.success(Unit)
} }
} catch (e: HttpGateway.HttpAuthException) {
is DialResult.AuthFailed -> { Result.failure(IllegalStateException("unauthorized — check the pairing token"))
Result.failure(IllegalStateException(result.message)) } catch (e: CancellationException) {
} throw e
} catch (e: Exception) {
is DialResult.Failed -> { Result.failure(IllegalStateException("connection failed: ${e.message}"))
Result.failure(IllegalStateException(result.message)) } finally {
} job.cancel()
}
}
// ── Heartbeat job ─────────────────────────────────────────────────────
fun startHeartbeat() {
scope.launch {
while (isActive) {
delay(20_000)
if (_state.value is State.Connected) {
ping()
reapStale()
} }
} }
} catch (e: Exception) {
Result.failure(e)
} }
} }
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
/** Backoff that wakes early when [poke] is called (app foregrounded). */
private suspend fun backoffOrWake(ms: Long) {
var remaining = ms
while (remaining > 0 && currentCoroutineContext().isActive) {
delay(minOf(remaining, 500L))
if (wakeRequested) {
wakeRequested = false
return
}
remaining -= 500L
}
}
private fun backoffMs(attempt: Int): Long { private fun backoffMs(attempt: Int): Long {
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
val capped = minOf(base, 30_000L) val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500) return capped + Random.nextLong(0, 500)
} }
} }
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
@@ -1,8 +1,11 @@
package iris.net package iris.net
import iris.media.Sha256
import iris.protocol.ErrorPayload
import iris.protocol.Frame import iris.protocol.Frame
import iris.protocol.HelloAckPayload import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.protocol.MediaUploadAckPayload
import iris.util.IrisLog import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -13,17 +16,18 @@ import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* HTTP fallback transport client (docs/19): the "HTTP leg". * HTTP transport client (docs/19) — the only transport.
* *
* When the WS is down (flaky network, NAT timeout, app just relaunched), * The app sends over `POST /v1/frame` and receives over SSE
* the app sends over `POST /v1/frame` and receives over SSE * `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked). * media travels via `POST/GET /v1/media`.
* Same frames, same outbox cursor, same token as the WS.
* *
* [events] is ONE SSE connection attempt (blocking read on * [events] is ONE SSE connection attempt (blocking read on
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks * [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
@@ -35,13 +39,27 @@ class HttpGateway(
private val baseUrl: String, private val baseUrl: String,
private val token: String, private val token: String,
private val deviceId: String, private val deviceId: String,
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
* upserts it into the device registry on every SSE open — the HTTP
* equivalent of the old WS hello upsert). */
private val deviceName: String? = null,
/** Live push-token providers, read per request so a rotated FCM token or
* a fresh ntfy topic is picked up without rebuilding the client. */
private val fcmToken: () -> String? = { null },
private val ntfyTopic: () -> String? = { null },
) { ) {
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
* with the same token can't succeed. */
class HttpAuthException : IOException("unauthorized (HTTP 401)")
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE // OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients // heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
// with extended call timeouts (see the *Client() helpers below). // with extended call timeouts (see the *Client() helpers below).
private val healthClient: OkHttpClient = client.healthClient() private val healthClient: OkHttpClient = client.healthClient()
private val streamClient: OkHttpClient = client.streamClient() private val streamClient: OkHttpClient = client.streamClient()
private val pollClient: OkHttpClient = client.pollClient() private val pollClient: OkHttpClient = client.pollClient()
private val mediaClient: OkHttpClient = client.mediaClient()
/** POST /v1/frame result. [frame] is the handler's synchronous reply /** POST /v1/frame result. [frame] is the handler's synchronous reply
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain * (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
* 202 accept-and-ack. */ * 202 accept-and-ack. */
@@ -64,6 +82,9 @@ class HttpGateway(
/** Default port of the gateway's HTTP leg (WS default is 8790). */ /** Default port of the gateway's HTTP leg (WS default is 8790). */
const val DEFAULT_PORT = 8791 const val DEFAULT_PORT = 8791
/** Media transfer chunk (docs/07 §7.5). */
private const val MEDIA_CHUNK_BYTES = 256 * 1024
/** /**
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4): * Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is * `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
@@ -84,12 +105,38 @@ class HttpGateway(
} }
} }
private fun authHeaders(): Headers = private fun authHeaders(): Headers {
val b =
Headers Headers
.Builder() .Builder()
.add("Authorization", "Bearer $token") .add("Authorization", "Bearer $token")
.add("X-Iris-Device", deviceId) .add("X-Iris-Device", deviceId)
.build() // Device registration (docs/19): the gateway upserts name + push
// tokens from these headers on every SSE open (COALESCE — absent
// headers never clobber a newer fcm.register value).
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
return b.build()
}
/** Parse a response body as a protocol frame (null when not a frame,
* e.g. the plain `{"ok":true}` ack). */
private fun parseFrame(body: String): Frame? =
try {
if (body.startsWith("{")) {
val obj = IrisJson.instance.parseToJsonElement(body)
if (obj.jsonObject.containsKey("type")) {
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
} else {
null
}
} else {
null
}
} catch (e: Exception) {
null
}
/** Liveness probe (unauthenticated by design). True on 200. */ /** Liveness probe (unauthenticated by design). True on 200. */
suspend fun health(): Boolean = suspend fun health(): Boolean =
@@ -111,7 +158,11 @@ class HttpGateway(
/** /**
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok] * POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
* (with the synchronous reply frame when the handler sent one); 4xx -> * (with the synchronous reply frame when the handler sent one); 4xx ->
* the error frame as the body. * the error frame as the body. Network failures (timeout, reset, DNS —
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
* callers are fire-and-forget coroutines — an uncaught exception here
* kills the app process.
*/ */
suspend fun postFrame(frame: Frame): PostResult = suspend fun postFrame(frame: Frame): PostResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -123,40 +174,33 @@ class HttpGateway(
.headers(authHeaders()) .headers(authHeaders())
.post(wire.toRequestBody(JSON)) .post(wire.toRequestBody(JSON))
.build() .build()
try {
client client
.newCall(request) .newCall(request)
.execute() .execute()
.use { response -> .use { response ->
val body = response.body?.string().orEmpty() val body = response.body?.string().orEmpty()
val parsed = val parsed = parseFrame(body)
try { PostResult(response.isSuccessful, response.code, parsed)
if (body.startsWith("{")) {
val obj = IrisJson.instance.parseToJsonElement(body)
// 202 {"ok":true} is not a frame; 4xx/200 bodies are.
if (obj.jsonObject.containsKey("type")) {
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
} else {
null
}
} else {
null
} }
} catch (e: Exception) { } catch (e: Exception) {
null IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
} PostResult(ok = false, status = 0, frame = null)
PostResult(response.isSuccessful, response.code, parsed)
} }
} }
/** /**
* One SSE connection attempt: outbox catch-up from [cursor], then live * One SSE connection attempt: outbox catch-up from [cursor], then live
* frames. [onHello] fires for `event: hello` (the HTTP hello.ack); * frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
* leg is proven; the server may still replay a large outbox before the
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox * [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
* cursor) when present. Returns on clean EOF; throws [IOException] on * cursor) when present. Returns on clean EOF; throws [IOException] on
* open/read failure. Callbacks run on the IO thread. * open/read failure. Callbacks run on the IO thread.
*/ */
suspend fun events( suspend fun events(
cursor: Long, cursor: Long,
onOpen: (() -> Unit)? = null,
onHello: (HelloAckPayload) -> Unit, onHello: (HelloAckPayload) -> Unit,
onFrame: (Frame) -> Unit, onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit, onCursor: (Long) -> Unit,
@@ -168,13 +212,15 @@ class HttpGateway(
.url("$baseUrl/v1/events?cursor=$cursor") .url("$baseUrl/v1/events?cursor=$cursor")
.headers(authHeaders()) .headers(authHeaders())
.build() .build()
client streamClient
.newCall(request) .newCall(request)
.execute() .execute()
.use { response -> .use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("SSE open failed: HTTP ${response.code}") throw IOException("SSE open failed: HTTP ${response.code}")
} }
onOpen?.invoke()
val source = response.body?.source() ?: throw IOException("empty SSE body") val source = response.body?.source() ?: throw IOException("empty SSE body")
var eventId: String? = null var eventId: String? = null
val dataLines = mutableListOf<String>() val dataLines = mutableListOf<String>()
@@ -248,10 +294,11 @@ class HttpGateway(
.url("$baseUrl/v1/poll?cursor=$cursor") .url("$baseUrl/v1/poll?cursor=$cursor")
.headers(authHeaders()) .headers(authHeaders())
.build() .build()
client pollClient
.newCall(request) .newCall(request)
.execute() .execute()
.use { response -> .use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("poll failed: HTTP ${response.code}") throw IOException("poll failed: HTTP ${response.code}")
} }
@@ -271,29 +318,160 @@ class HttpGateway(
PollResult(newCursor, frames) PollResult(newCursor, frames)
} }
} }
/**
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
* with the whole file as the body and the metadata in `X-Iris-Media-*`
* headers (sha256 precomputed in a first pass). Returns the server's
* media_ref (for message.send media_refs) on success.
*/
suspend fun uploadMedia(
path: String,
mime: String,
kind: String,
filename: String,
mediaRef: String,
): Result<String> =
withContext(Dispatchers.IO) {
val file = File(path)
if (!file.isFile() || file.length() <= 0) {
return@withContext Result.failure(IllegalStateException("empty file"))
}
val sha = Sha256()
file.inputStream().use { ins ->
val buf = ByteArray(MEDIA_CHUNK_BYTES)
while (true) {
val n = ins.read(buf)
if (n < 0) break
if (n > 0) sha.update(buf, 0, n)
}
}
val request =
Request
.Builder()
.url("$baseUrl/v1/media")
.headers(
authHeaders()
.newBuilder()
.add("X-Iris-Media-Ref", mediaRef)
.add("X-Iris-Media-Kind", kind)
.add("X-Iris-Media-Filename", filename)
.add("X-Iris-Media-Sha256", sha.hex())
.build(),
).post(file.asRequestBody(mime.toMediaType()))
.build()
try {
mediaClient
.newCall(request)
.execute()
.use { response ->
val body = response.body?.string().orEmpty()
val frame = parseFrame(body)
if (response.isSuccessful) {
val p = frame?.payloadAs<MediaUploadAckPayload>()
if (p != null && p.ok) {
Result.success(p.mediaRef)
} else {
Result.failure(IllegalStateException("upload rejected by server"))
}
} else {
val e = frame?.payloadAs<ErrorPayload>()
Result.failure(
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
)
}
}
} catch (e: Exception) {
// Network failure mid-upload: report, don't throw (the caller
// is a fire-and-forget coroutine — an uncaught exception kills
// the app process).
IrisLog.w("media upload failed: ${e.message}")
Result.failure(e)
}
}
/**
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
* response body is the file, streamed to [onChunk] (write to cache).
*/
suspend fun pullMedia(
mediaId: String,
onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/media/$mediaId")
.headers(authHeaders())
.build()
try {
mediaClient
.newCall(request)
.execute()
.use { response ->
if (!response.isSuccessful) {
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
Result.failure(
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
)
} else {
try {
val source = response.body?.source() ?: throw IOException("empty body")
val buf = ByteArray(MEDIA_CHUNK_BYTES)
while (true) {
val n = source.read(buf)
if (n < 0) break
if (n == 0) continue
onChunk(buf.copyOfRange(0, n))
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
} catch (e: Exception) {
// Network failure before/while opening the pull: report, don't
// throw (fire-and-forget caller — uncaught = process death).
IrisLog.w("media pull failed: ${e.message}")
Result.failure(e)
}
}
} }
/** /**
* OkHttp's default read timeout (10 s) is shorter than the gateway's SSE * Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
* heartbeat (15 s) and the long-poll hold (25 s) — extend the call timeout * is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
* for streaming endpoints. Applied via [OkHttpClient] builders in * (25 s) — it would kill both receive paths while they are simply waiting
* [GatewayClient]. * for the next byte, so the streaming clients override it. The read timeout
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
* comment every 15 s; a healthy poll answers within 25 s).
*/ */
internal const val HTTP_STREAM_CALL_TIMEOUT_MS = 60_000L
internal const val HTTP_POLL_CALL_TIMEOUT_MS = 35_000L
internal const val HTTP_HEALTH_TIMEOUT_MS = 2_000L
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
* heartbeat (detects a dead connection within 45 s). */
internal fun OkHttpClient.streamClient(): OkHttpClient = internal fun OkHttpClient.streamClient(): OkHttpClient =
newBuilder() newBuilder()
.callTimeout(HTTP_STREAM_CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS) .callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(45_000, TimeUnit.MILLISECONDS)
.build() .build()
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
* hold + 15 s margin. */
internal fun OkHttpClient.pollClient(): OkHttpClient = internal fun OkHttpClient.pollClient(): OkHttpClient =
newBuilder() newBuilder()
.callTimeout(HTTP_POLL_CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS) .callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(40_000, TimeUnit.MILLISECONDS)
.build() .build()
internal fun OkHttpClient.healthClient(): OkHttpClient = internal fun OkHttpClient.healthClient(): OkHttpClient =
newBuilder() newBuilder()
.callTimeout(HTTP_HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS) .callTimeout(2_000, TimeUnit.MILLISECONDS)
.build()
/** Media transfers (upload/pull) can take a while on large files. */
internal fun OkHttpClient.mediaClient(): OkHttpClient =
newBuilder()
.callTimeout(300_000, TimeUnit.MILLISECONDS)
.build() .build()
@@ -30,13 +30,10 @@ object IrisJson {
// ── Frame type constants ──────────────────────────────────────────────── // ── Frame type constants ────────────────────────────────────────────────
const val TYPE_HELLO = "hello"
const val TYPE_HELLO_ACK = "hello.ack" const val TYPE_HELLO_ACK = "hello.ack"
const val TYPE_MESSAGE = "message" const val TYPE_MESSAGE = "message"
const val TYPE_MESSAGE_SEND = "message.send" const val TYPE_MESSAGE_SEND = "message.send"
const val TYPE_ERROR = "error" const val TYPE_ERROR = "error"
const val TYPE_PING = "ping"
const val TYPE_PONG = "pong"
const val TYPE_TYPING = "typing" const val TYPE_TYPING = "typing"
// M2 — streaming / tools / commentary // M2 — streaming / tools / commentary
@@ -52,13 +49,9 @@ const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end" const val TYPE_TOOL_END = "tool.end"
const val TYPE_COMMENTARY = "commentary" const val TYPE_COMMENTARY = "commentary"
// M4 — media (upload / offer / pull) // M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
const val TYPE_MEDIA_UPLOAD_START = "media.upload.start"
const val TYPE_MEDIA_UPLOAD_END = "media.upload.end"
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack" const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
const val TYPE_MEDIA_OFFER = "media.offer" const val TYPE_MEDIA_OFFER = "media.offer"
const val TYPE_MEDIA_PULL = "media.pull"
const val TYPE_MEDIA_PULL_END = "media.pull.end"
// M5 — push / notifications / read receipt / gateway status // M5 — push / notifications / read receipt / gateway status
const val TYPE_NOTIFICATION = "notification" const val TYPE_NOTIFICATION = "notification"
@@ -133,18 +126,6 @@ data class Frame(
} }
} }
// ── hello (app -> server) ───────────────────────────────────────────────
@Serializable
data class HelloPayload(
val token: String,
@SerialName("device_id") val deviceId: String,
@SerialName("device_name") val deviceName: String,
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
@SerialName("fcm_token") val fcmToken: String? = null,
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
)
// ── hello.ack (server -> app) ─────────────────────────────────────────── // ── hello.ack (server -> app) ───────────────────────────────────────────
@Serializable @Serializable
@@ -241,21 +222,6 @@ data class MediaRef(
val filename: String, val filename: String,
) )
@Serializable
data class MediaUploadStartPayload(
@SerialName("media_ref") val mediaRef: String,
val kind: String,
val mime: String,
val filename: String,
val size: Long,
)
@Serializable
data class MediaUploadEndPayload(
@SerialName("media_ref") val mediaRef: String,
@SerialName("sha256") val sha256: String,
)
@Serializable @Serializable
data class MediaUploadAckPayload( data class MediaUploadAckPayload(
val ok: Boolean, val ok: Boolean,
@@ -272,16 +238,6 @@ data class MediaOfferPayload(
@SerialName("message_id") val messageId: String? = null, @SerialName("message_id") val messageId: String? = null,
) )
@Serializable
data class MediaPullPayload(
@SerialName("media_id") val mediaId: String,
)
@Serializable
data class MediaPullEndPayload(
val ok: Boolean,
)
// ── M2: streaming frames (server -> app) ──────────────────────────────── // ── M2: streaming frames (server -> app) ────────────────────────────────
@Serializable @Serializable
@@ -353,7 +309,7 @@ data class MessageSendPayload(
@SerialName("auto_thread") val autoThread: Boolean = false, @SerialName("auto_thread") val autoThread: Boolean = false,
) )
// ── typing / error / ping ─────────────────────────────────────────────── // ── typing / error ──────────────────────────────────────────────────────
@Serializable @Serializable
data class TypingPayload( data class TypingPayload(
@@ -366,11 +322,6 @@ data class ErrorPayload(
val message: String, val message: String,
) )
@Serializable
data class PingPayload(
val ts: Long? = null,
)
// ── M3: channel directory (app -> server requests) ────────────────────── // ── M3: channel directory (app -> server requests) ──────────────────────
@Serializable @Serializable
@@ -548,28 +499,6 @@ data class StatusPayload(
// ── Frame builders ────────────────────────────────────────────────────── // ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(
token: String,
deviceId: String,
deviceName: String,
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame(
type = TYPE_HELLO,
payload =
IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(
token = token,
deviceId = deviceId,
deviceName = deviceName,
fcmToken = fcmToken,
ntfyTopic = ntfyTopic,
),
),
)
fun messageSendFrame( fun messageSendFrame(
id: Int, id: Int,
chatId: String, chatId: String,
@@ -590,8 +519,6 @@ fun messageSendFrame(
), ),
) )
fun pingFrame(): Frame = Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── M3 frame builders ─────────────────────────────────────────────────── // ── M3 frame builders ───────────────────────────────────────────────────
fun channelCreateFrame( fun channelCreateFrame(
@@ -765,55 +692,6 @@ fun messageDeleteFrame(
}, },
) )
// ── M4 frame builders ────────────────────────────────────────────────────
fun mediaUploadStartFrame(
id: Int,
mediaRef: String,
kind: String,
mime: String,
filename: String,
size: Long,
): Frame =
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_START,
payload =
IrisJson.instance.encodeToJsonElement(
MediaUploadStartPayload.serializer(),
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
),
)
fun mediaUploadEndFrame(
id: Int,
mediaRef: String,
sha256: String,
): Frame =
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_END,
payload =
IrisJson.instance.encodeToJsonElement(
MediaUploadEndPayload.serializer(),
MediaUploadEndPayload(mediaRef, sha256),
),
)
fun mediaPullFrame(
id: Int,
mediaId: String,
): Frame =
Frame(
id = id,
type = TYPE_MEDIA_PULL,
payload =
IrisJson.instance.encodeToJsonElement(
MediaPullPayload.serializer(),
MediaPullPayload(mediaId),
),
)
// ── M5 frame builders ─────────────────────────────────────────────────── // ── M5 frame builders ───────────────────────────────────────────────────
fun fcmRegisterFrame( fun fcmRegisterFrame(
@@ -28,6 +28,7 @@ import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload import iris.protocol.NotificationPayload
import iris.protocol.ROLE_ASSISTANT import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.ReadReceiptPayload import iris.protocol.ReadReceiptPayload
import iris.protocol.SearchHit import iris.protocol.SearchHit
import iris.protocol.SearchResultsPayload import iris.protocol.SearchResultsPayload
@@ -432,7 +433,7 @@ class IrisController(
) { ) {
if (chatId.isNullOrBlank()) return if (chatId.isNullOrBlank()) return
pendingDeepLink = chatId to threadId pendingDeepLink = chatId to threadId
if (client.state.value is GatewayClient.State.HelloInfo) applyDeepLink() if (client.state.value is GatewayClient.State.Connected) applyDeepLink()
} }
private fun applyDeepLink() { private fun applyDeepLink() {
@@ -605,6 +606,10 @@ class IrisController(
// stays unmarked and the next (re)connect // stays unmarked and the next (re)connect
// retries it. // retries it.
historyLoaded.add(lane) historyLoaded.add(lane)
// Offline sends that never arrived go out
// now (delivered duplicates were dropped by
// loadHistory's dedupe above).
reconcileFailedSends(lane)
} }
} }
} }
@@ -683,13 +688,13 @@ class IrisController(
client.state.collect { s -> client.state.collect { s ->
val prev = prevState val prev = prevState
prevState = s prevState = s
if (s is GatewayClient.State.HelloInfo) { if (s is GatewayClient.State.Connected) {
// Clear any stale "restarting" latch from the previous // Clear any stale "restarting" latch from the previous
// down phase (the gateway's own status{online} frame // down phase (the gateway's own status{online} frame
// follows on hello.ack and re-asserts the truth). // follows on hello.ack and re-asserts the truth).
_gatewayStatus.value = "online" _gatewayStatus.value = "online"
// The lane/history fast path runs on [client.onHelloAck] // The lane/history fast path runs on [client.onHelloAck]
// (promptly, on the WS thread) — see onConnectedLane. Here // (promptly, on the SSE thread) — see onConnectedLane. Here
// we do the non-time-critical connect work. // we do the non-time-critical connect work.
// M5: refresh the push-dedupe watermark (docs/08 §8.7). // M5: refresh the push-dedupe watermark (docs/08 §8.7).
lastPushedCursor = s.lastPushedCursor lastPushedCursor = s.lastPushedCursor
@@ -729,17 +734,21 @@ class IrisController(
if (store.ntfyTopic.isBlank()) { if (store.ntfyTopic.isBlank()) {
store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}" store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
} }
client.startHeartbeat()
// Prompt fast path: seed the channel directory + load the active lane's
// history the moment hello.ack lands (on the WS thread), not after the
// state collector (which can be starved for seconds on startup). This
// gets the history request out early so its response lands inside a
// flaky network's window.
client.onHelloAck = { connected -> client.onHelloAck = { connected ->
try { try {
onConnectedLane(connected) onConnectedLane(connected)
// Auto-resend offline sends AFTER the outbox replay has been
// processed: replayed frames precede the hello on the stream,
// but the frame collector may still be draining them — a
// delivered message whose POST response was lost must
// reconcile (echo replaces the failed bubble) before we
// decide to resend it.
scope.launch {
delay(2_000)
reconcileAllFailedSends()
}
} catch (e: Exception) { } catch (e: Exception) {
// Must not throw on the WS thread (would break the connection). // Must not throw on the SSE thread (would break the connection).
IrisLog.e("onConnectedLane failed: $e") IrisLog.e("onConnectedLane failed: $e")
} }
} }
@@ -755,7 +764,7 @@ class IrisController(
* after a process death the cached copy may be stale — so history always * after a process death the cached copy may be stale — so history always
* refreshes (skipped on a plain reconnect via historyLoaded). * refreshes (skipped on a plain reconnect via historyLoaded).
*/ */
private fun onConnectedLane(connected: GatewayClient.State.HelloInfo) { private fun onConnectedLane(connected: GatewayClient.State.Connected) {
channels.setAll(connected.channels) channels.setAll(connected.channels)
val home = connected.channels.firstOrNull { it.isDefault }?.chatId val home = connected.channels.firstOrNull { it.isDefault }?.chatId
_homeChannel.value = home ?: ChatStore.DEFAULT_LANE _homeChannel.value = home ?: ChatStore.DEFAULT_LANE
@@ -883,7 +892,7 @@ class IrisController(
/** Request the gateway's slash-command catalog. No-op while disconnected /** Request the gateway's slash-command catalog. No-op while disconnected
* (sendFrame drops silently); the response lands via [slashCommands]. */ * (sendFrame drops silently); the response lands via [slashCommands]. */
fun requestCommandsCatalog() { fun requestCommandsCatalog() {
if (client.state.value !is GatewayClient.State.HelloInfo) return if (client.state.value !is GatewayClient.State.Connected) return
client.sendFrame(commandsCatalogFrame(0)) client.sendFrame(commandsCatalogFrame(0))
} }
@@ -932,11 +941,36 @@ class IrisController(
localPath = it.path, localPath = it.path,
) )
} }
chat.addPending(trimmed, lane, media) val messageId = chat.addPending(trimmed, lane, media)
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId)) client.sendMessage(
chatId,
trimmed,
threadId,
refs,
autoThread = wantsAutoThread(trimmed, threadId, chatId),
onResult = { status -> onSendResult(messageId, status) },
)
_attachments.value = emptyList() _attachments.value = emptyList()
} }
/** POST result for an optimistic send: 2xx = accepted (the echo
* reconciles the bubble); 0 = no response (offline / network failure) —
* keep the bubble QUEUED (Pending) and remember it: it goes out on the
* next (re)connect, so the user can compose and send while the network
* is down; 4xx = gateway rejection — fail the bubble (tap to retry),
* no auto-retry (the gateway said no). */
private fun onSendResult(
messageId: String,
status: Int,
) {
if (status in 200..299) return
if (status == 0) {
networkFailed.add(messageId)
} else {
chat.failMessage(messageId)
}
}
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the /** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the
* default channel's flat lane gets its own fresh thread, AI-named by the * default channel's flat lane gets its own fresh thread, AI-named by the
* gateway (Telegram topic-mode workflow). Threading is only active on * gateway (Telegram topic-mode workflow). Threading is only active on
@@ -966,9 +1000,52 @@ class IrisController(
threadId, threadId,
item.media.map { it.mediaId }, item.media.map { it.mediaId },
autoThread = wantsAutoThread(item.text, threadId, chatId), autoThread = wantsAutoThread(item.text, threadId, chatId),
onResult = { status -> onSendResult(messageId, status) },
) )
} }
/** User message ids that failed for NETWORK reasons (status 0 — not a
* gateway error frame): queued (Pending) or failed bubbles that go out
* automatically on the next (re)connect. In-memory only — a process
* death leaves them as tap-to-retry (the local cache restore already
* marks pending sends failed). */
private val networkFailed = mutableSetOf<String>()
/** Resend queued/failed user messages of [lane] now that the link is
* back: a message the server already has (the POST response was lost in
* the drop) was reconciled by the echo / loadHistory dedupe, so anything
* still queued or Failed here never arrived — send it. */
private fun reconcileFailedSends(lane: String) {
val items = chat.lanes.value[lane] ?: return
for (item in items) {
if (item !is MessageItem || item.role != ROLE_USER || item.id !in networkFailed) {
continue
}
if (item.status != MsgStatus.Pending && item.status != MsgStatus.Failed) {
continue
}
networkFailed.remove(item.id)
chat.rearmForRetry(lane, item.id) // no-op for queued (already Pending)
val (chatId, threadId) = chat.parseLane(lane)
client.sendMessage(
chatId,
item.text,
threadId,
item.media.map { it.mediaId },
autoThread = wantsAutoThread(item.text, threadId, chatId),
onResult = { status -> onSendResult(item.id, status) },
)
}
}
/** Reconcile every lane (offline sends may sit in any lane). */
private fun reconcileAllFailedSends() {
if (networkFailed.isEmpty()) return
for (lane in chat.lanes.value.keys) {
reconcileFailedSends(lane)
}
}
/** Delete the given message(s) from the current lane (long-press select → /** Delete the given message(s) from the current lane (long-press select →
* delete). The server removes them from the outbox and broadcasts * delete). The server removes them from the outbox and broadcasts
* `message.deleted`; the local cache drops them on that frame (or * `message.deleted`; the local cache drops them on that frame (or
@@ -245,10 +245,8 @@ fun ChatScreen(controller: IrisController) {
val nonThreadChannels = channels.filter { it.kind != "thread" } val nonThreadChannels = channels.filter { it.kind != "thread" }
fun doSend() { fun doSend() {
// No-op while no transport is up (sendMessage drops silently); the // Offline sends are allowed: the bubble stays queued (Pending) and is
// send button is disabled in that state, this guards the IME "Send" // auto-resent on the next (re)connect (IrisController.onSendResult).
// action. docs/19: the HTTP fallback leg counts as sendable.
if (state !is GatewayClient.State.HelloInfo) return
val ready = attachments.filter { it.mediaRef != null && it.error == null } val ready = attachments.filter { it.mediaRef != null && it.error == null }
if (input.isBlank() && ready.isEmpty()) return if (input.isBlank() && ready.isEmpty()) return
val text = input val text = input
@@ -312,7 +310,7 @@ fun ChatScreen(controller: IrisController) {
} }
fun onSlashPick(cmd: SlashCommand) { fun onSlashPick(cmd: SlashCommand) {
if (state !is GatewayClient.State.HelloInfo) return if (state !is GatewayClient.State.Connected) return
input = "" input = ""
controller.send(cmd.name) controller.send(cmd.name)
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
@@ -740,15 +738,15 @@ fun ChatScreen(controller: IrisController) {
) )
} }
// Composer (M7: rounded pill + accent circular send button). Sending is gated // Composer (M7: rounded pill + accent circular send button). Sending is
// on a live socket: sendMessage is a no-op while disconnected, so an // NOT gated on a live connection: an offline send stays queued
// ungated send would show a pending bubble that never resolves. // (Pending) and is auto-resent on the next (re)connect (see
val isConnected = state is GatewayClient.State.HelloInfo // IrisController.onSendResult / reconcileFailedSends). The
// docs/19: media needs the live WS connection; in HTTP fallback // automation lane stays read-only regardless.
// only text is sendable. // docs/19 §19.15 (v2): media works over the HTTP leg too, so
val wsConnected = state is GatewayClient.State.Connected // attachments are sendable in both connected states (uploading
val canSend = // still needs a live connection — the picker stays gated).
isConnected && (input.isNotBlank() || (wsConnected && attachments.any { it.mediaRef != null && it.error == null })) val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
val layoutDensity = LocalDensity.current.density val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) } var textHeightPx by remember { mutableFloatStateOf(0f) }
if (isAutomation) { if (isAutomation) {
@@ -1826,7 +1824,6 @@ private fun statusLabel(state: GatewayClient.State): String =
GatewayClient.State.Connecting -> "connecting…" GatewayClient.State.Connecting -> "connecting…"
GatewayClient.State.Reconnecting -> "reconnecting…" GatewayClient.State.Reconnecting -> "reconnecting…"
is GatewayClient.State.Connected -> "connected" is GatewayClient.State.Connected -> "connected"
is GatewayClient.State.HttpFallback -> "connected · http"
is GatewayClient.State.AuthFailed -> "auth failed" is GatewayClient.State.AuthFailed -> "auth failed"
} }
@@ -2109,7 +2106,6 @@ private fun NameDialog(
private fun statusToastText(state: GatewayClient.State): String = private fun statusToastText(state: GatewayClient.State): String =
when (state) { when (state) {
is GatewayClient.State.Connected -> "Connected to Hermes" is GatewayClient.State.Connected -> "Connected to Hermes"
is GatewayClient.State.HttpFallback -> "Connected to Hermes (HTTP fallback — media paused)"
GatewayClient.State.Connecting -> "Connecting to Hermes" GatewayClient.State.Connecting -> "Connecting to Hermes"
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes" GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
GatewayClient.State.Disconnected -> "Unpaired from Hermes" GatewayClient.State.Disconnected -> "Unpaired from Hermes"
@@ -2126,7 +2122,6 @@ private fun StatusBubble(
val (color, pulsing) = val (color, pulsing) =
when (state) { when (state) {
is GatewayClient.State.Connected, is GatewayClient.State.Connected,
is GatewayClient.State.HttpFallback,
-> IrisColors.statusGreen to false -> IrisColors.statusGreen to false
GatewayClient.State.Connecting, GatewayClient.State.Connecting,
@@ -44,15 +44,15 @@ fun ConnectScreen(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Default is a cleartext (non-TLS) URL because the typical gateway is on // Default is a cleartext (non-TLS) URL because the typical gateway is on
// the LAN. A TLS gateway is reached by entering a secure (wss) URL instead. // the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
var token by remember { mutableStateOf(prefillToken) } var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) } var error by remember { mutableStateOf(initialError) }
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(24.dp), .padding(24.dp),
@@ -69,7 +69,8 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface) .background(IrisColors.surface)
@@ -79,9 +80,8 @@ fun ConnectScreen(
value = url, value = url,
onValueChange = { url = it }, onValueChange = { url = it },
label = { Text("Server URL") }, label = { Text("Server URL") },
// Example LAN URL; wss:// works too for TLS gateways. // Example LAN URL; https:// works too for TLS gateways.
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket placeholder = { Text("http://192.168.1.10:8791") },
placeholder = { Text("ws://192.168.1.10:8790/ws") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -136,4 +136,3 @@ fun ConnectScreen(
) )
} }
} }
+30 -26
View File
@@ -1,12 +1,15 @@
# 07 — Media (upload, download, playback) # 07 — Media (upload, download, playback)
Media travels **over the WebSocket** as chunked binary frames (decision: no Media travels **over HTTP** (`POST /v1/media` for upload,
separate HTTP server; keeps the plugin to `websockets` only). Both directions `GET /v1/media/{id}` for pull; see `19-http-fallback-transport.md` §19.15).
use the same chunking. HTTP is the only transport — the WebSocket leg (chunked binary frames) was
removed entirely. The contracts below (kinds, sha256, re-sniffing, delivery
validation) apply to both directions.
## 7.1 Kinds & MIME ## 7.1 Kinds & MIME
`kind` ∈ `image | audio | video | document | voice`. `kind` ∈ `image | audio | video | document | voice`.
- `image` — `image/*` (jpg/png/webp/gif/heic). - `image` — `image/*` (jpg/png/webp/gif/heic).
- `audio` — `audio/*` (mp3/m4a/ogg/…) — music. - `audio` — `audio/*` (mp3/m4a/ogg/…) — music.
- `video` — `video/*` (mp4/webm/mov). - `video` — `video/*` (mp4/webm/mov).
@@ -20,32 +23,36 @@ receipt (don't trust the client) using hermes helpers
## 7.2 Inbound (app → agent) — `media.upload` ## 7.2 Inbound (app → agent) — `media.upload`
**Flow:** **Flow:**
1. App picks a file (SAF) → reads size + MIME.
2. App sends `media.upload.start {media_ref, kind, mime, size, filename}`. 1. App picks a file (SAF) → reads size + MIME, computes sha256.
3. App streams the file as **binary WS frames** (e.g. 256 KiB chunks). 2. App `POST /v1/media` with the raw file body; metadata in
4. App sends `media.upload.end {media_ref, sha256}`. `X-Iris-Media-*` headers (`media_ref`, `kind`, `mime`, `filename`,
5. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media `sha256`).
3. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
cache via hermes `cache_*_from_bytes`: cache via hermes `cache_*_from_bytes`:
- image → `cache_image_from_bytes` - image → `cache_image_from_bytes`
- audio/voice → `cache_audio_from_bytes` - audio/voice → `cache_audio_from_bytes`
- video → `cache_video_from_bytes` - video → `cache_video_from_bytes`
- document → `cache_document_from_bytes` - document → `cache_document_from_bytes`
→ returns a local path. → returns a local path.
5b. Plugin replies `media.upload.ack {ok, media_ref}` (failures use `error`). 4. Plugin replies `media.upload.ack {ok, media_ref}` (failures use `error`).
6. The path is attached to the next `message.send` via `media_refs`, becoming 5. The path is attached to the next `message.send` via `media_refs`, becoming
`MessageEvent.media_urls` + `media_types` `MessageEvent.media_urls` + `media_types`
(`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then (`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then
read the file. read the file.
**Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size` **Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size`
(`base.py:758/779`) enforce the cap; over-limit → `error {code:"media_too_large"}`. (`base.py:758/779`) enforce the cap; over-limit → 413 +
`error {code:"media_too_large"}`. (The 1 MiB `MAX_BODY_BYTES` cap applies to
JSON *frame* bodies only, not media uploads.)
**Backpressure:** large uploads use the WS flow control; the plugin reads **Single-shot:** no chunking/resumability — HTTP carries the body; single-user
binary frames into a temp file (not memory) to bound RAM. scale makes a one-shot upload sufficient.
## 7.3 Outbound (agent → app) — `media.offer` / `media.pull` ## 7.3 Outbound (agent → app) — `media.offer` / `media.pull`
**Flow:** **Flow:**
1. Agent produces/references media (e.g. generates an image, or replies with a 1. Agent produces/references media (e.g. generates an image, or replies with a
`MEDIA:` tag / image URL). hermes base `extract_media` / `extract_images` `MEDIA:` tag / image URL). hermes base `extract_media` / `extract_images`
(`base.py:4439/4884`) pull these out and call the adapter's (`base.py:4439/4884`) pull these out and call the adapter's
@@ -54,14 +61,13 @@ binary frames into a temp file (not memory) to bound RAM.
2. Adapter stages the file in the media cache, mints a `media_id`, and emits 2. Adapter stages the file in the media cache, mints a `media_id`, and emits
`media.offer {media_id, kind, mime, size, filename}` (inside/with the `media.offer {media_id, kind, mime, size, filename}` (inside/with the
`message` frame's `media[]`). `message` frame's `media[]`).
3. App sends `media.pull {media_id}`. 3. App `GET /v1/media/{id}` — the full file body.
4. Plugin streams the file as **binary WS frames**; ends with 4. App writes to its cache dir and hands the path to the player/viewer.
`media.pull.end {ok:true}`.
5. App writes to its cache dir and hands the path to the player/viewer.
**Security:** `validate_media_delivery_path` (`base.py:1684`) + the media **Security:** `validate_media_delivery_path` (`base.py:1684`) + the media
delivery root/recency/denied-path checks (`base.py:1312-1480`) ensure the plugin delivery root/recency/denied-path checks (`base.py:1312-1480`) ensure the plugin
only serves files hermes is allowed to deliver (no arbitrary file read). only serves files hermes is allowed to deliver (no arbitrary file read). The
delivery-path check is re-run **at pull time**, not just at offer time.
## 7.4 Live playback (AI-sent music/video) ## 7.4 Live playback (AI-sent music/video)
@@ -79,14 +85,12 @@ only serves files hermes is allowed to deliver (no arbitrary file read).
a desktop backend (see `11-desktop-app.md`): a `libmpv`/`mpv`-backed surface a desktop backend (see `11-desktop-app.md`): a `libmpv`/`mpv`-backed surface
or a WebView fallback for video, and a desktop audio player for music. or a WebView fallback for video, and a desktop audio player for music.
## 7.5 Chunking parameters ## 7.5 Integrity
- Chunk size: **256 KiB** (tunable). - Upload: `sha256` (precomputed by the app, sent in `X-Iris-Media-Sha256`)
- Binary frames carry raw bytes only; framing/metadata is in the JSON header + is verified by the plugin; mismatch → `media.upload.ack {ok:false}`.
end frames. - Pull: the app checks the received size against the offered `size`.
- Reassembly is ordered (WS preserves order); a gap/corruption → abort + - A failed transfer → retry the whole upload (single-shot, no resume).
`error {code:"internal"}` + retry the whole transfer.
- `sha256` in `media.upload.end` / a size check on pull verify integrity.
## 7.6 App-side storage ## 7.6 App-side storage
+103 -18
View File
@@ -6,11 +6,24 @@ by the gateway. When the WS is down (flaky network, NAT timeout, app just
relaunched), the app **sends over `POST` and receives over SSE** instead of relaunched), the app **sends over `POST` and receives over SSE** instead of
waiting 2–20 s for a WS redial. waiting 2–20 s for a WS redial.
Status: **implemented** (gateway leg: `gateway-plugin/http_server.py`; app Status: **implemented — and now the ONLY transport.** The WebSocket leg has
leg: `app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt` + been removed entirely from the codebase (gateway `ws_server.py` deleted;
`GatewayClient.State.HttpFallback`). Complements — does not replace — WS-only frames `hello`/`ping`/`pong`/`media.upload.*`/`media.pull*` dropped
`04-wire-protocol.md` (frames), `08-push.md` (outbox/sync/push), and from `protocol.py` and `Protocol.kt`; `GatewayClient` is HTTP-only with no
`09-pairing-security.md` (auth model). `HttpFallback` state — it *is* the connected state). HTTP is the primary and
sole transport: v1 (JSON frames over POST/SSE/long-poll) and v2 (media over
`POST /v1/media` + `GET /v1/media/{id}`, §19.15). Gateway leg:
`gateway-plugin/http_server.py` (+ `dispatch.py` for frame dispatch);
app leg: `app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt` +
`GatewayClient.kt`. Legacy `ws(s)://` URLs entered by users are still
accepted and rewritten to `http(s)://` (`HttpGateway.deriveHttpUrl`).
Complements — does not replace — `04-wire-protocol.md` (frames),
`08-push.md` (outbox/sync/push), and `09-pairing-security.md` (auth model).
> **Note:** the rest of this document describes the original design, in
> which HTTP was a *fallback* next to a WS primary. That framing is
> historical; where it says "WS (primary)" / "HTTP (fallback)", read
> "HTTP (the only transport)".
## 19.1 Problem ## 19.1 Problem
@@ -78,17 +91,17 @@ acceptable alternative if preferred).
**v1 scope** **v1 scope**
| Over HTTP (v1) | WS-only (v1) | | Over HTTP | WS-only |
| --- | --- | | --- | --- |
| All JSON request frames (`message.send`, `search`, `channel.*`, `commands.catalog`, `agent.stop`/`agent.steer`, …) via one generic endpoint | Binary media upload (chunked binary frames) | | All JSON request frames (`message.send`, `search`, `channel.*`, `commands.catalog`, `agent.stop`/`agent.steer`, …) via one generic endpoint | — |
| All event/response frames via SSE (or long-poll) | Binary media pull stream | | All event/response frames via SSE (or long-poll) | — |
| `sync` catch-up (same outbox, same cursor) | — | | `sync` catch-up (same outbox, same cursor) | — |
| Media upload + pull (`POST /v1/media`, `GET /v1/media/{id}`, v2 — §19.15) | — |
Media stays WS-only in v1: it is the one part of the protocol that is With v2 the HTTP leg is feature-complete: media no longer needs the WS
inherently binary/streaming, and attachments are a rarer action than sending (the composer's attach button is enabled in `HTTP_FALLBACK` too). The WS
text. While in HTTP-fallback mode the composer disables the attach button binary media frames remain accepted for WS clients, but the app routes media
("media needs the live connection"). HTTP media endpoints are a v2 item over HTTP whenever the WS is down.
(§19.13).
## 19.4 Gateway: `gateway-plugin/http_server.py` ## 19.4 Gateway: `gateway-plugin/http_server.py`
@@ -117,6 +130,8 @@ to the WS server.
| `POST /v1/frame` | Bearer token | Accept **any** JSON frame the WS accepts (except binary media). Body = one frame envelope (`04-wire-protocol.md`). Dispatched through the *same* adapter handlers as WS (`on_message_send`, `on_search`, …). | | `POST /v1/frame` | Bearer token | Accept **any** JSON frame the WS accepts (except binary media). Body = one frame envelope (`04-wire-protocol.md`). Dispatched through the *same* adapter handlers as WS (`on_message_send`, `on_search`, …). |
| `GET /v1/events?cursor=N` | Bearer token | **SSE** stream: catch-up from the outbox, then live frames (§19.5). | | `GET /v1/events?cursor=N` | Bearer token | **SSE** stream: catch-up from the outbox, then live frames (§19.5). |
| `GET /v1/poll?cursor=N` | Bearer token | **Long-poll** fallback where SSE is blocked (§19.6). | | `GET /v1/poll?cursor=N` | Bearer token | **Long-poll** fallback where SSE is blocked (§19.6). |
| `POST /v1/media` | Bearer token | **Media upload** (v2, §19.15): whole file as the body, metadata in `X-Iris-Media-*` headers. |
| `GET /v1/media/{media_id}` | Bearer token | **Media pull** (v2, §19.15): streams an outbound offer as the response body. |
### Auth & limits ### Auth & limits
@@ -243,7 +258,9 @@ New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
- **Send path:** `sendMessage()` builds the same `message.send` frame JSON and - **Send path:** `sendMessage()` builds the same `message.send` frame JSON and
writes it to WS or POST depending on state. The `State.Connected` gate in writes it to WS or POST depending on state. The `State.Connected` gate in
`ChatScreen.doSend()` becomes `state is Connected || state is HttpFallback`. `ChatScreen.doSend()` becomes `state is Connected || state is HttpFallback`.
- **Media:** disabled in the composer while in `HTTP_FALLBACK` (v1). - **Media:** works in `HTTP_FALLBACK` too (v2, §19.15) — uploads go via
`POST /v1/media`, pulls via `GET /v1/media/{id}`; the composer's attach
button is enabled in both connected states.
- **UI:** status pill shows "connected" (WS) or "connected · http" (fallback) - **UI:** status pill shows "connected" (WS) or "connected · http" (fallback)
— both green; the fallback is a healthy state, not an error. — both green; the fallback is a healthy state, not an error.
@@ -283,8 +300,13 @@ New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
- long-poll: returns on new frame; empty 200 at timeout with advanced cursor. - long-poll: returns on new frame; empty 200 at timeout with advanced cursor.
- **delivery counting:** frame with only an SSE subscriber → `delivered ≥ 1` - **delivery counting:** frame with only an SSE subscriber → `delivered ≥ 1`
→ **no push fired** (the critical regression test for §19.8). → **no push fired** (the critical regression test for §19.8).
- **media (v2, §19.15):** `POST /v1/media` happy path (201 ack + cached
entry), sha256 mismatch, oversize → 413, missing ref / bad kind → 400,
auth → 401, magic-byte reclassification; `GET /v1/media/{id}` happy path
(bytes + content-type), unknown id → 404, denied path → 404.
- **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with - **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with
assertion flags, per `gateway-plugin/tests/README.md`). assertion flags, per `gateway-plugin/tests/README.md`) + `--http-media FILE`
(v2: upload round-trip via `POST /v1/media`, exit 23 on rejection).
- **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments, - **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments,
`Last-Event-ID` bookkeeping); transport state machine transitions (fake `Last-Event-ID` bookkeeping); transport state machine transitions (fake
clock: WS-loss → immediate fallback; startup race → fallback in < 1 s). clock: WS-loss → immediate fallback; startup race → fallback in < 1 s).
@@ -295,9 +317,9 @@ New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
## 19.13 Non-goals (v1) / future ## 19.13 Non-goals (v1) / future
- **Media over HTTP** (v2): `POST /v1/media` (chunked, same sha256 contract as - ~~**Media over HTTP** (v2)~~ — **done** (§19.15): `POST /v1/media`
`07-media.md`) + `GET /v1/media/{id}` for pull/playback. Unblocks (whole-file body, sha256 contract per `07-media.md`) +
attachments in fallback mode. `GET /v1/media/{id}` for pull/playback. Attachments work in fallback mode.
- **App-side send outbox** (companion work, separate doc): queue sends locally - **App-side send outbox** (companion work, separate doc): queue sends locally
when *both* legs are down; drains over whichever leg recovers. This doc when *both* legs are down; drains over whichever leg recovers. This doc
removes the 2–20 s wait; the outbox removes the last "gateway was down for removes the 2–20 s wait; the outbox removes the last "gateway was down for
@@ -307,6 +329,69 @@ New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
- Per-device tokens (`16-open-questions.md` #3) apply to both legs identically - Per-device tokens (`16-open-questions.md` #3) apply to both legs identically
when implemented. when implemented.
## 19.15 Media over HTTP (v2)
The last WS-only feature, closed out so the HTTP leg is feature-complete.
Same contracts as `07-media.md` — only the transport changes.
### Upload — `POST /v1/media`
One request per file (no chunked/resumable protocol — HTTP handles the
body; single-user scale makes resume unnecessary):
```
POST /v1/media
Authorization: Bearer <token>
X-Iris-Device: <device_id>
X-Iris-Media-Ref: up_123456 # app-chosen ref (mu_*/up_*), ≤ 64 chars
X-Iris-Media-Kind: image|audio|video|document|voice
X-Iris-Media-Filename: photo.jpg
X-Iris-Media-Sha256: <64 hex> # precomputed (headers precede the body)
Content-Type: <media mime> # doubles as the declared MIME
Content-Length: <size>
<raw file bytes>
```
- **Response:** `201` with the `media.upload.ack` frame as the body
(`{ok, media_ref}`); validation failures return the `error` frame as the
4xx body with the same codes as the WS path (`media_too_large` → 413,
`unsupported` → 400, `internal` → 500, `not_found` → 404).
- **Server flow:** the body is streamed to a temp file in 256 KiB reads
(bounded RAM, same `UploadSession` as the WS path), then
`complete_upload` verifies size + sha256, re-sniffs the kind from magic
bytes (the client's declared kind is not trusted), and caches via the
hermes `cache_*_from_bytes` helpers. Runs entirely on the handler thread
— no asyncio bridge (plain file IO).
- **Limits:** `Content-Length` is checked against `max_upload_bytes`
*before* reading the body (early 413); the 64 KiB `/v1/frame` body cap
does not apply. Same per-device rate limit as the other endpoints.
- **Abort:** a client that disconnects mid-body leaves a short read → the
upload session (temp file) is discarded; nothing is cached.
- The ref then travels in `message.send`'s `media_refs` exactly as on the WS
path (single-use, resolved to `MessageEvent.media_urls`).
### Pull — `GET /v1/media/{media_id}`
- `media.offer` is a plain JSON event frame — it arrives on the SSE stream
unchanged; only the byte transfer moves to HTTP.
- **Response:** `200` with the file as the body,
`Content-Type: <mime>`, `Content-Length: <size>`,
`Content-Disposition: attachment; filename="<name>"`. Unknown id or a
path that fails delivery validation → `404` with the `error` frame
(`not_found`) — the delivery-path check is re-run at pull time, exactly
as the WS `media.pull` handler does.
- The app streams the body into its media cache (same
`MediaCache.openWriter` path as the WS pull); playback is unchanged
(`07-media.md` §7.4).
### What stays WS-only
Nothing feature-wise. The WS binary media frames (`media.upload.start/end`,
`media.pull` + binary chunks) remain accepted for WS clients, and
`POST /v1/frame` still rejects those frame types (they have HTTP endpoint
equivalents now, not a WS dependency).
## 19.14 Effort & change list ## 19.14 Effort & change list
| Slice | Files | Est. | | Slice | Files | Est. |
+74 -268
View File
@@ -1,18 +1,20 @@
""" """
Android Platform Adapter for Hermes Agent (Iris x Hermes). Android Platform Adapter for Hermes Agent (Iris x Hermes).
A plugin-based gateway adapter that runs a WebSocket server *inside* the A plugin-based gateway adapter that runs an HTTP server *inside* the
``hermes gateway`` process. The native Android / Desktop app connects to it ``hermes gateway`` process. The native Android / Desktop app connects to it
with a pairing token and talks to the agent over a single WS transport with a pairing token and talks to the agent over a single HTTP transport
(chat, streaming, tools, media, pairing, push-token). (chat, streaming, tools, media, pairing, push-token): JSON frames via
``POST /v1/frame``, events via SSE ``GET /v1/events`` (or long-poll), and
media via ``POST /v1/media`` / ``GET /v1/media/{id}`` (docs/19).
Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core Zero new Python dependencies: ``httpx`` is a hermes core dep. Zero
deps. Zero hermes-core changes. hermes-core changes.
Milestone M1: the gateway core loop (text round-trip). The WS server binds Milestone M1: the gateway core loop (text round-trip). The server binds and
and authenticates devices (``hello`` with constant-time token check), the authenticates devices (constant-time token check), the adapter emits
adapter emits ``message`` frames from ``send()`` and turns inbound ``message`` frames from ``send()`` and turns inbound ``message.send`` frames
``message.send`` frames into ``MessageEvent``s for ``handle_message()``. into ``MessageEvent``s for ``handle_message()``.
Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
``message.start``/``message.update``/``message.stop`` (streaming), tool ``message.start``/``message.update``/``message.stop`` (streaming), tool
@@ -22,13 +24,13 @@ reasoning prefix is split into a ``reasoning`` field. Outbox and search land
in M3; media, push, and desktop land in later milestones (see in M3; media, push, and desktop land in later milestones (see
``docs/14-milestones.md``). ``docs/14-milestones.md``).
Milestone M4: media. Inbound ``media.upload`` (chunked binary frames) is Milestone M4: media. Inbound uploads (``POST /v1/media``) are streamed to a
reassembled in a temp file, verified (size + sha256), re-sniffed, and cached temp file, verified (size + sha256), re-sniffed, and cached via hermes
via hermes ``cache_*_from_bytes``; the resulting refs attach to the next ``cache_*_from_bytes``; the resulting refs attach to the next
``message.send`` as ``MessageEvent.media_urls``. Outbound ``send_*`` calls ``message.send`` as ``MessageEvent.media_urls``. Outbound ``send_*`` calls
register the (delivery-validated) file in the media registry and emit register the (delivery-validated) file in the media registry and emit
``media.offer``; ``media.pull`` streams the file back as chunked binary ``media.offer``; ``GET /v1/media/{id}`` streams the file back, re-checking
frames, re-checking ``validate_media_delivery_path`` at pull time. ``validate_media_delivery_path`` at pull time.
Milestone M5: push + offline. Frames with no live subscriber are parked in Milestone M5: push + offline. Frames with no live subscriber are parked in
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
@@ -127,7 +129,6 @@ from .pairing import ( # noqa: E402
qr_payload, qr_payload,
) )
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402 from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
from .ws_server import WsServer # noqa: E402
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Slash-command catalog (the app's "/" drawer) # Slash-command catalog (the app's "/" drawer)
@@ -459,8 +460,6 @@ DEFAULT_PUSH_BACKEND = "fcm"
DEFAULT_OUTBOX_RETENTION_HOURS = 72 DEFAULT_OUTBOX_RETENTION_HOURS = 72
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
# Max length of a client-supplied media_ref (mu_*/md_* ids are short).
MAX_MEDIA_REF_LEN = 64
# How often (seconds) the outbox-prune "storage reclaimed" notice may repeat. # How often (seconds) the outbox-prune "storage reclaimed" notice may repeat.
_PRUNE_NOTIFY_INTERVAL_S = 3600.0 _PRUNE_NOTIFY_INTERVAL_S = 3600.0
@@ -802,15 +801,12 @@ class _TurnState:
def check_requirements() -> bool: def check_requirements() -> bool:
"""PASSIVE dependency probe: ``websockets`` importable + token set. """PASSIVE dependency probe: token set.
Must be side-effect free (called from ``hermes setup`` / ``status`` / Must be side-effect free (called from ``hermes setup`` / ``status`` /
dashboard readiness). Never installs. dashboard readiness). Never installs. The HTTP transport is stdlib-only,
so there is no extra dependency to probe.
""" """
try:
import websockets # noqa: F401 (core dep)
except Exception:
return False
return bool(_get_scoped_secret("ANDROID_TOKEN")) return bool(_get_scoped_secret("ANDROID_TOKEN"))
@@ -856,9 +852,9 @@ def _env_enablement() -> dict | None:
host = os.getenv("ANDROID_WS_HOST", "").strip() host = os.getenv("ANDROID_WS_HOST", "").strip()
if host: if host:
seed["host"] = host seed["host"] = host
port_raw = os.getenv("ANDROID_WS_PORT", "").strip() http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
if port_raw: if http_port_raw:
seed["port"] = _parse_port(port_raw) seed["http_port"] = _parse_port(http_port_raw)
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
if push: if push:
seed["push_backend"] = push seed["push_backend"] = push
@@ -1032,10 +1028,16 @@ def interactive_setup() -> None:
else: else:
print_info("Existing ANDROID_TOKEN found (not shown).") print_info("Existing ANDROID_TOKEN found (not shown).")
host = prompt("WS bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST) host = prompt("Bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST) save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
port = prompt("WS port", default=str(_parse_port(get_env_value("ANDROID_WS_PORT") or ""))) # _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
save_env_value("ANDROID_WS_PORT", str(_parse_port(port))) # HTTP default must be applied explicitly (docs/19: 8791).
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
port = prompt(
"HTTP port",
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
)
save_env_value("ANDROID_HTTP_PORT", str(_parse_port(port)))
backend = prompt( backend = prompt(
"Push backend (fcm/ntfy)", "Push backend (fcm/ntfy)",
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND, default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
@@ -1063,19 +1065,20 @@ def interactive_setup() -> None:
class AndroidAdapter(BasePlatformAdapter): class AndroidAdapter(BasePlatformAdapter):
"""WebSocket-backed adapter for the native Iris Android / Desktop app. """HTTP-backed adapter for the native Iris Android / Desktop app.
M1: the WS server (``ws_server.WsServer``) authenticates devices with the The HTTP server (``http_server.HttpServer``) authenticates devices with
pairing token, the connection registry tracks live sockets, ``send()`` the pairing token, the device registry tracks live subscribers, ``send()``
emits ``message`` frames, and inbound ``message.send`` frames become emits ``message`` frames, and inbound ``message.send`` frames become
``MessageEvent``s for ``handle_message()``. ``MessageEvent``s for ``handle_message()``.
""" """
# WS has no message-size limit. The stream consumer resolves its per-chat # The HTTP transport has no per-message size limit. The stream consumer
# chunking budget via ``max_message_length_for_chat`` -> this attribute # resolves its per-chat chunking budget via ``max_message_length_for_chat``
# (defaulting to 4096 when unset), which would split long replies — and # -> this attribute (defaulting to 4096 when unset), which would split
# complete HTML artifacts — across multiple fence-reopened messages. A # long replies — and complete HTML artifacts — across multiple
# large cap disables that chunking so a reply arrives as a single message. # fence-reopened messages. A large cap disables that chunking so a reply
# arrives as a single message.
MAX_MESSAGE_LENGTH = 1_000_000 MAX_MESSAGE_LENGTH = 1_000_000
def __init__(self, config, **kwargs): def __init__(self, config, **kwargs):
@@ -1088,12 +1091,10 @@ class AndroidAdapter(BasePlatformAdapter):
extra = getattr(config, "extra", {}) or {} extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml) # Connection settings (env vars override config.yaml). The bind host
# is shared with the (legacy) WS-era env var name for compatibility.
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST) self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
self.port = _parse_port( # docs/19: HTTP transport (the only device-facing transport; optional TLS).
os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT))
)
# docs/19: HTTP fallback leg (same bind host as the WS; optional TLS).
self.http_port = _parse_port( self.http_port = _parse_port(
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT)) os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
) )
@@ -1127,8 +1128,6 @@ class AndroidAdapter(BasePlatformAdapter):
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
# TLS (optional) # TLS (optional)
self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "")
self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "")
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "") self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "") self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
@@ -1141,13 +1140,12 @@ class AndroidAdapter(BasePlatformAdapter):
# Runtime state # Runtime state
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db") self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
self._ws_server = WsServer(self, self._devices) # docs/19: HTTP transport (the only device-facing transport).
# docs/19: HTTP fallback leg (inert until the app uses it; a bind
# failure disables it without affecting the WS).
self._http_server = HttpServer(self, self._devices) self._http_server = HttpServer(self, self._devices)
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a # docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
# POST /v1/frame is being dispatched, the handler's point-to-point # POST /v1/frame is being dispatched, the handler's point-to-point
# replies are captured here and returned as the HTTP response. # replies are captured here and returned as the HTTP response.
# Entry shape: (sink queue, abandoned event).
self._http_reply_sinks: dict[str, tuple[queue.Queue, threading.Event]] = {} self._http_reply_sinks: dict[str, tuple[queue.Queue, threading.Event]] = {}
self._connected = False self._connected = False
# M2: per-chat turn state for outbound frame classification. # M2: per-chat turn state for outbound frame classification.
@@ -1191,7 +1189,7 @@ class AndroidAdapter(BasePlatformAdapter):
# ── Connection lifecycle ────────────────────────────────────────────── # ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool: async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Bring the platform up: bind the WS server on host:port.""" """Bring the platform up: bind the HTTP server on host:http_port."""
if not self.token: if not self.token:
logger.error("android: ANDROID_TOKEN must be set") logger.error("android: ANDROID_TOKEN must be set")
self._set_fatal_error( self._set_fatal_error(
@@ -1201,41 +1199,24 @@ class AndroidAdapter(BasePlatformAdapter):
) )
return False return False
# Prevent two profiles from binding the same port/identity. # The HTTP server is the only device-facing transport, so a bind
try: # failure is fatal (the app has no other way to reach the gateway).
from gateway.status import acquire_scoped_lock # start() never raises; it disables the leg and logs on failure.
await self._http_server.start()
lock_key = f"{self.host}:{self.port}" if not self._http_server.enabled:
if not acquire_scoped_lock("android", lock_key): logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
logger.error(
"android: %s:%s already in use by another profile", self.host, self.port
)
self._set_fatal_error( self._set_fatal_error(
"lock_conflict", "bind_failed",
"WS port in use by another profile", f"HTTP port {self.http_port} unavailable",
retryable=False, retryable=False,
) )
return False return False
self._lock_key = lock_key
except ImportError:
self._lock_key = None # status module not available (e.g. tests)
try:
await self._ws_server.start()
except Exception:
self._connected = False
return False
# docs/19: start the HTTP fallback leg next to the WS. Bind failure
# is NON-fatal (unlike the WS): the plugin keeps working WS-only.
await self._http_server.start()
# M5: announce gateway health to connected clients (none yet at # M5: announce gateway health to connected clients (none yet at
# startup; the frame + plumbing exist for future transitions). # startup; the frame + plumbing exist for future transitions).
# Reset in case this adapter instance previously went down (the # Reset in case this adapter instance previously went down (the
# gateway may reconnect the same adapter after a fatal error). # gateway may reconnect the same adapter after a fatal error).
self._gateway_status = protocol.STATUS_ONLINE self._gateway_status = protocol.STATUS_ONLINE
await self._ws_server.broadcast(protocol.status(self._gateway_status))
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None) await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
# M3: ensure the default (home) channel exists in the directory so the # M3: ensure the default (home) channel exists in the directory so the
@@ -1257,29 +1238,18 @@ class AndroidAdapter(BasePlatformAdapter):
self._connected = True self._connected = True
self._mark_connected() self._mark_connected()
logger.info("android: connected; WS server on %s:%s", self.host, self.port) logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port)
return True return True
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Tear down the platform: stop the server, close device sockets.""" """Tear down the platform: stop the server, close device streams."""
# Tell live clients the gateway is going away (restart/shutdown) so # Tell live clients the gateway is going away (restart/shutdown) so
# the app can distinguish a clean gateway teardown from a plain # the app can distinguish a clean gateway teardown from a plain
# network drop: the "Gateway restarting" chat notice is shown only # network drop: the "Gateway restarting" chat notice is shown only
# when this frame was received (docs/04 §status). # when this frame was received (docs/04 §status).
self._gateway_status = protocol.STATUS_RESTARTING self._gateway_status = protocol.STATUS_RESTARTING
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await self._ws_server.broadcast(protocol.status(self._gateway_status))
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None) await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
with contextlib.suppress(ImportError):
from gateway.status import release_scoped_lock
lock_key = getattr(self, "_lock_key", None)
if lock_key:
release_scoped_lock("android", lock_key)
try:
await self._ws_server.stop()
except Exception:
logger.warning("android: WS server stop failed", exc_info=True)
try: try:
await self._http_server.stop() await self._http_server.stop()
except Exception: except Exception:
@@ -1689,31 +1659,24 @@ class AndroidAdapter(BasePlatformAdapter):
return self._http_reply_sinks.pop(device_id, None) return self._http_reply_sinks.pop(device_id, None)
async def _broadcast_both(self, frame: "protocol.Frame") -> None: async def _broadcast_both(self, frame: "protocol.Frame") -> None:
"""Bare (non-outbox) broadcast to both transports (docs/19): the """Bare (non-outbox) broadcast to live subscribers (docs/19): the
frame reaches WS devices and live SSE/long-poll subscribers.""" frame reaches every live SSE/long-poll subscriber."""
await self._ws_server.broadcast(frame)
await self._http_server.fanout(frame, cursor=None) await self._http_server.fanout(frame, cursor=None)
async def _reply(self, device_id: str, frame: "protocol.Frame") -> None: async def _reply(self, device_id: str, frame: "protocol.Frame") -> None:
"""Point-to-point reply with HTTP-leg fallback (docs/19 §19.7). """Point-to-point reply with broadcast fallback (docs/19 §19.7).
WS-originated requests keep point-to-point delivery. For an For an in-flight HTTP request (a reply sink is registered) the frame
in-flight HTTP request (a reply sink is registered) the frame goes goes into the HTTP response. Otherwise it is broadcast so the
into the HTTP response. If the device has no live WS and no sink device's SSE stream delivers it (single-user model).
(e.g. it dropped mid-request), the frame is broadcast so the SSE
stream delivers it (single-user model).
""" """
entry = self._http_reply_sinks.get(device_id) entry = self._http_reply_sinks.get(device_id)
if entry is not None: if entry is not None:
entry[0].put(frame) entry[0].put(frame)
return return
if await self._ws_server.send_to(device_id, frame): await self._broadcast_both(frame)
return
await self._ws_server.broadcast(frame)
await self._http_server.fanout(frame, cursor=None)
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None: async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
delivered = await self._ws_server.broadcast(frame)
# M3/M5: always append to the outbox so a reconnecting app can catch # M3/M5: always append to the outbox so a reconnecting app can catch
# up on *all* recent frames, not just the ones that were parked. This # up on *all* recent frames, not just the ones that were parked. This
# covers the case where the app's in-memory ChatStore is reset (e.g. # covers the case where the app's in-memory ChatStore is reset (e.g.
@@ -1729,7 +1692,7 @@ class AndroidAdapter(BasePlatformAdapter):
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber # docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
# — count it in the delivery total or every message would push AND # — count it in the delivery total or every message would push AND
# stream to a device that is already receiving it. # stream to a device that is already receiving it.
delivered += await self._http_server.fanout(frame, cursor) delivered = await self._http_server.fanout(frame, cursor)
if delivered == 0: if delivered == 0:
logger.info( logger.info(
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)", "android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
@@ -1827,11 +1790,6 @@ class AndroidAdapter(BasePlatformAdapter):
device_id = device.get("device_id") device_id = device.get("device_id")
if not device_id: if not device_id:
continue continue
# Prefer the live connection's token (fcm.register refreshes it
# in memory) over the possibly-stale registry row.
conn = self._ws_server.connection(device_id)
token = getattr(conn, backend.token_field, None) if conn is not None else None
if not token:
token = device.get(backend.token_field) token = device.get(backend.token_field)
if not token: if not token:
continue continue
@@ -1894,13 +1852,11 @@ class AndroidAdapter(BasePlatformAdapter):
if isinstance(tid, str) and tid: if isinstance(tid, str) and tid:
thread_id = tid thread_id = tid
frame = protocol.typing(chat_id, True, thread_id=thread_id) frame = protocol.typing(chat_id, True, thread_id=thread_id)
await self._ws_server.broadcast(frame)
await self._http_server.fanout(frame, cursor=None) await self._http_server.fanout(frame, cursor=None)
async def stop_typing(self, chat_id: str) -> None: async def stop_typing(self, chat_id: str) -> None:
"""Clear the typing indicator (``typing`` frame, on=false).""" """Clear the typing indicator (``typing`` frame, on=false)."""
frame = protocol.typing(chat_id, False) frame = protocol.typing(chat_id, False)
await self._ws_server.broadcast(frame)
await self._http_server.fanout(frame, cursor=None) await self._http_server.fanout(frame, cursor=None)
# ── M4: outbound media (agent -> app) ───────────────────────────────── # ── M4: outbound media (agent -> app) ─────────────────────────────────
@@ -2243,150 +2199,6 @@ class AndroidAdapter(BasePlatformAdapter):
threading.Thread(target=_work, daemon=True, name="android-thread-title").start() threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
# ── M4: inbound media (app -> agent) ──────────────────────────────────
#
# ``media.upload.start`` -> raw binary frames (one at a time per
# connection) -> ``media.upload.end``. The session streams to a temp
# file (bounded RAM); on end we verify size + sha256, re-sniff the kind,
# and cache via hermes ``cache_*_from_bytes``. ``media.pull`` serves an
# outbound offer as chunked binary frames, re-checking the delivery-path
# validation at pull time.
async def on_media_upload_start(self, frame: protocol.Frame, device_id: str) -> None:
payload = frame.payload
media_ref = str(payload.get("media_ref") or "").strip()
if not media_ref or len(media_ref) > MAX_MEDIA_REF_LEN:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "media.upload.start requires media_ref", id=frame.id
),
)
return
kind = payload.get("kind")
if kind not in media_bridge.KINDS:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}", id=frame.id
),
)
return
mime = str(payload.get("mime") or "application/octet-stream")[:128]
filename = str(payload.get("filename") or "upload")[:255]
size = payload.get("size")
try:
size = int(size) if size is not None else -1
except (TypeError, ValueError):
size = -1
if size <= 0:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED,
"media.upload.start requires a positive size",
id=frame.id,
),
)
return
if size > self.max_upload_bytes:
await self._reply(
device_id,
protocol.error(
protocol.ERR_MEDIA_TOO_LARGE,
f"upload of {size} bytes exceeds limit ({self.max_upload_bytes})",
id=frame.id,
),
)
return
try:
self._media.create_upload(
device_id,
media_ref,
kind,
mime,
filename,
size,
frame.id,
self.max_upload_bytes,
)
except media_bridge.MediaError as e:
await self._reply(device_id, protocol.error(e.code, e.message, id=frame.id))
return
# No ack: WS ordering guarantees the server processes this before the
# first binary chunk; failures arrive as ``error`` frames.
async def on_media_chunk(self, device_id: str, chunk: bytes) -> None:
session = self._media.get_upload(device_id)
if session is None:
return # stray binary frame: ignore (forward-compat)
session.feed(chunk)
if session.failed:
await self._reply(
device_id,
protocol.error(session.error_code, session.error_message, id=session.request_id),
)
self._media.discard_upload(device_id, session.media_ref)
async def on_media_upload_end(self, frame: protocol.Frame, device_id: str) -> None:
payload = frame.payload
media_ref = str(payload.get("media_ref") or "").strip()
sha256 = str(payload.get("sha256") or "").strip().lower()
if not media_ref:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "media.upload.end requires media_ref", id=frame.id
),
)
return
try:
entry = self._media.complete_upload(device_id, media_ref, sha256)
except media_bridge.MediaError as e:
await self._reply(device_id, protocol.error(e.code, e.message, id=frame.id))
return
await self._reply(
device_id, protocol.media_upload_ack(True, entry.media_id, id=frame.id)
)
async def on_media_pull(self, frame: protocol.Frame, device_id: str) -> None:
payload = frame.payload
media_id = str(payload.get("media_id") or "").strip()
entry = self._media.get_outbound(media_id) if media_id else None
if entry is None:
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}", id=frame.id
),
)
return
# Delivery-path security: re-validate at pull time (the file may have
# moved / been replaced since the offer).
safe = validate_media_delivery_path(entry.path)
if safe is None:
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable", id=frame.id),
)
return
conn = self._ws_server.connection(device_id)
if conn is None:
return
try:
await media_bridge.stream_file(conn.ws, safe, media_bridge.DEFAULT_CHUNK_BYTES)
except Exception as e:
logger.warning("android: media.pull stream failed for %s: %s", media_id, e)
await self._reply(
device_id, protocol.error(protocol.ERR_INTERNAL, f"pull failed: {e}", id=frame.id)
)
return
await self._reply(device_id, protocol.media_pull_end(True, id=frame.id))
def on_connection_closed(self, device_id: str) -> None:
"""M4: drop in-flight upload temp files for a disconnected device."""
self._media.discard_device(device_id)
# ── M3: channel directory management (app -> agent) ─────────────────── # ── M3: channel directory management (app -> agent) ───────────────────
# #
# Each request is answered by broadcasting the matching ``channel.*`` # Each request is answered by broadcasting the matching ``channel.*``
@@ -2421,7 +2233,7 @@ class AndroidAdapter(BasePlatformAdapter):
return return
resp = protocol.channel_created(entry) resp = protocol.channel_created(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline). # M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log( await self._broadcast_or_log(
entry["chat_id"], entry["chat_id"],
@@ -2467,7 +2279,7 @@ class AndroidAdapter(BasePlatformAdapter):
return return
resp = protocol.channel_renamed(entry) resp = protocol.channel_renamed(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline). # M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log( await self._broadcast_or_log(
chat_id, chat_id,
@@ -2500,7 +2312,7 @@ class AndroidAdapter(BasePlatformAdapter):
# new is_default flag) so every device reconciles the default change. # new is_default flag) so every device reconciles the default change.
resp = protocol.channel_renamed(entry) resp = protocol.channel_renamed(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._http_server.fanout(resp)
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2524,7 +2336,7 @@ class AndroidAdapter(BasePlatformAdapter):
# new favorite flag) so every device reconciles the change. # new favorite flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry) resp = protocol.channel_renamed(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._http_server.fanout(resp)
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2557,7 +2369,7 @@ class AndroidAdapter(BasePlatformAdapter):
return return
resp = protocol.channel_renamed(entry) resp = protocol.channel_renamed(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._http_server.fanout(resp)
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2585,7 +2397,7 @@ class AndroidAdapter(BasePlatformAdapter):
# new automation flag) so every device reconciles the change. # new automation flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry) resp = protocol.channel_renamed(entry)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._http_server.fanout(resp)
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2632,7 +2444,7 @@ class AndroidAdapter(BasePlatformAdapter):
) )
resp = protocol.channel_deleted(chat_id) resp = protocol.channel_deleted(chat_id)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline). # M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log( await self._broadcast_or_log(
chat_id, chat_id,
@@ -2844,8 +2656,8 @@ class AndroidAdapter(BasePlatformAdapter):
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None: async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
"""Update the device's push tokens (FCM rotation / ntfy topic). """Update the device's push tokens (FCM rotation / ntfy topic).
Persists to the device registry AND refreshes the live connection so Persists to the device registry so the next push targets the current
the next push targets the current token without a stale read. token without a stale read.
""" """
fcm_token = frame.payload.get("fcm_token") fcm_token = frame.payload.get("fcm_token")
ntfy_topic = frame.payload.get("ntfy_topic") ntfy_topic = frame.payload.get("ntfy_topic")
@@ -2858,12 +2670,6 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception: except Exception:
logger.warning("android: fcm.register update failed", exc_info=True) logger.warning("android: fcm.register update failed", exc_info=True)
return return
conn = self._ws_server.connection(device_id)
if conn is not None:
if fcm_token is not None:
conn.fcm_token = fcm_token
if ntfy_topic is not None:
conn.ntfy_topic = ntfy_topic
logger.info("android: push tokens updated for %s", device_id) logger.info("android: push tokens updated for %s", device_id)
# ── M5: approval / clarify banners ──────────────────────────────────── # ── M5: approval / clarify banners ────────────────────────────────────
@@ -3107,7 +2913,7 @@ def register(ctx):
validate_config=validate_config, validate_config=validate_config,
is_connected=is_connected, is_connected=is_connected,
required_env=["ANDROID_TOKEN"], required_env=["ANDROID_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)", install_hint="No extra packages needed (httpx is a core dep)",
setup_fn=interactive_setup, setup_fn=interactive_setup,
# Env-driven auto-configuration: seeds PlatformConfig.extra with # Env-driven auto-configuration: seeds PlatformConfig.extra with
# host/port/push_backend + home_channel so env-only setups show up in # host/port/push_backend + home_channel so env-only setups show up in
+84
View File
@@ -0,0 +1,84 @@
"""Shared inbound frame dispatch + inbound rate limit.
Extracted from the (now-removed) WS server so the HTTP transport has a
single home for the transport-agnostic dispatch chain and the per-device
token bucket. The HTTP leg (``http_server.py``) is the only transport;
this module is transport-neutral.
"""
from __future__ import annotations
import time
from typing import Any
from . import protocol
# Inbound JSON control-frame rate limit (per device, token bucket).
# A legitimate app sends occasional user-initiated requests — far below
# 20/s sustained. Media uploads are exempt (they travel via
# ``POST /v1/media``, not the frame endpoint).
INBOUND_RATE_PER_S = 20.0
INBOUND_BURST = 40
# Max length of a client-supplied device_id.
MAX_DEVICE_ID_LEN = 128
async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None:
"""Shared inbound frame dispatch (docs/19 §19.4). Unknown types are
ignored (forward-compat)."""
if frame.type == protocol.TYPE_MESSAGE_SEND:
await adapter.on_message_send(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_CREATE:
await adapter.on_channel_create(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_RENAME:
await adapter.on_channel_rename(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
await adapter.on_channel_set_default(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
await adapter.on_channel_favorite(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_ICON:
await adapter.on_channel_icon(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
await adapter.on_channel_set_automation(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST:
await adapter.on_channel_list(frame, device_id)
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
await adapter.on_commands_catalog(frame, device_id)
elif frame.type == protocol.TYPE_SEARCH:
await adapter.on_search(frame, device_id)
elif frame.type == protocol.TYPE_SYNC:
await adapter.on_sync(frame, device_id)
elif frame.type == protocol.TYPE_HISTORY:
await adapter.on_history(frame, device_id)
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
await adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_FCM_REGISTER:
await adapter.on_fcm_register(frame, device_id)
# Unknown types are ignored (forward-compat).
class _TokenBucket:
"""Minimal token bucket (stdlib only). One instance per device."""
__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
+262 -85
View File
@@ -1,11 +1,10 @@
"""HTTP fallback transport (docs/19): the "HTTP leg". """HTTP transport (docs/19): the gateway's device-facing server.
A second, short-lived-connection transport next to the WebSocket: the same Short-lived-connection transport: the same JSON frames, the same
JSON frames, the same outbox/cursor, the same token — served over plain HTTP outbox/cursor, the same token — served over plain HTTP by the gateway.
by the gateway. When the WS is down (flaky network, NAT timeout, app just The app sends over ``POST /v1/frame`` and receives over
relaunched), the app sends over ``POST /v1/frame`` and receives over ``GET /v1/events`` (SSE) or ``GET /v1/poll`` (long-poll); media travels
``GET /v1/events`` (SSE) or ``GET /v1/poll`` (long-poll) instead of waiting via ``POST /v1/media`` / ``GET /v1/media/{id}`` (v2, docs/19 §19.15).
for a WS redial.
Zero new Python dependencies: stdlib ``http.server`` (a Zero new Python dependencies: stdlib ``http.server`` (a
``ThreadingHTTPServer`` in a daemon thread) bridged into the gateway's ``ThreadingHTTPServer`` in a daemon thread) bridged into the gateway's
@@ -13,19 +12,30 @@ asyncio loop with ``asyncio.run_coroutine_threadsafe``.
Endpoints (docs/19 §19.4): Endpoints (docs/19 §19.4):
* ``GET /v1/health`` — unauthenticated liveness probe. * ``GET /v1/health`` — unauthenticated liveness probe.
* ``POST /v1/frame`` — accept-and-ack for any JSON frame the WS * ``POST /v1/frame`` — accept-and-ack for any JSON frame the
accepts (except binary media, which stays app sends (media uses the /v1/media
WS-only in v1). endpoints; hello/ping are
transport-specific).
* ``GET /v1/events?cursor=N`` — SSE stream: outbox catch-up, then live * ``GET /v1/events?cursor=N`` — SSE stream: outbox catch-up, then live
frames (``id`` = outbox cursor, so resume frames (``id`` = outbox cursor, so resume
is just ``Last-Event-ID``). is just ``Last-Event-ID``).
* ``GET /v1/poll?cursor=N`` — long-poll fallback where SSE is blocked. * ``GET /v1/poll?cursor=N`` — long-poll fallback where SSE is blocked.
* ``POST /v1/media`` — media upload (docs/19 §19.15, v2): the
whole file as the request body; metadata
in ``X-Iris-Media-*`` headers; sha256
contract per docs/07 §7.2.
* ``GET /v1/media/{media_id}`` — media pull (docs/19 §19.15, v2): streams
an outbound offer (``media.offer`` id)
as the response body.
Auth: ``Authorization: Bearer <token>`` (constant-time ``verify_token``) + Auth: ``Authorization: Bearer <token>`` (constant-time ``verify_token``) +
``X-Iris-Device`` header (same device id / allowlist as the WS ``hello``). ``X-Iris-Device`` header (device id / allowlist). Device registration
(name + push tokens) rides on the SSE open via ``X-Iris-Device-Name`` /
``X-Iris-Fcm-Token`` / ``X-Iris-Ntfy-Topic`` headers (the HTTP equivalent
of the old WS ``hello`` upsert).
Bind failure is NON-fatal (unlike the WS): the plugin keeps working HTTP is the ONLY transport: a bind failure is FATAL (the app has no other
WS-only. way to reach the gateway).
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,24 +53,25 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from . import protocol from . import dispatch, protocol
from . import media as media_bridge
from .pairing import verify_token from .pairing import verify_token
from .ws_server import (
INBOUND_BURST, try: # main-repo import (same as adapter.py); absent in bare unit contexts
INBOUND_RATE_PER_S, from gateway.platforms.base import validate_media_delivery_path
MAX_DEVICE_ID_LEN, except ImportError: # pragma: no cover
_TokenBucket, validate_media_delivery_path = None # type: ignore[assignment]
dispatch_frame,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Default port for the HTTP leg (WS default is 8790). # Default port for the HTTP transport (the WS-era default was 8790).
DEFAULT_HTTP_PORT = 8791 DEFAULT_HTTP_PORT = 8791
# Request body cap for POST /v1/frame (frames are small; media never # Request body cap for POST /v1/frame. Frames are usually small, but a
# travels here in v1). # ``channel.icon`` carries a base64 blob up to 512 KiB (docs/10), so the cap
MAX_BODY_BYTES = 64 * 1024 # must clear that with headroom. Media never travels here (it uses
# POST /v1/media).
MAX_BODY_BYTES = 1024 * 1024
# Per-subscriber live-frame queue. A subscriber that can't keep up is # Per-subscriber live-frame queue. A subscriber that can't keep up is
# dropped; it reconnects with Last-Event-ID and catches up from the outbox. # dropped; it reconnects with Last-Event-ID and catches up from the outbox.
@@ -79,18 +90,16 @@ ACCEPT_ACK_TIMEOUT_S = 5.0
# Sentinel pushed into subscriber queues on shutdown. # Sentinel pushed into subscriber queues on shutdown.
_STOP = object() _STOP = object()
# Frame types POST /v1/frame must not accept (docs/19 §19.3): media is # Max length of a client-supplied media_ref (same as the WS path).
# inherently binary/streaming (WS-only in v1); hello/ping are MAX_MEDIA_REF_LEN = 64
# transport-specific (auth is via headers, liveness via /v1/health).
HTTP_REJECTED_TYPES = frozenset( # MediaError code -> HTTP status for the /v1/media endpoints.
{ _MEDIA_STATUS = {
protocol.TYPE_HELLO, protocol.ERR_MEDIA_TOO_LARGE: 413,
protocol.TYPE_PING, protocol.ERR_NOT_FOUND: 404,
protocol.TYPE_MEDIA_UPLOAD_START, protocol.ERR_UNSUPPORTED: 400,
protocol.TYPE_MEDIA_UPLOAD_END, protocol.ERR_INTERNAL: 500,
protocol.TYPE_MEDIA_PULL, }
}
)
def _with_cursor(frame: dict[str, Any], cursor: int) -> str: def _with_cursor(frame: dict[str, Any], cursor: int) -> str:
@@ -151,12 +160,12 @@ class _Subscriber:
class HttpServer: class HttpServer:
"""The plugin's HTTP fallback server + live subscriber registry. """The plugin's HTTP server + live subscriber registry.
The handler threads never touch adapter state directly: inbound frames The handler threads never touch adapter state directly: inbound frames
are bridged into the gateway's asyncio loop (captured at ``start()``) are bridged into the gateway's asyncio loop (captured at ``start()``)
with ``asyncio.run_coroutine_threadsafe`` and dispatched through the with ``asyncio.run_coroutine_threadsafe`` and dispatched through
same ``dispatch_frame`` the WS server uses. ``dispatch_frame`` (``dispatch.py``).
""" """
def __init__(self, adapter: Any, devices: Any): def __init__(self, adapter: Any, devices: Any):
@@ -167,7 +176,7 @@ class HttpServer:
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._subs: dict[str, list[_Subscriber]] = {} self._subs: dict[str, list[_Subscriber]] = {}
self._subs_lock = threading.Lock() self._subs_lock = threading.Lock()
self._buckets: dict[str, _TokenBucket] = {} self._buckets: dict[str, dispatch._TokenBucket] = {}
self._buckets_lock = threading.Lock() self._buckets_lock = threading.Lock()
self._lock_key: str | None = None self._lock_key: str | None = None
self.enabled = False self.enabled = False
@@ -176,8 +185,9 @@ class HttpServer:
# ── Lifecycle ───────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────
async def start(self) -> None: async def start(self) -> None:
"""Bind and start serving. NEVER raises: a bind failure disables the """Bind and start serving. NEVER raises: a bind failure leaves
HTTP leg (the plugin keeps working WS-only, docs/19 §19.4).""" ``enabled`` False, which the adapter treats as a fatal error
(HTTP is the only transport, docs/19 §19.4)."""
if self.enabled: if self.enabled:
return return
self._loop = asyncio.get_running_loop() self._loop = asyncio.get_running_loop()
@@ -191,7 +201,7 @@ class HttpServer:
lock_key = f"http:{host}:{port}" lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("android", lock_key): if not acquire_scoped_lock("android", lock_key):
logger.warning( logger.warning(
"android: HTTP port %s:%s in use by another profile; HTTP leg disabled", "android: HTTP port %s:%s in use by another profile; server disabled",
host, host,
port, port,
) )
@@ -208,9 +218,7 @@ class HttpServer:
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key) ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
except Exception as e: except Exception as e:
logger.warning( logger.warning("android: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
"android: HTTP fallback leg disabled (bind %s:%s failed: %s)", host, port, e
)
self._release_lock() self._release_lock()
return return
@@ -221,9 +229,7 @@ class HttpServer:
self._thread.start() self._thread.start()
self.enabled = True self.enabled = True
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http" scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
logger.info( logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
"android: HTTP fallback leg listening on %s://%s:%s", scheme, host, self.bound_port
)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop serving and unblock all subscribers.""" """Stop serving and unblock all subscribers."""
@@ -317,7 +323,7 @@ class HttpServer:
_send_json(handler, 401, {"error": "unauthorized"}) _send_json(handler, 401, {"error": "unauthorized"})
return None return None
device_id = (handler.headers.get("X-Iris-Device") or "").strip() device_id = (handler.headers.get("X-Iris-Device") or "").strip()
if not device_id or len(device_id) > MAX_DEVICE_ID_LEN: if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
_send_json(handler, 401, {"error": "X-Iris-Device header required"}) _send_json(handler, 401, {"error": "X-Iris-Device header required"})
return None return None
if ( if (
@@ -333,11 +339,13 @@ class HttpServer:
return device_id return device_id
def _rate_limited(self, device_id: str) -> bool: def _rate_limited(self, device_id: str) -> bool:
"""Per-device token bucket, same parameters as the WS inbound limit.""" """Per-device token bucket, same parameters as the frame limit."""
with self._buckets_lock: with self._buckets_lock:
b = self._buckets.get(device_id) b = self._buckets.get(device_id)
if b is None: if b is None:
b = self._buckets[device_id] = _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST) b = self._buckets[device_id] = dispatch._TokenBucket(
dispatch.INBOUND_RATE_PER_S, dispatch.INBOUND_BURST
)
return not b.consume() return not b.consume()
# ── POST /v1/frame ──────────────────────────────────────────────────── # ── POST /v1/frame ────────────────────────────────────────────────────
@@ -379,31 +387,35 @@ class HttpServer:
frames: list[protocol.Frame] = [] frames: list[protocol.Frame] = []
deadline = time.monotonic() + ACCEPT_ACK_TIMEOUT_S deadline = time.monotonic() + ACCEPT_ACK_TIMEOUT_S
while True: while True:
try: # If the handler is done, drain any replies and stop (no wait).
frames.append(sink.get(timeout=0.05)) # This keeps fast/ignored frames from incurring the sink timeout.
break
except queue.Empty:
if task.done(): if task.done():
# All replies are in the sink now (the handler finished);
# drain them all.
while True: while True:
try: try:
frames.append(sink.get_nowait()) frames.append(sink.get_nowait())
except queue.Empty: except queue.Empty:
break break
break break
try:
frames.append(sink.get(timeout=0.01))
except queue.Empty:
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
# Long-running handler (the agent turn): ack now; late # Long-running handler (the agent turn): ack now; late
# replies go to the event stream (the dispatch's finally # replies go to the event stream (the dispatch's finally
# sees ``abandoned`` and delivers them there). # sees ``abandoned`` and delivers them there).
abandoned.set() abandoned.set()
break break
continue
# Got a frame; loop back to check task.done() (drain the rest if
# the handler finished, e.g. a sync replay).
if not frames: if not frames:
_send_json(handler, 202, {"ok": True}) _send_json(handler, 202, {"ok": True})
elif len(frames) == 1: elif len(frames) == 1:
f = frames[0] f = frames[0]
status = 429 if f.payload.get("code") == protocol.ERR_RATE_LIMITED else ( status = (
400 if f.type == protocol.TYPE_ERROR else 200 429
if f.payload.get("code") == protocol.ERR_RATE_LIMITED
else (400 if f.type == protocol.TYPE_ERROR else 200)
) )
_send_frame_json(handler, status, f.to_json()) _send_frame_json(handler, status, f.to_json())
else: else:
@@ -411,9 +423,7 @@ class HttpServer:
# event stream; the ack stays plain. # event stream; the ack stays plain.
for f in frames: for f in frames:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(self._deliver_via_stream(f), loop)
self._deliver_via_stream(f), loop
)
_send_json(handler, 202, {"ok": True}) _send_json(handler, 202, {"ok": True})
async def _dispatch_guarded( async def _dispatch_guarded(
@@ -424,11 +434,9 @@ class HttpServer:
abandoned: threading.Event, abandoned: threading.Event,
) -> None: ) -> None:
try: try:
await dispatch_frame(self._adapter, frame, device_id) await dispatch.dispatch_frame(self._adapter, frame, device_id)
except Exception: except Exception:
logger.warning( logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True)
"android: HTTP dispatch failed for %s", frame.type, exc_info=True
)
finally: finally:
# Pop our sink entry (a newer request from the same device may # Pop our sink entry (a newer request from the same device may
# have replaced it). If the HTTP response was already sent # have replaced it). If the HTTP response was already sent
@@ -447,16 +455,30 @@ class HttpServer:
await self._deliver_via_stream(f) await self._deliver_via_stream(f)
async def _deliver_via_stream(self, frame: protocol.Frame) -> None: async def _deliver_via_stream(self, frame: protocol.Frame) -> None:
await self._adapter._ws_server.broadcast(frame)
await self.fanout(frame, cursor=None) await self.fanout(frame, cursor=None)
# ── GET /v1/events (SSE) ────────────────────────────────────────────── # ── GET /v1/events (SSE) ──────────────────────────────────────────────
def _handle_sse( def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any
) -> None:
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID")) cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID"))
# Device registration (the HTTP equivalent of the WS hello upsert):
# the SSE open carries the device name + push tokens as optional
# headers; upsert is idempotent and COALESCEs absent tokens, so a
# re-open never clobbers a newer fcm.register value.
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") or None
try:
self._devices.upsert(
device_id,
device_name or device_id,
None,
fcm_token,
ntfy_topic,
)
except Exception:
logger.warning("android: device registry upsert failed", exc_info=True)
sub = _Subscriber(device_id=device_id, kind="sse") sub = _Subscriber(device_id=device_id, kind="sse")
# Register BEFORE the replay so a frame appended in between is # Register BEFORE the replay so a frame appended in between is
# fanned out to us (and de-duped by cursor below) instead of lost. # fanned out to us (and de-duped by cursor below) instead of lost.
@@ -499,7 +521,9 @@ class HttpServer:
continue # already replayed above continue # already replayed above
self._write_sse(handler, "frame", c, data) self._write_sse(handler, "frame", c, data)
except (BrokenPipeError, ConnectionResetError, OSError): except (BrokenPipeError, ConnectionResetError, OSError):
pass # client went away: normal # Client went away mid-stream: normal (the app reconnects with
# Last-Event-ID and catches up from the outbox).
pass
finally: finally:
self._remove_sub(sub) self._remove_sub(sub)
@@ -519,11 +543,137 @@ class HttpServer:
handler.wfile.write(text.encode("utf-8")) handler.wfile.write(text.encode("utf-8"))
handler.wfile.flush() handler.wfile.flush()
# ── POST /v1/media (upload, docs/19 §19.15) ───────────────────────────
def _handle_media_upload(self, handler: BaseHTTPRequestHandler, device_id: str) -> None:
"""Whole-file upload: metadata in headers, file bytes as the body.
Mirrors the WS ``media.upload`` contract (docs/07 §7.2) in one
request: the body is streamed to a temp file (bounded RAM), then
size + sha256 are verified and the file cached via the hermes
``cache_*_from_bytes`` helpers. Runs entirely on the handler thread
(plain file IO — no asyncio bridge needed)."""
media_ref = (handler.headers.get("X-Iris-Media-Ref") or "").strip()
kind = (handler.headers.get("X-Iris-Media-Kind") or "").strip()
filename = (handler.headers.get("X-Iris-Media-Filename") or "upload")[:255]
sha256 = (handler.headers.get("X-Iris-Media-Sha256") or "").strip().lower()
mime = handler.headers.get("Content-Type") or "application/octet-stream"
mime = mime.split(";")[0].strip()[:128]
try:
length = int(handler.headers.get("Content-Length") or 0)
except ValueError:
length = 0
def reject(code: str, message: str) -> None:
_send_frame_json(
handler,
_MEDIA_STATUS.get(code, 400),
protocol.error(code, message).to_json(),
)
# Same validation rules as the WS media.upload.start handler.
if not media_ref or len(media_ref) > MAX_MEDIA_REF_LEN:
reject(protocol.ERR_UNSUPPORTED, "X-Iris-Media-Ref header required")
return
if kind not in media_bridge.KINDS:
reject(protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}")
return
if length <= 0:
reject(protocol.ERR_UNSUPPORTED, "empty body")
return
if length > self._adapter.max_upload_bytes:
reject(
protocol.ERR_MEDIA_TOO_LARGE,
f"upload of {length} bytes exceeds limit ({self._adapter.max_upload_bytes})",
)
return
try:
sess = self._adapter._media.create_upload(
device_id,
media_ref,
kind,
mime,
filename,
length,
None,
self._adapter.max_upload_bytes,
)
except media_bridge.MediaError as e:
reject(e.code, e.message)
return
try:
remaining = length
while remaining > 0:
chunk = handler.rfile.read(min(media_bridge.DEFAULT_CHUNK_BYTES, remaining))
if not chunk:
raise media_bridge.MediaError(
protocol.ERR_INTERNAL, "client disconnected mid-upload"
)
sess.feed(chunk)
remaining -= len(chunk)
if sess.received != length:
raise media_bridge.MediaError(
protocol.ERR_INTERNAL,
f"size mismatch (declared {length}, received {sess.received})",
)
entry = self._adapter._media.complete_upload(device_id, media_ref, sha256)
except media_bridge.MediaError as e:
# complete_upload already popped the session; discard is a no-op
# in that case (feed/short-read failures leave it active).
self._adapter._media.discard_upload(device_id, media_ref)
reject(e.code, e.message)
return
except (BrokenPipeError, ConnectionResetError, OSError):
self._adapter._media.discard_upload(device_id, media_ref)
return # client went away: nothing to answer
_send_frame_json(handler, 201, protocol.media_upload_ack(True, entry.media_id).to_json())
# ── GET /v1/media/{id} (pull, docs/19 §19.15) ─────────────────────────
def _handle_media_pull(
self, handler: BaseHTTPRequestHandler, device_id: str, media_id: str
) -> None:
"""Stream an outbound offer as the response body (docs/07 §7.3).
The delivery-path validation is re-checked at pull time, exactly as
the WS ``media.pull`` handler does (the file may have moved since
the offer)."""
entry = self._adapter._media.get_outbound(media_id)
if entry is None:
_send_frame_json(
handler,
404,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}").to_json(),
)
return
safe = validate_media_delivery_path(entry.path) if validate_media_delivery_path else None
if safe is None:
_send_frame_json(
handler,
404,
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable").to_json(),
)
return
filename = entry.filename.replace('"', "")
handler.send_response(200)
handler.send_header("Content-Type", entry.mime)
handler.send_header("Content-Length", str(entry.size))
handler.send_header("Content-Disposition", f'attachment; filename="{filename}"')
handler.end_headers()
try:
with open(safe, "rb") as f: # pi-lens-ignore: python-path-traversal
while True:
chunk = f.read(media_bridge.DEFAULT_CHUNK_BYTES)
if not chunk:
break
handler.wfile.write(chunk)
handler.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass # client went away mid-pull, or the file vanished: normal
# ── GET /v1/poll (long-poll) ────────────────────────────────────────── # ── GET /v1/poll (long-poll) ──────────────────────────────────────────
def _handle_poll( def _handle_poll(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any
) -> None:
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
cursor = _parse_cursor(qs.get("cursor", [None])[0]) cursor = _parse_cursor(qs.get("cursor", [None])[0])
sub = _Subscriber(device_id=device_id, kind="poll") sub = _Subscriber(device_id=device_id, kind="poll")
@@ -552,6 +702,7 @@ class HttpServer:
hwm = max(max_cursor, self._adapter._outbox.latest_cursor()) hwm = max(max_cursor, self._adapter._outbox.latest_cursor())
_send_json(handler, 200, {"cursor": hwm, "frames": frames}) _send_json(handler, 200, {"cursor": hwm, "frames": frames})
except (BrokenPipeError, ConnectionResetError, OSError): except (BrokenPipeError, ConnectionResetError, OSError):
# Client went away while we held the poll: normal.
pass pass
finally: finally:
self._remove_sub(sub) self._remove_sub(sub)
@@ -601,6 +752,17 @@ class _Handler(BaseHTTPRequestHandler):
if device_id is not None: if device_id is not None:
hs._handle_poll(self, device_id, parsed) hs._handle_poll(self, device_id, parsed)
return return
if parsed.path.startswith("/v1/media/"):
media_id = parsed.path[len("/v1/media/") :]
# The id is looked up in an exact-match dict; reject anything
# path-shaped so a bad URL can't be mistaken for an id.
if media_id and "/" not in media_id:
device_id = hs._authenticate(self)
if device_id is not None:
hs._handle_media_pull(self, device_id, media_id)
else:
_send_json(self, 404, {"error": "not found"})
return
_send_json(self, 404, {"error": "not found"}) _send_json(self, 404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
@@ -609,6 +771,21 @@ class _Handler(BaseHTTPRequestHandler):
_send_json(self, 503, {"error": "http leg disabled"}) _send_json(self, 503, {"error": "http leg disabled"})
return return
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path == "/v1/media":
device_id = hs._authenticate(self)
if device_id is None:
return
if hs._rate_limited(device_id):
_send_frame_json(
self,
429,
protocol.error(
protocol.ERR_RATE_LIMITED, "http media rate limit exceeded"
).to_json(),
)
return
hs._handle_media_upload(self, device_id)
return
if parsed.path != "/v1/frame": if parsed.path != "/v1/frame":
_send_json(self, 404, {"error": "not found"}) _send_json(self, 404, {"error": "not found"})
return return
@@ -639,6 +816,15 @@ class _Handler(BaseHTTPRequestHandler):
except ValueError: except ValueError:
length = 0 length = 0
if length <= 0 or length > MAX_BODY_BYTES: if length <= 0 or length > MAX_BODY_BYTES:
# Drain the (oversize) body so the connection stays clean; cap the
# drain at MAX_BODY_BYTES so a runaway body can't wedge the thread.
if length > 0:
to_drain = min(length, MAX_BODY_BYTES)
while to_drain > 0:
chunk = self.rfile.read(min(65536, to_drain))
if not chunk:
break
to_drain -= len(chunk)
_send_frame_json( _send_frame_json(
self, self,
413, 413,
@@ -654,13 +840,4 @@ class _Handler(BaseHTTPRequestHandler):
self, 400, protocol.error(protocol.ERR_INTERNAL, "invalid frame").to_json() self, 400, protocol.error(protocol.ERR_INTERNAL, "invalid frame").to_json()
) )
return return
if frame.type in HTTP_REJECTED_TYPES:
_send_frame_json(
self,
400,
protocol.error(
protocol.ERR_UNSUPPORTED, f"{frame.type} requires the live connection"
).to_json(),
)
return
hs._handle_frame(self, device_id, frame) hs._handle_frame(self, device_id, frame)
-25
View File
@@ -20,7 +20,6 @@ live under ``get_hermes_home()/"android"/media/tmp``.
Milestone M4. Milestone M4.
""" """
import asyncio
import contextlib import contextlib
import hashlib import hashlib
import logging import logging
@@ -434,27 +433,3 @@ class MediaStore:
for k in stale: for k in stale:
del self._outbound[k] del self._outbound[k]
return len(stale) return len(stale)
async def stream_file(
ws, path: str, chunk_bytes: int = DEFAULT_CHUNK_BYTES, timeout: float = 10.0
) -> int:
"""Stream *path* to *ws* as binary frames. Returns bytes sent.
Ordering is guaranteed by the WebSocket; the caller sends the terminal
``media.pull.end`` frame afterwards. Each chunk send is bounded by
*timeout* so a stalled puller can't wedge the handler forever (the
caller treats the raised error as an aborted pull).
"""
sent = 0
# Safe: ``path`` is produced by hermes ``cache_*_from_bytes`` (a path inside
# hermes's own media cache dir), never derived from raw user input.
# pi-lens-ignore: python-path-traversal
with open(path, "rb") as f:
while True:
chunk = f.read(chunk_bytes)
if not chunk:
break
await asyncio.wait_for(ws.send(chunk), timeout=timeout)
sent += len(chunk)
return sent
+4 -4
View File
@@ -45,7 +45,7 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str: def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
"""Pairing URL encoded into the QR / pre-filled into the app. """Pairing URL encoded into the QR / pre-filled into the app.
``iris://pair?host=<lan-ip>&port=8790&token=<token>`` — the app's ``iris://pair?host=<lan-ip>&port=8791&token=<token>`` — the app's
Connect screen parses this to pre-fill settings (docs/09 §9.2). Connect screen parses this to pre-fill settings (docs/09 §9.2).
""" """
return ( return (
@@ -57,9 +57,9 @@ def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
def pairing_url(host: str, port: int, secure: bool = False) -> str: def pairing_url(host: str, port: int, secure: bool = False) -> str:
"""Plain ws(s) URL the app connects to (shown next to the QR).""" """Plain http(s) URL the app connects to (shown next to the QR)."""
scheme = "wss" if secure else "ws" scheme = "https" if secure else "http"
return f"{scheme}://{host}:{int(port)}/ws" return f"{scheme}://{host}:{int(port)}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+4 -22
View File
@@ -26,11 +26,8 @@ PROTOCOL_VERSION = 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pairing / lifecycle # Pairing / lifecycle
TYPE_HELLO = "hello"
TYPE_HELLO_ACK = "hello.ack" TYPE_HELLO_ACK = "hello.ack"
TYPE_ERROR = "error" TYPE_ERROR = "error"
TYPE_PING = "ping"
TYPE_PONG = "pong"
# Chat # Chat
TYPE_MESSAGE = "message" TYPE_MESSAGE = "message"
@@ -82,12 +79,8 @@ TYPE_SYNC_DONE = "sync.done"
TYPE_HISTORY = "history" TYPE_HISTORY = "history"
# Media (M4) # Media (M4)
TYPE_MEDIA_UPLOAD_START = "media.upload.start"
TYPE_MEDIA_UPLOAD_END = "media.upload.end"
TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack" TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
TYPE_MEDIA_OFFER = "media.offer" TYPE_MEDIA_OFFER = "media.offer"
TYPE_MEDIA_PULL = "media.pull"
TYPE_MEDIA_PULL_END = "media.pull.end"
# Push / notifications (M5) # Push / notifications (M5)
TYPE_NOTIFICATION = "notification" TYPE_NOTIFICATION = "notification"
@@ -749,7 +742,8 @@ def media_offer(
thread_id: str | None = None, thread_id: str | None = None,
message_id: str | None = None, message_id: str | None = None,
) -> Frame: ) -> Frame:
"""Event: the agent produced media the app can fetch via ``media.pull``. """Event: the agent produced media the app can fetch via
``GET /v1/media/{media_id}`` (docs/19 §19.15).
``message_id`` (optional) associates the offer with the assistant message ``message_id`` (optional) associates the offer with the assistant message
it belongs to (the app falls back to the lane's last assistant message). it belongs to (the app falls back to the lane's last assistant message).
@@ -766,14 +760,9 @@ def media_offer(
return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload) return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload)
def media_pull_end(ok: bool, *, id: int | None = None) -> Frame:
"""Terminal frame of a ``media.pull`` binary stream."""
return Frame(type=TYPE_MEDIA_PULL_END, id=id, payload={"ok": ok})
def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Frame: def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Frame:
"""Response to ``media.upload.end``: the ref is cached and may be used in """Response to ``POST /v1/media``: the ref is cached and may be used in a
a ``message.send`` ``media_refs``. Failures use ``error`` frames instead.""" ``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
return Frame( return Frame(
type=TYPE_MEDIA_UPLOAD_ACK, type=TYPE_MEDIA_UPLOAD_ACK,
id=id, id=id,
@@ -783,10 +772,3 @@ def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Fram
def error(code: str, message: str, *, id: int | None = None) -> Frame: def error(code: str, message: str, *, id: int | None = None) -> Frame:
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message}) return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
def pong(ts: int | None = None) -> Frame:
payload: dict[str, Any] = {}
if ts is not None:
payload["ts"] = ts
return Frame(type=TYPE_PONG, payload=payload)
+307 -246
View File
@@ -5,13 +5,13 @@ The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
from the source tree directly so they never depend on that install. from the source tree directly so they never depend on that install.
Coverage (docs/13-testing.md §13.1, media bullets): Coverage (docs/13-testing.md §13.1, media bullets):
* upload start -> binary chunks -> end reassembles + sha256 verified * upload via ``POST /v1/media`` reassembles + sha256 verified
* over-limit (declared and mid-stream) -> ``media_too_large`` * over-limit (Content-Length) -> ``media_too_large``
* sha256 mismatch -> ``internal`` * sha256 mismatch -> ``internal``
* ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the * ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the
``MessageEvent`` carries ``media_urls``/``media_types`` ``MessageEvent`` carries ``media_urls``/``media_types``
* ``send_*`` -> ``media.offer`` (fields + message association) * ``send_*`` -> ``media.offer`` (fields + message association)
* ``media.pull`` serves only allowed paths (denied/unknown -> ``not_found``) * ``GET /v1/media/{id}`` serves only allowed paths (denied/unknown -> ``not_found``)
* kind re-sniffing (don't trust the client) * kind re-sniffing (don't trust the client)
Run via ``scripts/run_tests.sh tests/gateway/test_android.py``. Run via ``scripts/run_tests.sh tests/gateway/test_android.py``.
@@ -26,6 +26,9 @@ import importlib.util
import json import json
import os import os
import sys import sys
import socket
import threading
from http.client import HTTPConnection
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -47,8 +50,14 @@ def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR") env = os.environ.get("ANDROID_PLUGIN_DIR")
if env: if env:
return Path(env) return Path(env)
# hermes-agent/tests/gateway/test_android.py -> repo root is parents[3]. # Works from either copy of this file: gateway-plugin/tests/ (canonical,
return Path(__file__).resolve().parents[3] / "gateway-plugin" # plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
# (repo root is parents[3]).
here = Path(__file__).resolve()
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
if (candidate / "protocol.py").is_file():
return candidate
return here.parents[1]
def _load_plugin(): def _load_plugin():
@@ -105,7 +114,7 @@ def adapter(plugin, monkeypatch):
config = SimpleNamespace( config = SimpleNamespace(
extra={ extra={
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 0, # ephemeral port "http_port": 0, # ephemeral HTTP port
"max_upload_bytes": 1024 * 1024, # 1 MiB -- keeps over-limit tests fast "max_upload_bytes": 1024 * 1024, # 1 MiB -- keeps over-limit tests fast
}, },
home_channel=None, home_channel=None,
@@ -122,48 +131,206 @@ def adapter(plugin, monkeypatch):
pass pass
async def _hello(ws) -> dict: class HttpTestClient:
await ws.send( """Mimics the old WS client interface over the HTTP transport (docs/19).
json.dumps(
{ ``.send(json_str)`` -> ``POST /v1/frame``; ``.recv(timeout)`` -> the next
"v": 1, frame from the SSE stream (a dict); ``.upload(...)`` -> ``POST /v1/media``
"type": "hello", returning the terminal frame (ack or error). The SSE stream is read in a
"payload": { background thread that feeds an asyncio queue, so ``recv`` is awaitable
"token": TOKEN, exactly like the old WS ``recv``.
"device_id": DEVICE_ID, """
"device_name": "Test Device",
"caps": {}, def __init__(self, port: int, cursor: int = 0):
self._port = port
self._cursor = cursor
self._queue: asyncio.Queue = asyncio.Queue()
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._resp = None
async def start(self) -> dict:
"""Open the SSE stream; returns the hello.ack frame. Any outbox
catch-up frames replayed before the hello are buffered and re-enqueued
so ``recv`` can still see them."""
self._loop = asyncio.get_running_loop()
self._thread = threading.Thread(target=self._sse_reader, daemon=True)
self._thread.start()
buffered: list = []
hello = None
while hello is None:
frame = await asyncio.wait_for(self._queue.get(), timeout=5)
if frame is None:
raise AssertionError("SSE stream closed before hello")
if frame.get("type") == "hello.ack":
hello = frame
else:
buffered.append(frame)
for f in buffered:
self._queue.put_nowait(f)
return hello
def _sse_reader(self) -> None:
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
conn.request(
"GET",
f"/v1/events?cursor={self._cursor}",
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
)
resp = conn.getresponse()
self._resp = resp
cur_data: list[str] = []
loop = self._loop
assert loop is not None
while True:
line = resp.fp.readline()
if not line:
break
line = line.decode("utf-8").rstrip("\r\n")
if line == "":
if cur_data:
try:
frame = json.loads("\n".join(cur_data))
except Exception:
frame = None
if frame is not None:
loop.call_soon_threadsafe(self._queue.put_nowait, frame)
cur_data = []
elif line.startswith(":"):
continue # heartbeat comment
elif line.startswith("data:"):
cur_data.append(line[5:].lstrip())
# id:/event: fields are not needed for the test shim
loop.call_soon_threadsafe(self._queue.put_nowait, None) # EOF sentinel
async def send(self, json_str: str) -> None:
await asyncio.to_thread(self._post_frame, json_str)
def _post_frame(self, json_str: str) -> None:
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
conn.request(
"POST",
"/v1/frame",
body=json_str.encode("utf-8"),
headers={
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
"Content-Type": "application/json",
}, },
}
) )
resp = conn.getresponse()
body = resp.read()
conn.close()
# Fast responses (validation, channel ops, …) come back on the POST
# response body via the reply sink, NOT the SSE stream. Enqueue any
# protocol frame so recv_until sees it (the plain {"ok":true} ack is
# not a frame and is skipped).
if body:
try:
obj = json.loads(body)
except Exception:
obj = None
if isinstance(obj, dict) and "type" in obj:
assert self._loop is not None
self._loop.call_soon_threadsafe(self._queue.put_nowait, obj)
async def recv(self, timeout: float = 10.0) -> dict:
frame = await asyncio.wait_for(self._queue.get(), timeout=timeout)
if frame is None:
raise ConnectionError("SSE stream closed")
return frame
async def upload(
self,
media_ref: str,
data: bytes,
*,
kind: str = "image",
mime: str = "image/png",
filename: str = "t.png",
sha256: str | None = None,
) -> dict:
"""Drive a media upload via ``POST /v1/media``; returns the terminal
frame (``media.upload.ack`` or ``error``)."""
def _do() -> dict:
conn = HTTPConnection("127.0.0.1", self._port, timeout=60)
conn.request(
"POST",
"/v1/media",
body=data,
headers={
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
"Content-Type": mime,
"X-Iris-Media-Ref": media_ref,
"X-Iris-Media-Kind": kind,
"X-Iris-Media-Filename": filename,
"X-Iris-Media-Sha256": sha256
or hashlib.sha256(data).hexdigest(),
},
) )
raw = await asyncio.wait_for(ws.recv(), timeout=5) resp = conn.getresponse()
ack = json.loads(raw) body = resp.read()
assert ack["type"] == "hello.ack", f"expected hello.ack, got {ack}" conn.close()
return ack return json.loads(body)
return await asyncio.to_thread(_do)
async def pull(self, media_id: str) -> tuple[int, bytes]:
"""Drive a media pull via ``GET /v1/media/{id}``; returns (status, body)."""
def _do() -> tuple[int, bytes]:
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
conn.request(
"GET",
f"/v1/media/{media_id}",
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
)
resp = conn.getresponse()
body = resp.read()
status = resp.status
conn.close()
return status, body
return await asyncio.to_thread(_do)
async def close(self) -> None:
# Interrupt the reader thread's blocking readline() by shutting down
# the socket first; otherwise resp.close() blocks until the in-flight
# read returns (the file lock is held for the whole blocking read).
if self._resp is not None:
sock = getattr(self._resp.fp, "raw", None)
sock = getattr(sock, "_sock", None) if sock is not None else None
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
self._resp.close()
except Exception:
pass
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def ws_client(adapter): async def ws_client(adapter):
"""Connected + paired WS client; the adapter's server runs on an """Connected + paired HTTP client; the adapter's server runs on an
ephemeral port for the duration of the test.""" ephemeral port for the duration of the test. Yields ``(client, ack)`` to
from websockets.asyncio.client import connect match the old WS fixture shape so test bodies need no signature change."""
await adapter.connect() await adapter.connect()
client = HttpTestClient(adapter._http_server.bound_port)
try: try:
port = adapter._ws_server._server.sockets[0].getsockname()[1] ack = await client.start()
async with connect( yield client, ack
f"ws://127.0.0.1:{port}/ws", max_size=4 * 1024 * 1024
) as ws:
ack = await _hello(ws)
yield ws, ack
finally: finally:
await client.close()
await adapter.disconnect() await adapter.disconnect()
async def recv_until(ws, predicate, timeout: float = 10.0) -> list: async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
"""Collect frames (dicts; binary frames as ("binary", bytes)) until """Collect frames (dicts) until *predicate* matches a frame. Returns all
*predicate* matches a JSON frame. Returns all frames collected.""" frames collected."""
frames: list = [] frames: list = []
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
deadline = loop.time() + timeout deadline = loop.time() + timeout
@@ -172,13 +339,9 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
if remaining <= 0: if remaining <= 0:
raise AssertionError( raise AssertionError(
"timed out waiting for frame; got: " "timed out waiting for frame; got: "
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "binary" for f in frames) + ", ".join(f.get("type", "?") if isinstance(f, dict) else "?" for f in frames)
) )
raw = await asyncio.wait_for(ws.recv(), timeout=remaining) frame = await ws.recv(timeout=remaining)
if isinstance(raw, (bytes, bytearray)):
frames.append(("binary", bytes(raw)))
continue
frame = json.loads(raw)
frames.append(frame) frames.append(frame)
if predicate(frame): if predicate(frame):
return frames return frames
@@ -186,43 +349,42 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image", async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image",
mime: str = "image/png", filename: str = "t.png", mime: str = "image/png", filename: str = "t.png",
request_id: int = 1) -> dict: request_id: int = 1, sha256: str | None = None) -> dict:
"""Drive a full media.upload flow; returns the terminal frame (ack or error).""" """Drive a media upload via the HTTP leg; returns the terminal frame
await ws.send( (ack or error)."""
json.dumps( return await ws.upload(
{ media_ref, data, kind=kind, mime=mime, filename=filename, sha256=sha256
"v": 1,
"id": request_id,
"type": "media.upload.start",
"payload": {
"media_ref": media_ref,
"kind": kind,
"mime": mime,
"size": len(data),
"filename": filename,
},
}
) )
# ── Lifecycle ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_disconnect_broadcasts_status_restarting(adapter):
"""Teardown broadcasts ``status{state=restarting}`` before closing the
streams, so the app can distinguish a clean gateway teardown (restart/
stop) from a plain network drop — it shows the "Gateway restarting" chat
notice only when this frame was received (docs/04 §status)."""
await adapter.connect()
client = HttpTestClient(adapter._http_server.bound_port)
try:
await client.start()
await adapter.disconnect()
frames = []
while True:
try:
frames.append(await client.recv(timeout=5))
except (ConnectionError, asyncio.TimeoutError):
break
statuses = [f for f in frames if f.get("type") == "status"]
assert any(f["payload"]["state"] == "restarting" for f in statuses), (
f"expected status{{restarting}} before close, got: {frames}"
) )
# Two chunks to exercise reassembly. finally:
half = len(data) // 2 await client.close()
await ws.send(data[:half]) # Idempotent: the second call is a no-op on the already-stopped server.
await ws.send(data[half:]) await adapter.disconnect()
await ws.send(
json.dumps(
{
"v": 1,
"id": request_id + 1,
"type": "media.upload.end",
"payload": {
"media_ref": media_ref,
"sha256": hashlib.sha256(data).hexdigest(),
},
}
)
)
frames = await recv_until(ws, lambda f: f.get("type") in ("media.upload.ack", "error"))
return frames[-1]
# ── Pure helpers ──────────────────────────────────────────────────────────── # ── Pure helpers ────────────────────────────────────────────────────────────
@@ -375,7 +537,6 @@ async def test_upload_reassembles_verifies_and_caches(adapter, ws_client):
terminal = await upload_file(ws, "mu_t1", PNG_1X1) terminal = await upload_file(ws, "mu_t1", PNG_1X1)
assert terminal["type"] == "media.upload.ack", terminal assert terminal["type"] == "media.upload.ack", terminal
assert terminal["id"] == 2
assert terminal["payload"]["ok"] is True assert terminal["payload"]["ok"] is True
assert terminal["payload"]["media_ref"] == "mu_t1" assert terminal["payload"]["media_ref"] == "mu_t1"
@@ -396,100 +557,37 @@ async def test_upload_reassembles_verifies_and_caches(adapter, ws_client):
async def test_upload_declared_over_limit_rejected(adapter, ws_client): async def test_upload_declared_over_limit_rejected(adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
limit = adapter.max_upload_bytes limit = adapter.max_upload_bytes
await ws.send( # Over HTTP the server checks Content-Length before reading the body.
json.dumps( err = await upload_file(
{ ws, "mu_big", b"x" * (limit + 1), kind="document", mime="application/pdf",
"v": 1, filename="big.pdf",
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_big",
"kind": "document",
"mime": "application/pdf",
"size": limit + 1,
"filename": "big.pdf",
},
}
) )
) assert err["type"] == "error"
frames = await recv_until(ws, lambda f: f.get("type") == "error")
err = frames[-1]
assert err["payload"]["code"] == "media_too_large" assert err["payload"]["code"] == "media_too_large"
assert err["id"] == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_midstream_over_limit_rejected(adapter, ws_client): async def test_upload_over_limit_ref_not_consumed(adapter, ws_client):
"""Over HTTP the over-limit check happens before the body is read, so a
rejected upload must not consume its media_ref (a later valid upload with
the same ref succeeds)."""
ws, _ = ws_client ws, _ = ws_client
limit = adapter.max_upload_bytes limit = adapter.max_upload_bytes
await ws.send( err = await upload_file(ws, "mu_reuse", b"x" * (limit + 1), kind="document")
json.dumps( assert err["type"] == "error"
{ assert err["payload"]["code"] == "media_too_large"
"v": 1, # The ref is free: a valid upload with the same ref now succeeds.
"id": 1, ok = await upload_file(ws, "mu_reuse", PNG_1X1)
"type": "media.upload.start", assert ok["type"] == "media.upload.ack", ok
"payload": { assert ok["payload"]["ok"] is True
"media_ref": "mu_mid",
"kind": "document",
"mime": "application/octet-stream",
"size": limit,
"filename": "mid.bin",
},
}
)
)
# Declared size passes the start check; the stream exceeds it.
await ws.send(b"x" * (limit // 2))
await ws.send(b"x" * (limit // 2 + 1))
frames = await recv_until(ws, lambda f: f.get("type") == "error")
assert frames[-1]["payload"]["code"] == "media_too_large"
# The session is discarded: a late end cannot complete it.
await ws.send(
json.dumps(
{
"v": 1,
"id": 2,
"type": "media.upload.end",
"payload": {"media_ref": "mu_mid", "sha256": "0" * 64},
}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
assert frames[-1]["payload"]["code"] == "not_found"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_sha256_mismatch_rejected(adapter, ws_client): async def test_upload_sha256_mismatch_rejected(adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
await ws.send( err = await upload_file(ws, "mu_bad", PNG_1X1, sha256="0" * 64)
json.dumps( assert err["type"] == "error"
{ assert err["payload"]["code"] == "internal"
"v": 1,
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_bad",
"kind": "image",
"mime": "image/png",
"size": len(PNG_1X1),
"filename": "bad.png",
},
}
)
)
await ws.send(PNG_1X1)
await ws.send(
json.dumps(
{
"v": 1,
"id": 2,
"type": "media.upload.end",
"payload": {"media_ref": "mu_bad", "sha256": "0" * 64},
}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error")
assert frames[-1]["payload"]["code"] == "internal"
assert adapter._media.get_inbound("mu_bad") is None assert adapter._media.get_inbound("mu_bad") is None
@@ -1223,47 +1321,26 @@ async def test_pull_serves_allowed_path(adapter, ws_client):
str(img), "image", "image/png", "pull_test.png", len(PNG_1X1) str(img), "image", "image/png", "pull_test.png", len(PNG_1X1)
) )
await ws.send( status, body = await ws.pull(entry.media_id)
json.dumps( assert status == 200
{"v": 1, "id": 9, "type": "media.pull", "payload": {"media_id": entry.media_id}} assert body == PNG_1X1
)
)
chunks: list[bytes] = []
terminal = None
while terminal is None:
raw = await asyncio.wait_for(ws.recv(), timeout=10)
if isinstance(raw, (bytes, bytearray)):
chunks.append(bytes(raw))
continue
frame = json.loads(raw)
if frame.get("type") == "media.pull.end":
terminal = frame
assert terminal["id"] == 9
assert terminal["payload"]["ok"] is True
assert b"".join(chunks) == PNG_1X1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pull_rejects_unknown_and_denied(adapter, ws_client): async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
# Unknown media_id. # Unknown media_id.
await ws.send( status, body = await ws.pull("md_nope")
json.dumps({"v": 1, "id": 1, "type": "media.pull", "payload": {"media_id": "md_nope"}}) assert status == 404
) assert json.loads(body)["payload"]["code"] == "not_found"
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
assert frames[-1]["payload"]["code"] == "not_found"
# Known id, but the path fails delivery validation (denylist). # Known id, but the path fails delivery validation (denylist).
entry = adapter._media.register_outbound( entry = adapter._media.register_outbound(
"/etc/passwd", "document", "text/plain", "passwd", 100 "/etc/passwd", "document", "text/plain", "passwd", 100
) )
await ws.send( status, body = await ws.pull(entry.media_id)
json.dumps( assert status == 404
{"v": 1, "id": 2, "type": "media.pull", "payload": {"media_id": entry.media_id}} assert json.loads(body)["payload"]["code"] == "not_found"
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
assert frames[-1]["payload"]["code"] == "not_found"
# Known id, file deleted since the offer. # Known id, file deleted since the offer.
from gateway.platforms.base import get_image_cache_dir from gateway.platforms.base import get_image_cache_dir
@@ -1274,13 +1351,9 @@ async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
str(img), "image", "image/png", "gone.png", len(PNG_1X1) str(img), "image", "image/png", "gone.png", len(PNG_1X1)
) )
img.unlink() img.unlink()
await ws.send( status, body = await ws.pull(entry2.media_id)
json.dumps( assert status == 404
{"v": 1, "id": 3, "type": "media.pull", "payload": {"media_id": entry2.media_id}} assert json.loads(body)["payload"]["code"] == "not_found"
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 3)
assert frames[-1]["payload"]["code"] == "not_found"
# ── M5: push backends (pure) ─────────────────────────────────────────────── # ── M5: push backends (pure) ───────────────────────────────────────────────
@@ -1653,7 +1726,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client): async def test_fcm_register_updates_registry(adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
await ws.send( await ws.send(
json.dumps( json.dumps(
@@ -1664,7 +1737,7 @@ async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
} }
) )
) )
# Ordering barrier: WS frames are processed in order, so by the time the # Ordering barrier: frames are processed in order, so by the time the
# channel.list response arrives, fcm.register has been handled. # channel.list response arrives, fcm.register has been handled.
await ws.send( await ws.send(
json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}}) json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}})
@@ -1673,16 +1746,13 @@ async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
dev = adapter._devices.get(DEVICE_ID) dev = adapter._devices.get(DEVICE_ID)
assert dev["fcm_token"] == "rotated-token" assert dev["fcm_token"] == "rotated-token"
assert dev["ntfy_topic"] == "dev-topic" assert dev["ntfy_topic"] == "dev-topic"
conn = adapter._ws_server.connection(DEVICE_ID)
assert conn.fcm_token == "rotated-token"
assert conn.ntfy_topic == "dev-topic"
# The next offline push targets the rotated token. # The next offline push targets the rotated token. Close the stream and
# force-remove the (possibly stale) subscriber so the push isn't counted
# as delivered to a dead connection.
await ws.close() await ws.close()
for _ in range(100): with adapter._http_server._subs_lock:
if not adapter._ws_server.has_devices(): adapter._http_server._subs.clear()
break
await asyncio.sleep(0.01)
fake = _FakePush() fake = _FakePush()
adapter._push = fake adapter._push = fake
await adapter.send("android:default", "after rotation", metadata={"notify": True}) await adapter.send("android:default", "after rotation", metadata={"notify": True})
@@ -1898,13 +1968,12 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
await adapter.send("android:default", "two", metadata={"notify": True}) await adapter.send("android:default", "two", metadata={"notify": True})
assert adapter._outbox.latest_cursor() == 2 assert adapter._outbox.latest_cursor() == 2
from websockets.asyncio.client import connect
await adapter.connect() await adapter.connect()
# Open at the latest cursor so the SSE catch-up doesn't replay the parked
# frames (the sync request below is what we're testing).
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try: try:
port = adapter._ws_server._server.sockets[0].getsockname()[1] ack = await ws.start()
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["sync_cursor"] == 2 assert ack["payload"]["sync_cursor"] == 2
await ws.send( await ws.send(
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}}) json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
@@ -1924,6 +1993,7 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
) )
assert len(frames) == 1 assert len(frames) == 1
finally: finally:
await ws.close()
await adapter.disconnect() await adapter.disconnect()
@@ -1931,15 +2001,13 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
async def test_hello_ack_last_pushed_cursor_default_zero(adapter): async def test_hello_ack_last_pushed_cursor_default_zero(adapter):
"""A device that never received a push reports last_pushed_cursor=0 in """A device that never received a push reports last_pushed_cursor=0 in
hello.ack (docs/08 §8.7 dedupe watermark).""" hello.ack (docs/08 §8.7 dedupe watermark)."""
from websockets.asyncio.client import connect
await adapter.connect() await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port)
try: try:
port = adapter._ws_server._server.sockets[0].getsockname()[1] ack = await ws.start()
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["last_pushed_cursor"] == 0 assert ack["payload"]["last_pushed_cursor"] == 0
finally: finally:
await ws.close()
await adapter.disconnect() await adapter.disconnect()
@@ -1949,8 +2017,6 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
next hello.ack reports it — the app uses it to skip re-notifying next hello.ack reports it — the app uses it to skip re-notifying
sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same
chat coalesce into one push; a failed push does not advance the cursor.""" chat coalesce into one push; a failed push does not advance the cursor."""
from websockets.asyncio.client import connect
fake = _FakePush() fake = _FakePush()
adapter._push = fake adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
@@ -1978,12 +2044,12 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
await adapter.connect() await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try: try:
port = adapter._ws_server._server.sockets[0].getsockname()[1] ack = await ws.start()
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["last_pushed_cursor"] == 3 assert ack["payload"]["last_pushed_cursor"] == 3
finally: finally:
await ws.close()
await adapter.disconnect() await adapter.disconnect()
@@ -1992,16 +2058,13 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
"""Frames replayed by sync carry their outbox cursor in the envelope so """Frames replayed by sync carry their outbox cursor in the envelope so
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
frames carry no cursor.""" frames carry no cursor."""
from websockets.asyncio.client import connect
await adapter.send("android:default", "one", metadata={"notify": True}) await adapter.send("android:default", "one", metadata={"notify": True})
await adapter.send("android:default", "two", metadata={"notify": True}) await adapter.send("android:default", "two", metadata={"notify": True})
await adapter.connect() await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try: try:
port = adapter._ws_server._server.sockets[0].getsockname()[1] await ws.start()
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
await _hello(ws)
await ws.send( await ws.send(
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}}) json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
) )
@@ -2011,6 +2074,7 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
# sync.done itself carries no envelope cursor. # sync.done itself carries no envelope cursor.
assert "cursor" not in frames[-1] assert "cursor" not in frames[-1]
finally: finally:
await ws.close()
await adapter.disconnect() await adapter.disconnect()
@@ -2129,30 +2193,27 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wrong_token_rejected(adapter): async def test_wrong_token_rejected(adapter):
from websockets.asyncio.client import connect """A request with a wrong Bearer token is rejected with 401 (the HTTP
equivalent of the WS hello auth rejection)."""
await adapter.connect() await adapter.connect()
try: try:
port = adapter._ws_server._server.sockets[0].getsockname()[1] port = adapter._http_server.bound_port
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
await ws.send( def _req() -> int:
json.dumps( conn = HTTPConnection("127.0.0.1", port, timeout=5)
{ conn.request(
"v": 1, "GET",
"type": "hello", "/v1/events",
"payload": { headers={"Authorization": "Bearer wrong-token", "X-Iris-Device": DEVICE_ID},
"token": "wrong-token",
"device_id": DEVICE_ID,
"device_name": "Bad",
"caps": {},
},
}
) )
) resp = conn.getresponse()
raw = await asyncio.wait_for(ws.recv(), timeout=5) resp.read()
err = json.loads(raw) status = resp.status
assert err["type"] == "error" conn.close()
assert err["payload"]["code"] == "auth" return status
status = await asyncio.to_thread(_req)
assert status == 401
finally: finally:
await adapter.disconnect() await adapter.disconnect()
+826
View File
@@ -0,0 +1,826 @@
"""Tests for the android plugin's HTTP fallback transport (docs/19).
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
from the source tree directly (same pattern as ``test_android.py``).
Coverage (docs/19 §19.12):
* auth: bad/missing token -> 401; missing device header -> 401;
allowlist rejection -> 401
* ``POST /v1/frame``: valid ``message.send`` dispatches (202 + echo on
the SSE stream); empty text -> 400 error frame; automation channel ->
400; bad JSON -> 400; wrong content-type -> 400; oversize body -> 413;
media frames -> 400 (WS-only in v1); rate limit -> 429
* SSE: catch-up rows carry correct ``id``s + cursor envelope;
``event: hello`` present; a live frame appended after connect arrives
on the stream; ``Last-Event-ID`` resume replays exactly the delta;
heartbeat observed
* long-poll: returns on new frame; empty 200 at timeout with advanced
cursor
* **delivery counting (docs/19 §19.8)**: a frame with only an SSE
subscriber is ``delivered >= 1`` -> NO push fired (the critical
regression test)
Run via ``scripts/run_tests.sh tests/gateway/test_android_http.py``.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import importlib.util
import json
import os
import sys
import time
from http.client import HTTPConnection
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
# Test-only token (not a credential; the adapter is built with it via
# monkeypatch in the fixture below).
# pi-lens-ignore: S105
TOKEN = "test-android-http-token-0123456789"
DEVICE_ID = "test-http-device"
CHAT_ID = "android:default"
# 1x1 PNG (same fixture as test_android.py).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR")
if env:
return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
# (repo root is parents[3]).
here = Path(__file__).resolve()
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
if (candidate / "protocol.py").is_file():
return candidate
return here.parents[1]
def _load_plugin():
"""Load the gateway-plugin package under a unique module name (same
pattern as test_android.py)."""
name = "android_plugin_http_under_test"
cached = sys.modules.get(name)
if cached is not None:
return cached
pkg_dir = _plugin_dir()
if not (pkg_dir / "__init__.py").is_file():
pytest.fail(f"android plugin not found at {pkg_dir}")
spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
)
if spec is None or spec.loader is None:
pytest.fail(f"could not build import spec for {pkg_dir}")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(name, None)
raise
return module
@pytest.fixture(scope="module")
def plugin():
return _load_plugin()
@pytest.fixture
def adapter(plugin, monkeypatch):
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
from gateway.platform_registry import PlatformEntry, platform_registry
if not platform_registry.is_registered("android"):
platform_registry.register(
PlatformEntry(
name="android",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
)
)
config = SimpleNamespace(
extra={
"host": "127.0.0.1",
"port": 0, # ephemeral WS port
"http_port": 0, # ephemeral HTTP port
"max_upload_bytes": 1024 * 1024,
},
home_channel=None,
)
a = plugin.adapter.AndroidAdapter(config)
yield a
with contextlib.suppress(Exception):
a._devices.close()
with contextlib.suppress(Exception):
a._outbox.close()
@pytest_asyncio.fixture
async def gw(adapter):
"""Connected adapter (WS + HTTP legs up); the HTTP port is ephemeral."""
await adapter.connect()
try:
yield adapter
finally:
await adapter.disconnect()
def http_port(adapter) -> int:
assert adapter._http_server.enabled, "HTTP leg should be enabled after connect()"
return adapter._http_server.bound_port
# ── Blocking HTTP helpers (run via asyncio.to_thread) ──────────────────────
def _request(
port: int,
method: str,
path: str,
*,
token: str | None = TOKEN,
device: str | None = DEVICE_ID,
body: bytes | str | None = None,
content_type: str = "application/json",
timeout: float = 10.0,
extra_headers: dict | None = None,
) -> tuple[int, bytes]:
conn = HTTPConnection("127.0.0.1", port, timeout=timeout)
headers = {}
if token is not None:
headers["Authorization"] = f"Bearer {token}"
if device is not None:
headers["X-Iris-Device"] = device
if extra_headers:
headers.update(extra_headers)
if body is not None:
data = body if isinstance(body, bytes) else body.encode("utf-8")
headers["Content-Type"] = content_type
conn.request(method, path, body=data, headers=headers)
else:
conn.request(method, path, headers=headers)
resp = conn.getresponse()
payload = resp.read()
status = resp.status
conn.close()
return status, payload
def _post_frame(port: int, frame: dict, **kw) -> tuple[int, dict]:
status, payload = _request(port, "POST", "/v1/frame", body=json.dumps(frame), **kw)
return status, json.loads(payload)
def _frame_json(frame: dict) -> dict:
return {"v": 1, **frame}
def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str]], int]:
"""Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count."""
events: list[tuple[str | None, str | None, str]] = []
comments = 0
cur_event: str | None = None
cur_id: str | None = None
cur_data: list[str] = []
for raw in lines:
line = raw.rstrip("\r\n")
if line == "":
if cur_data:
events.append((cur_event, cur_id, "\n".join(cur_data)))
cur_event, cur_id, cur_data = None, None, []
elif line.startswith(":"):
comments += 1
else:
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
cur_event = value
elif field == "id":
cur_id = value
elif field == "data":
cur_data.append(value)
return events, comments
def _sse_open(port: int, *, cursor: int | None = None, last_event_id: str | None = None):
"""Open an SSE connection (blocking); returns the HTTPResponse (read
lines via ``_sse_read_lines``; close with ``resp.close()``)."""
conn = HTTPConnection("127.0.0.1", port, timeout=30)
path = "/v1/events" + (f"?cursor={cursor}" if cursor is not None else "")
headers = {
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
}
if last_event_id is not None:
headers["Last-Event-ID"] = last_event_id
conn.request("GET", path, headers=headers)
resp = conn.getresponse()
assert resp.status == 200, f"SSE open failed: {resp.status}"
assert resp.getheader("Content-Type", "").startswith("text/event-stream")
return resp
def _sse_read_lines(resp, n: int, timeout: float = 10.0) -> list[str]:
"""Read up to n lines from the SSE stream (blocking)."""
raw = resp.fp.raw
sock = getattr(raw, "_sock", None)
if sock is not None:
sock.settimeout(timeout)
lines: list[str] = []
while len(lines) < n:
line = resp.fp.readline()
if not line:
break
lines.append(line.decode("utf-8"))
return lines
# ── /v1/health ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_health_no_auth(gw):
status, payload = await asyncio.to_thread(
_request, http_port(gw), "GET", "/v1/health", token=None, device=None
)
assert status == 200
assert json.loads(payload) == {"ok": True}
@pytest.mark.asyncio
async def test_unknown_path_404(gw):
status, _ = await asyncio.to_thread(_request, http_port(gw), "GET", "/v1/nope")
assert status == 404
# ── Auth ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_post_bad_token_401(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
token="wrong-token",
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
@pytest.mark.asyncio
async def test_post_missing_token_401(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
token=None,
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
@pytest.mark.asyncio
async def test_post_missing_device_401(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
device=None,
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
@pytest.mark.asyncio
async def test_post_allowlist_rejection_401(gw):
gw.allowed_users = ["some-other-device"]
gw.allow_all = False
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
# ── POST /v1/frame: validation ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_post_bad_json_400(gw):
status, payload = await asyncio.to_thread(
_request, http_port(gw), "POST", "/v1/frame", body=b"not json"
)
assert status == 400
frame = json.loads(payload)
assert frame["type"] == "error"
@pytest.mark.asyncio
async def test_post_wrong_content_type_400(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
content_type="text/plain",
)
assert status == 400
@pytest.mark.asyncio
async def test_post_oversize_body_413(gw):
big = json.dumps(_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}}))
status, _ = await asyncio.to_thread(_request, http_port(gw), "POST", "/v1/frame", body=big)
assert status == 413
@pytest.mark.asyncio
async def test_post_empty_message_400(gw):
gw.handle_message = AsyncMock()
status, payload = await asyncio.to_thread(
_post_frame,
http_port(gw),
_frame_json(
{"id": 7, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": " "}}
),
)
assert status == 400
frame = payload
assert frame["type"] == "error"
assert frame["id"] == 7
assert frame["payload"]["code"] == "unsupported"
gw.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_post_automation_channel_400(gw):
gw.handle_message = AsyncMock()
entry = gw._channels.create(name="Cron")
gw._channels.set_automation(entry["chat_id"], True)
status, payload = await asyncio.to_thread(
_post_frame,
http_port(gw),
_frame_json(
{
"id": 8,
"type": "message.send",
"chat_id": entry["chat_id"],
"payload": {"text": "hi"},
}
),
)
assert status == 400
assert payload["type"] == "error"
gw.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_post_rate_limit_429(gw):
gw.handle_message = AsyncMock()
port = http_port(gw)
# Exhaust the per-device bucket (INBOUND_BURST = 40) then expect 429.
got_429 = False
for i in range(60):
status, _ = await asyncio.to_thread(
_post_frame,
port,
_frame_json({"id": i, "type": "ping", "payload": {}}),
)
if status == 429:
got_429 = True
break
assert got_429, "expected a 429 within 60 rapid frames"
# ── POST /v1/frame: dispatch ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_post_message_send_dispatches(gw):
"""202 ack; the user echo + read receipt arrive on the SSE stream; the
agent turn fires (docs/19 §19.7: async responses on the event stream)."""
gw.handle_message = AsyncMock()
port = http_port(gw)
conn = _sse_open(port)
try:
# Consume the open sequence (hello + status = 6 lines) first.
lines = _sse_read_lines(conn, 6, timeout=5)
events, _ = _parse_sse(lines)
assert events[0][0] == "hello"
status, payload = await asyncio.to_thread(
_post_frame,
port,
_frame_json(
{
"id": 42,
"type": "message.send",
"chat_id": CHAT_ID,
"payload": {"text": "hi there"},
}
),
)
# The read receipt (sent when the turn is handed to the agent) is
# the handler's single point-to-point reply -> 200 with the frame
# as the body (a plain 202 {"ok": true} is also valid when no
# synchronous reply exists).
assert status in (200, 202)
if status == 200:
assert payload["type"] == "read.receipt"
else:
assert payload == {"ok": True}
# The echo must arrive on the stream, tagged with its outbox
# cursor as the SSE id (live frames carry no cursor in the
# envelope, same as the WS path).
deadline = time.monotonic() + 10
echo = None
while time.monotonic() < deadline and echo is None:
lines = _sse_read_lines(conn, 4, timeout=5)
for _event, sse_id, data in _parse_sse(lines)[0]:
frame = json.loads(data)
if (
frame.get("type") == "message"
and frame.get("payload", {}).get("text") == "hi there"
):
echo = (frame, sse_id)
assert echo is not None, "user echo did not arrive on the SSE stream"
assert echo[1] is not None # SSE id = outbox cursor
await asyncio.sleep(0.2)
gw.handle_message.assert_called_once()
finally:
conn.close()
# ── SSE: catch-up, hello, live, resume, heartbeat ──────────────────────────
@pytest.mark.asyncio
async def test_sse_catchup_and_hello(gw):
"""Catch-up rows carry correct ids + cursor envelope; hello present."""
port = http_port(gw)
# Park two frames through the real outbound path (no live devices).
push_calls: list = []
gw._maybe_push = AsyncMock(side_effect=lambda *a, **k: push_calls.append(a))
for text in ("one", "two"):
await _park_frame(gw, text)
cursors = [1, 2]
conn = _sse_open(port, cursor=0)
try:
# 2 replayed frames (id + event + data + blank = 4 lines each) +
# hello (3 lines) + status (3 lines) = 14 lines.
lines = _sse_read_lines(conn, 14, timeout=5)
events, _ = _parse_sse(lines)
assert events[0][0] == "frame"
assert events[0][1] == str(cursors[0])
f0 = json.loads(events[0][2])
assert f0["payload"]["text"] == "one"
assert f0["cursor"] == cursors[0]
assert events[1][0] == "frame"
assert events[1][1] == str(cursors[1])
assert json.loads(events[1][2])["payload"]["text"] == "two"
assert events[2][0] == "hello"
hello = json.loads(events[2][2])
assert hello["type"] == "hello.ack"
assert hello["payload"]["sync_cursor"] == 2
assert events[3][0] == "frame"
assert json.loads(events[3][2])["type"] == "status"
finally:
conn.close()
@pytest.mark.asyncio
async def test_sse_live_frame_after_connect(gw):
port = http_port(gw)
conn = _sse_open(port)
try:
# Consume the open sequence (hello + status = 6 lines).
_sse_read_lines(conn, 6, timeout=5)
await _park_frame(gw, "live!")
deadline = time.monotonic() + 10
got = None
while time.monotonic() < deadline and got is None:
lines = _sse_read_lines(conn, 4, timeout=5)
for _event, sse_id, data in _parse_sse(lines)[0]:
frame = json.loads(data)
if frame.get("payload", {}).get("text") == "live!":
got = (frame, sse_id)
assert got is not None, "live frame did not arrive on the SSE stream"
assert got[1] is not None # SSE id = outbox cursor
finally:
conn.close()
@pytest.mark.asyncio
async def test_sse_last_event_id_resume(gw):
"""Resume with Last-Event-ID replays exactly the delta."""
port = http_port(gw)
for text in ("a", "b", "c"):
await _park_frame(gw, text)
conn = _sse_open(port, last_event_id="1")
try:
# Frames 2 and 3 replayed (8 lines) + hello (3) + status (3) = 14.
lines = _sse_read_lines(conn, 14, timeout=5)
events, _ = _parse_sse(lines)
replayed = [e for e in events if e[0] == "frame" and e[1] is not None]
assert [e[1] for e in replayed] == ["2", "3"]
assert json.loads(replayed[0][2])["payload"]["text"] == "b"
assert json.loads(replayed[1][2])["payload"]["text"] == "c"
finally:
conn.close()
@pytest.mark.asyncio
async def test_sse_heartbeat(gw, monkeypatch, plugin):
"""A comment heartbeat is written when the stream is idle."""
monkeypatch.setattr(plugin.http_server, "SSE_HEARTBEAT_S", 1.0)
port = http_port(gw)
conn = _sse_open(port)
try:
# Consume the open sequence (6 lines), then wait for the heartbeat.
_sse_read_lines(conn, 6, timeout=5)
lines = _sse_read_lines(conn, 2, timeout=5)
events, comments = _parse_sse(lines)
assert comments >= 1, f"no heartbeat comment in {lines!r}"
assert events == []
finally:
conn.close()
# ── Long-poll ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_poll_returns_on_new_frame(gw):
port = http_port(gw)
await _park_frame(gw, "pre") # cursor 1: returned immediately (catch-up)
# A second poll at the high-water mark blocks until a new frame lands.
def poll_and_broadcast():
status, payload = _request(port, "GET", "/v1/poll?cursor=1", timeout=30)
return status, json.loads(payload)
async def late_frame():
await asyncio.sleep(0.5)
await _park_frame(gw, "late")
poll_task = asyncio.create_task(asyncio.to_thread(poll_and_broadcast))
late_task = asyncio.create_task(late_frame())
status, body = await asyncio.wait_for(poll_task, timeout=15)
await late_task
assert status == 200
assert body["cursor"] >= 2
assert len(body["frames"]) == 1
assert json.loads(body["frames"][0])["payload"]["text"] == "late"
@pytest.mark.asyncio
async def test_poll_timeout_empty(gw, monkeypatch, plugin):
monkeypatch.setattr(plugin.http_server, "POLL_TIMEOUT_S", 1.0)
port = http_port(gw)
await _park_frame(gw, "x")
hwm = gw._outbox.latest_cursor()
status, payload = await asyncio.to_thread(
_request, port, "GET", f"/v1/poll?cursor={hwm}", timeout=15
)
body = json.loads(payload)
assert status == 200
assert body["frames"] == []
assert body["cursor"] == hwm
# ── Delivery counting (docs/19 §19.8 — the critical regression) ────────────
@pytest.mark.asyncio
async def test_sse_subscriber_counts_as_delivered_no_push(gw):
"""A frame with only an SSE subscriber is delivered >= 1 -> NO push."""
port = http_port(gw)
conn = _sse_open(port)
try:
_sse_read_lines(conn, 6, timeout=5) # open sequence
push = AsyncMock()
gw._maybe_push = push
await _park_frame(gw, "no push for me")
await asyncio.sleep(0.2)
push.assert_not_called()
finally:
conn.close()
@pytest.mark.asyncio
async def test_no_subscribers_still_pushes(gw):
"""Control: with no live devices at all, the push path still fires."""
push = AsyncMock()
gw._maybe_push = push
await _park_frame(gw, "wake me up")
await asyncio.sleep(0.2)
push.assert_called_once()
# ── Media over HTTP (docs/19 §19.15, v2) ──────────────────────────────────
def _upload(
port: int,
data: bytes,
*,
media_ref: str = "mu_http1",
kind: str = "image",
mime: str = "image/png",
filename: str = "t.png",
sha256: str | None = None,
**kw,
) -> tuple[int, dict]:
import hashlib
headers = {
"X-Iris-Media-Ref": media_ref,
"X-Iris-Media-Kind": kind,
"X-Iris-Media-Filename": filename,
"X-Iris-Media-Sha256": sha256 if sha256 is not None else hashlib.sha256(data).hexdigest(),
}
status, payload = _request(
port,
"POST",
"/v1/media",
body=data,
content_type=mime,
extra_headers=headers,
**kw,
)
return status, json.loads(payload)
@pytest.mark.asyncio
async def test_media_upload_ok(gw):
port = http_port(gw)
status, body = await asyncio.to_thread(_upload, port, PNG_1X1)
assert status == 201, body
assert body["type"] == "media.upload.ack"
assert body["payload"]["ok"] is True
assert body["payload"]["media_ref"] == "mu_http1"
entry = gw._media.get_inbound("mu_http1")
assert entry is not None
assert entry.kind == "image"
assert entry.size == len(PNG_1X1)
@pytest.mark.asyncio
async def test_media_upload_sha_mismatch(gw):
port = http_port(gw)
status, body = await asyncio.to_thread(
_upload, port, PNG_1X1, media_ref="mu_badsha", sha256="0" * 64
)
assert status == 500, body # internal: digest mismatch
assert body["type"] == "error"
assert body["payload"]["code"] == "internal"
assert gw._media.get_inbound("mu_badsha") is None
@pytest.mark.asyncio
async def test_media_upload_oversize_413(gw):
port = http_port(gw)
oversize = b"x" * (gw.max_upload_bytes + 1)
status, body = await asyncio.to_thread(_upload, port, oversize, media_ref="mu_big")
assert status == 413, body
assert body["payload"]["code"] == "media_too_large"
@pytest.mark.asyncio
async def test_media_upload_missing_ref_400(gw):
port = http_port(gw)
status, payload = await asyncio.to_thread(
_request,
port,
"POST",
"/v1/media",
body=PNG_1X1,
content_type="image/png",
extra_headers={"X-Iris-Media-Kind": "image"},
)
body = json.loads(payload)
assert status == 400
assert body["payload"]["code"] == "unsupported"
@pytest.mark.asyncio
async def test_media_upload_bad_kind_400(gw):
port = http_port(gw)
status, body = await asyncio.to_thread(_upload, port, PNG_1X1, kind="hologram")
assert status == 400
assert body["payload"]["code"] == "unsupported"
@pytest.mark.asyncio
async def test_media_upload_auth_401(gw):
port = http_port(gw)
status, _ = await asyncio.to_thread(_upload, port, PNG_1X1, token="wrong-token")
assert status == 401
@pytest.mark.asyncio
async def test_media_upload_liar_reclassified(gw):
"""Lies about being a PNG: magic-byte re-sniff keeps it out of the image
cache (lands as a document) — same contract as the WS path."""
port = http_port(gw)
payload = b"<html>not an image</html>"
status, body = await asyncio.to_thread(
_upload, port, payload, media_ref="mu_liar", filename="liar.html"
)
assert status == 201, body
entry = gw._media.get_inbound("mu_liar")
assert entry is not None
assert entry.kind == "document"
@pytest.mark.asyncio
async def test_media_pull_ok(gw):
from gateway.platforms.base import get_image_cache_dir
img = get_image_cache_dir() / "http_pull_test.png"
img.write_bytes(PNG_1X1)
entry = gw._media.register_outbound(
str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1)
)
port = http_port(gw)
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
assert status == 200
assert payload == PNG_1X1
conn = HTTPConnection("127.0.0.1", port, timeout=10)
conn.request(
"GET",
f"/v1/media/{entry.media_id}",
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
)
resp = conn.getresponse()
resp.read()
assert resp.getheader("Content-Type") == "image/png"
assert resp.getheader("Content-Length") == str(len(PNG_1X1))
conn.close()
@pytest.mark.asyncio
async def test_media_pull_unknown_404(gw):
port = http_port(gw)
status, payload = await asyncio.to_thread(_request, port, "GET", "/v1/media/md_nope")
body = json.loads(payload)
assert status == 404
assert body["payload"]["code"] == "not_found"
@pytest.mark.asyncio
async def test_media_pull_denied_path_404(gw):
"""Known id, but the path fails delivery validation (denylist) — same
re-check at pull time as the WS path."""
entry = gw._media.register_outbound("/etc/passwd", "document", "text/plain", "passwd", 100)
port = http_port(gw)
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
body = json.loads(payload)
assert status == 404
assert body["payload"]["code"] == "not_found"
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _park_frame(adapter, text: str) -> int:
"""Emit one message frame through ``_broadcast_or_log`` (the real
outbound path); returns the outbox cursor."""
plugin = _load_plugin()
frame = plugin.protocol.message(
chat_id=CHAT_ID,
message_id=f"m_{abs(hash(text)) % 10**8:08x}",
role="assistant",
text=text,
)
await adapter._broadcast_or_log(CHAT_ID, frame)
return adapter._outbox.latest_cursor()
File diff suppressed because it is too large. Load diff
-444
View File
@@ -1,444 +0,0 @@
"""WebSocket server, connection registry, and frame routing.
Runs on the gateway's asyncio loop (started in ``AndroidAdapter.connect()``).
Uses the ``websockets`` core dep (v15): ``websockets.asyncio.server.serve(
handler, host, port, ssl=ctx)``.
Per-connection handler:
1. Await first frame (bounded); must be ``hello {token, device_id,
device_name, caps, fcm_token?}``. Verify token (constant-time) +
allowlist. On failure: send ``error {code:"auth"}`` and close.
2. On success: register in the device registry (SQLite) + connection
registry (``device_id -> {ws, caps, fcm_token}``), send
``hello.ack {server_caps, sync_cursor, channels[]}``.
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.
Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
model). Heartbeat via WS ping/pong (websockets built-in) + app-level
``ping``/``pong`` frames.
Milestone M1.
"""
import asyncio
import contextlib
import logging
import ssl
import time
from dataclasses import dataclass, field
from typing import Any
from websockets.asyncio.server import ServerConnection, serve
from websockets.exceptions import ConnectionClosed
from . import protocol
from .pairing import DeviceRegistry, verify_token
logger = logging.getLogger(__name__)
# How long a new socket may take to present its ``hello`` before we drop it.
HELLO_TIMEOUT_S = 10.0
# Max time a single outbound send may block on a peer's full write buffer
# before we give up on that peer (so one stalled client can't starve the
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
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_AUTH_FAILED = 4401
CLOSE_REPLACED = 4402
CLOSE_RATE_LIMITED = 4403
CLOSE_SHUTDOWN = 1001
# Max length of a client-supplied device_id.
MAX_DEVICE_ID_LEN = 128
async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None:
"""Shared inbound frame dispatch for the WS and HTTP transports
(docs/19 §19.4). Transport-specific frames (``ping``/``pong``, binary
media chunks) are handled by their own server before this is called;
unknown types are ignored (forward-compat)."""
if frame.type == protocol.TYPE_MESSAGE_SEND:
await adapter.on_message_send(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_CREATE:
await adapter.on_channel_create(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_RENAME:
await adapter.on_channel_rename(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
await adapter.on_channel_set_default(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
await adapter.on_channel_favorite(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_ICON:
await adapter.on_channel_icon(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
await adapter.on_channel_set_automation(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST:
await adapter.on_channel_list(frame, device_id)
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
await adapter.on_commands_catalog(frame, device_id)
elif frame.type == protocol.TYPE_SEARCH:
await adapter.on_search(frame, device_id)
elif frame.type == protocol.TYPE_SYNC:
await adapter.on_sync(frame, device_id)
elif frame.type == protocol.TYPE_HISTORY:
await adapter.on_history(frame, device_id)
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
await adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
await adapter.on_media_upload_start(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
await adapter.on_media_upload_end(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_PULL:
await adapter.on_media_pull(frame, device_id)
elif frame.type == protocol.TYPE_FCM_REGISTER:
await adapter.on_fcm_register(frame, device_id)
# Unknown types are ignored (forward-compat).
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
class DeviceConnection:
"""One live, authenticated device socket."""
device_id: str
device_name: str
ws: ServerConnection
caps: dict[str, Any] = field(default_factory=dict)
fcm_token: str | None = None
ntfy_topic: str | None = None
connected_at: float = field(default_factory=time.time)
rate_bucket: _TokenBucket = field(
default_factory=lambda: _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST)
)
class WsServer:
"""The plugin's WebSocket server + live connection registry."""
def __init__(self, adapter: Any, devices: DeviceRegistry):
self._adapter = adapter
self._devices = devices
self._server: Any | None = None
self._connections: dict[str, DeviceConnection] = {}
self._lock = asyncio.Lock()
# ── Lifecycle ─────────────────────────────────────────────────────────
async def start(self) -> None:
"""Bind and start serving. Raises on bind failure (adapter maps it
to a retryable fatal error)."""
adapter = self._adapter
ssl_ctx: ssl.SSLContext | None = None
if adapter.ws_cert and adapter.ws_key:
try:
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_ctx.load_cert_chain(adapter.ws_cert, adapter.ws_key)
except Exception as e:
adapter._set_fatal_error(
"tls_config", f"WS TLS cert/key invalid: {e}", retryable=False
)
raise
try:
self._server = await serve(
self._handler,
adapter.host,
adapter.port,
ssl=ssl_ctx,
# Media uploads (M4) are chunked binary frames; allow the
# configured max upload size per frame.
max_size=adapter.max_upload_bytes,
# WS-level heartbeat: dead peers are reaped by websockets.
ping_interval=20,
ping_timeout=20,
open_timeout=10,
)
except OSError as e:
adapter._set_fatal_error(
"bind_failed",
f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
retryable=True,
)
raise
scheme = "wss" if ssl_ctx else "ws"
logger.info(
"android: WS server listening on %s://%s:%s/ws",
scheme,
adapter.host,
adapter.port,
)
async def stop(self) -> None:
"""Stop serving and close all device sockets."""
if self._server is not None:
self._server.close()
# Best-effort: the server is already closing; a failure here is
# not actionable (nothing left to clean up besides the registry).
with contextlib.suppress(Exception):
await self._server.wait_closed()
self._server = None
for conn in list(self._connections.values()):
# Best-effort: a socket that is already gone needs no handling.
with contextlib.suppress(Exception):
await conn.ws.close(code=CLOSE_SHUTDOWN, reason="gateway shutting down")
self._connections.clear()
# ── Registry ──────────────────────────────────────────────────────────
@property
def connections(self) -> dict[str, DeviceConnection]:
return dict(self._connections)
def has_devices(self) -> bool:
return bool(self._connections)
def device_ids(self) -> list:
return list(self._connections.keys())
def connection(self, device_id: str) -> DeviceConnection | None:
return self._connections.get(device_id)
# ── Outbound ──────────────────────────────────────────────────────────
async def broadcast(self, frame: protocol.Frame) -> int:
"""Send a frame to every connected device. Returns devices reached.
Best-effort: a dead or stalled socket is skipped (deregistered on its
own close) so one slow peer can't starve the others."""
data = frame.to_json()
sent = 0
for conn in list(self._connections.values()):
# Best-effort: a dead or stalled socket is skipped (it is
# deregistered on its own close); one slow peer must not starve
# the rest of the broadcast.
with contextlib.suppress(Exception):
await asyncio.wait_for(conn.ws.send(data), timeout=SEND_TIMEOUT_S)
sent += 1
return sent
async def send_to(self, device_id: str, frame: protocol.Frame) -> bool:
"""Send a frame to one device (request responses / errors)."""
conn = self._connections.get(device_id)
if conn is None:
return False
try:
await asyncio.wait_for(conn.ws.send(frame.to_json()), timeout=SEND_TIMEOUT_S)
return True
except Exception:
return False
# ── Per-connection handler ────────────────────────────────────────────
async def _handler(self, ws: ServerConnection) -> None:
# 1. hello auth -----------------------------------------------------
try:
raw = await asyncio.wait_for(ws.recv(), timeout=HELLO_TIMEOUT_S)
except (asyncio.TimeoutError, ConnectionClosed) as e:
if isinstance(e, asyncio.TimeoutError):
logger.warning("android: dropping socket with no hello (timeout)")
await self._close_quiet(ws, 1000, "no hello")
# A peer that vanished before hello needs no further handling.
return
frame = protocol.Frame.from_json(raw)
if frame is None or frame.type != protocol.TYPE_HELLO:
await self._reject(ws, "first frame must be hello")
return
payload = frame.payload
if not verify_token(payload.get("token"), self._adapter.token):
peer = getattr(ws, "remote_address", None)
logger.warning("android: hello rejected: invalid token (peer=%s)", peer)
await self._reject(ws, "invalid token")
return
device_id = str(payload.get("device_id") or "").strip()
if not device_id or len(device_id) > MAX_DEVICE_ID_LEN:
await self._reject(ws, "device_id required")
return
if (
not self._adapter.allow_all
and self._adapter.allowed_users
and device_id not in self._adapter.allowed_users
):
logger.warning("android: hello rejected: device %s not allowlisted", device_id)
await self._reject(ws, "device not allowed")
return
device_name = str(payload.get("device_name") or device_id)[:120]
caps = payload.get("caps")
if not isinstance(caps, dict):
caps = {}
fcm_token = payload.get("fcm_token")
if not isinstance(fcm_token, str):
fcm_token = None
ntfy_topic = payload.get("ntfy_topic")
if not isinstance(ntfy_topic, str):
ntfy_topic = None
# 2. register --------------------------------------------------------
try:
self._devices.upsert(device_id, device_name, caps, fcm_token, ntfy_topic)
except Exception:
logger.warning("android: device registry upsert failed", exc_info=True)
conn = DeviceConnection(
device_id=device_id,
device_name=device_name,
ws=ws,
caps=caps,
fcm_token=fcm_token,
ntfy_topic=ntfy_topic,
)
async with self._lock:
old = self._connections.pop(device_id, None)
self._connections[device_id] = conn
if old is not None:
# Same device re-paired from a new socket: the new one wins.
# Best-effort close of the superseded socket.
with contextlib.suppress(Exception):
await old.ws.close(code=CLOSE_REPLACED, reason="replaced by newer connection")
ack = protocol.hello_ack(
server_caps=self._adapter.server_caps(),
sync_cursor=self._adapter._outbox.latest_cursor(),
channels=self._adapter.channel_list(),
# M5: lets the app dedupe sync-replayed notifications that
# already woke this device via push (docs/08 §8.7).
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
)
try:
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:
return
logger.info("android: device paired: %s (%s)", device_name, device_id)
# 3. frame loop ------------------------------------------------------
try:
async for raw in ws:
# ``_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 Exception as e:
# A clean disconnect (ConnectionClosed) is the normal path and is
# not worth a warning; anything else is unexpected.
if not isinstance(e, ConnectionClosed):
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
finally:
async with self._lock:
current = self._connections.get(device_id)
if current is not None and current.ws is ws:
self._connections.pop(device_id, None)
# M4: drop in-flight upload temp files for this socket.
try:
self._adapter.on_connection_closed(device_id)
except Exception:
logger.warning(
"android: connection cleanup failed for %s", device_id, exc_info=True
)
logger.info("android: device disconnected: %s", device_id)
# ── Inbound dispatch ──────────────────────────────────────────────────
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
# 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)):
await self._adapter.on_media_chunk(device_id, bytes(raw))
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)
if frame is None:
return True # malformed JSON: ignore (forward-compat)
if frame.type == protocol.TYPE_PING:
ts = frame.payload.get("ts")
await self._send_quiet(ws, protocol.pong(ts if isinstance(ts, int) else None))
return True
await dispatch_frame(self._adapter, frame, device_id)
return True
# ── Helpers ───────────────────────────────────────────────────────────
def _connection_for(self, ws: ServerConnection) -> DeviceConnection | None:
"""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:
# "Quiet" by contract: the caller does not care whether the peer was
# still there (e.g. an error frame right before the close).
with contextlib.suppress(Exception):
await ws.send(frame.to_json())
async def _reject(self, ws: ServerConnection, reason: str) -> None:
await self._send_quiet(ws, protocol.error(protocol.ERR_AUTH, reason))
await self._close_quiet(ws, CLOSE_AUTH_FAILED, "auth failed")
async def _close_quiet(self, ws: ServerConnection, code: int, reason: str) -> None:
# "Quiet" by contract: closing an already-closed socket is a no-op.
with contextlib.suppress(Exception):
await ws.close(code=code, reason=reason)