Compare commits

..
4 Commits
Author SHA1 Message Date
ARIA e6015033b6 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
2026-08-22 20:10:05 +02:00
ARIA 2349a95dd4 HTTP fallback leg (docs/19): e2e scenario 13 + docs
- e2e.py: scenario 13 (http fallback) — drives a full turn over the
  HTTP leg (health + POST /v1/frame + SSE /v1/events, no WS) and
  asserts the user echo lands on the SSE stream in < 1.5 s.
- ws_probe.py --http: prints '== user echo in X.XXs' (the docs/19
  sendable-in-fallback timing assertion) alongside the existing
  health/POST/SSE output; same assertion flags as the WS leg.
- docs: 19 status flipped to implemented; 09-pairing-security §9.4
  cross-reference (second door, same lock: token + device allowlist,
  64 KiB cap, rate limit, optional TLS, unauthenticated /v1/health);
  13-testing manual scenario 15 + automated pointers.
2026-08-22 14:34:14 +02:00
ARIA 7f936fa596 HTTP fallback leg (docs/19, app): State.HttpFallback + SSE/long-poll receive
When the WS is down but the gateway is reachable over HTTP, the app
stays sendable instead of waiting out the WS redial backoff:

- HttpGateway.kt: OkHttp client for the HTTP leg — GET /v1/health
  (2 s probe), POST /v1/frame (accept-and-ack; 4xx error frames parsed),
  SSE GET /v1/events (hand-rolled line parser: event/id/data, comments,
  multi-line data, Last-Event-ID bookkeeping), long-poll GET /v1/poll.
  Per-purpose call timeouts (SSE heartbeat 15 s / poll hold 25 s exceed
  OkHttp's 10 s default read timeout). deriveHttpUrl: ws(s)://host:port
  -> http(s)://host:8791 (pure, unit-tested).
- GatewayClient.kt: new State.HttpFallback (sibling of Connected, both
  implement State.HelloInfo). connectLoop races the WS dial against the
  health probe (sendable in < 1 s on a dead WS port); on WS loss it
  enters fallback immediately (no backoff gate on the send path); on WS
  reconnect it stops the HTTP leg (media available again). sendMessage/
  sendFrame route to POST in fallback (mediaRefs dropped — media is
  WS-only in v1); SSE frames feed the same _events flow, so request-id
  correlation is unchanged. The SSE hello is treated like hello.ack
  (caps/channels/lastPushedCursor + onHelloAck fast path). After two
  consecutive SSE open failures the receive loop switches to long-poll
  until the next full (re)connect.
- IrisController.kt: onHelloAck/onConnectedLane take State.HelloInfo;
  state collector + deep-link/commands-catalog gates accept fallback.
- ChatScreen.kt: send gate accepts fallback; attach button disabled in
  fallback (media needs the live connection); status pill shows
  'connected · http' (green).
- Tests: HttpGatewayTest (URL derivation + SSE parser, 8 tests); full
  :shared desktop + android-host suites green.
2026-08-22 14:31:46 +02:00
ARIA e5c7d690b8 HTTP fallback leg (docs/19): POST /v1/frame + SSE /v1/events + long-poll
Second, short-lived-connection transport next to the WS: same frames,
same outbox/cursor, same token, served over plain HTTP (stdlib
ThreadingHTTPServer bridged into the asyncio loop; zero new deps).

- http_server.py: /v1/health (unauthenticated), POST /v1/frame
  (accept-and-ack; validation rejections as 4xx error frames), SSE
  /v1/events (outbox catch-up with id=cursor, event: hello, 15s
  heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold).
  Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body
  cap, per-device rate limit, optional TLS, non-fatal bind failure.
- ws_server.py: inbound dispatch chain extracted to shared
  dispatch_frame() used by both transports.
- adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the
  WS; delivery counting in _broadcast_or_log (an SSE subscriber is a
  live subscriber -> no push, docs/19 19.8); _reply() routes
  point-to-point replies into the in-flight HTTP response (reply sink)
  or broadcasts when the device has no live WS (19.7); status/typing/
  channel events fan out to both transports.
- ws_probe.py: --http mode (health + POST + SSE turn drive, same
  assertion flags); tests/README updated.
- Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests,
  incl. the 19.8 delivery-counting regression); test_android.py (74)
  still green.
2026-08-22 14:10:14 +02:00
28 changed files with 4449 additions and 2101 deletions

No files matched your search

@@ -49,6 +49,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
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() {
@@ -12,7 +12,7 @@ import iris.net.GatewayClient
* - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token).
* - [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.
*
* 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.
*/
class IrisFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
val store = AndroidSecureStore(applicationContext)
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(
iris.protocol.fcmRegisterFrame(fcmToken = token),
iris.protocol.fcmRegisterFrame(
fcmToken = token,
ntfyTopic = store.ntfyTopic.ifBlank { null },
),
)
}
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
// 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
// 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`
// payload on our behalf (the data payload only carries sync
// metadata). Posting again would show a second notification with a
@@ -55,4 +64,4 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
}
IrisNotifications.post(applicationContext, chatId, null, title, body, threadId)
}
}
}
@@ -249,10 +249,14 @@ class ChatStore {
)
list.toMutableList().also { it[byId] = updated }
} 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 =
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) {
list.toMutableList().also {
@@ -315,7 +319,8 @@ class ChatStore {
val flatList = map[flatLane].orEmpty()
val idx =
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
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
@@ -613,6 +618,31 @@ class ChatStore {
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
* server is authoritative: the frame carries no lane, and a message id is
@@ -713,9 +743,24 @@ class ChatStore {
) {
updateLane(lane) { list ->
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 =
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,
// 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).
*/
interface SecureStore {
/** ws(s)://host:port/ws */
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
var serverUrl: String
/** ANDROID_TOKEN presented in the hello frame. */
/** ANDROID_TOKEN presented in the auth header. */
var token: String
/** Stable app-generated device id (persisted). */
@@ -1,33 +1,19 @@
package iris.net
import iris.data.SecureStore
import iris.media.FileSource
import iris.media.Sha256
import iris.protocol.ChannelInfo
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaPullEndPayload
import iris.protocol.MediaUploadAckPayload
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.pingFrame
import iris.protocol.syncFrame
import iris.util.IrisLog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -43,25 +29,19 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
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 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
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
* - events: server frames (minus hello.ack) on [events]
* - request/response correlation by id (M2+ consumers)
* - events: server frames on [events]
* - request/response correlation by id
*/
class GatewayClient(
private val scope: CoroutineScope,
@@ -105,41 +85,42 @@ class GatewayClient(
.build()
private var connectJob: Job? = null
private var socket: WebSocket? = null
private var nextRequestId = 1
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
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// 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
}
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
// the resume cursor (SSE id / outbox high-water mark).
private var http: HttpGateway? = null
private var httpCursor: Long = 0
private var sseFailures = 0
private var usingLongPoll = false
private var binarySession: BinarySession? = null
// Last hello.ack payload — used to restore State.Connected after a
// reconnect state race in the connect loop.
private var lastAck: HelloAckPayload? = null
/**
* Fired promptly (on the WS thread) the moment `hello.ack` is received —
* on 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
* (Dispatchers.Default) and would push a history request past a flaky
* network's window. Set before [start].
* Fired promptly the moment the SSE hello (hello.ack) is received — on
* 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 (Dispatchers.Default) and would push a history request past a
* flaky network's window. Set before [start].
*/
var onHelloAck: ((State.Connected) -> Unit)? = null
// M4: only one pull may be in flight at a time (binarySession is a single
// slot). Serialize concurrent offers so their byte streams don't interleave.
// Only one pull may be in flight at a time. Serialize concurrent offers so
// their byte streams don't interleave.
private val pullMutex = Mutex()
// ── Lifecycle ─────────────────────────────────────────────────────────
@@ -152,15 +133,25 @@ class GatewayClient(
connectJob = scope.launch { connectLoop() }
}
/** Stop the connect loop and close the socket. */
/** Stop the connect loop. */
fun stop() {
connectJob?.cancel()
connectJob = null
socket?.close(1000, "client shutdown")
socket = null
_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). */
fun restart() {
stop()
@@ -176,217 +167,221 @@ class GatewayClient(
return
}
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
val dial = dial(url, token)
when (val result = dial.result) {
is DialResult.AuthFailed -> {
_state.value = State.AuthFailed(result.message)
dial.socket.close(1000, "auth failed")
return
}
DialResult.Connected -> {
hasConnected = true
attempt = 0
lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await()
if (!currentCoroutineContext().isActive) return
// socket dropped -> loop again (Reconnecting)
}
is DialResult.Failed -> {
attempt++
delay(backoffMs(attempt))
val gw = httpGateway() ?: continue
// Health probe: if the gateway is alive, open the SSE receive loop
// (which delivers the hello.ack). Otherwise back off and retry.
val healthOk =
try {
gw.health()
} catch (e: Exception) {
false
}
if (!healthOk) {
attempt++
backoffOrWake(backoffMs(attempt))
continue
}
}
}
// ── 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
}
}
}
}
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
}
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()
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)
receiveJob.join()
}
// Terminal auth failure: don't redial with the same bad token.
if (_state.value is State.AuthFailed) return
}
}
// ── HTTP receive leg ──────────────────────────────────────────────────
/** Lazily build the HTTP client from the stored URL. */
private fun httpGateway(): HttpGateway? {
val url = store.serverUrl.trim()
val token = store.token
if (url.isBlank() || token.isBlank()) return null
return http
?: HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
token,
store.deviceId,
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
).also { http = it }
}
/**
* The HTTP receive loop: SSE by default; after two consecutive SSE open
* failures (buffering proxy) it switches to long-poll until the next full
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
*/
private suspend fun httpReceiveLoop(gw: HttpGateway) {
var backoff = 1_000L
while (currentCoroutineContext().isActive) {
if (usingLongPoll) {
try {
val res = gw.poll(httpCursor)
res.frames.forEach { emitHttpFrame(it) }
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) {
markStreamLost()
IrisLog.w("http poll failed: ${e.message}")
delay(backoff)
backoff = minOf(backoff * 2, 15_000)
}
} else {
try {
gw.events(
cursor = httpCursor,
onHello = { onHttpHello(it) },
onFrame = { emitHttpFrame(it) },
onCursor = { if (it > httpCursor) httpCursor = it },
)
// Clean EOF: reconnect immediately.
backoff = 1_000L
} catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return
} catch (e: Exception) {
sseFailures++
markStreamLost()
if (sseFailures >= 2) {
// SSE seems blocked: switch to long-poll.
usingLongPoll = true
continue
}
IrisLog.w("sse read failed: ${e.message}")
delay(backoff)
backoff = minOf(backoff * 2, 15_000)
}
}
}
authError.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
}
/** The SSE `event: hello` (the HTTP hello.ack). */
private fun onHttpHello(ack: HelloAckPayload) {
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++
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
}
fail.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
// Prompt fast path (before the possibly-starved state collector).
onHelloAck?.invoke(connected)
}
/** 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) {
_events.tryEmit(frame)
frame.id?.let { id ->
pending[id]?.complete(frame)
}
val result =
withTimeoutOrNull(15_000) { winner.await() }
?: DialResult.Failed("timeout waiting for hello.ack")
return Dial(result, ws, closed)
}
// ── Outbound ──────────────────────────────────────────────────────────
/** 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
* (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(
chatId: String,
text: String,
threadId: String? = null,
mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
onResult: ((Int) -> Unit)? = null,
) {
val ws = socket ?: return
if (_state.value !is State.Connected) {
onResult?.invoke(0)
return
}
val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
scope.launch {
val res =
httpGateway()?.postFrame(
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,
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's
* media_ref (for message.send media_refs) on success.
* Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
* Returns the server's media_ref (for message.send media_refs) on success.
*/
suspend fun uploadMedia(
path: String,
@@ -395,190 +390,132 @@ class GatewayClient(
filename: String,
mediaRef: String,
): Result<String> {
val ws = socket ?: return Result.failure(IllegalStateException("not connected"))
val source = FileSource(path)
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)
}
val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
return http.uploadMedia(path, mime, kind, filename, mediaRef)
}
/**
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until
* media.pull.end. Each chunk is handed to [onChunk] (write to cache).
* Pull offered media via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
* chunk is handed to [onChunk] (write to cache).
*/
suspend fun pullMedia(
mediaId: String,
onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> =
pullMutex.withLock {
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected"))
val id = nextRequestId++
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
}
val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
}
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 PULL_TIMEOUT_MS = 300_000L
}
/**
* Send an arbitrary frame with a fresh request id (fire-and-forget).
* The server replies (or broadcasts) a frame carrying the same id; the
* app reconciles from [events]. Returns the id used, or -1 if not connected.
* Send an arbitrary frame with a fresh request id (fire-and-forget). The
* server replies (or broadcasts) a frame carrying the same id; the app
* reconciles from [events]. Returns the id used, or -1 if not connected.
*/
fun sendFrame(frame: Frame): Int {
val ws = socket ?: return -1
if (_state.value !is State.Connected) return -1
val id = nextRequestId++
ws.send(frame.copy(id = id).toWire())
return id
}
/** 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")
scope.launch {
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
}
// ── One-shot hello test (Connect screen) ──────────────────────────────
/**
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
* Exercises the auth leg, not just TCP (docs/10 §10.8).
* Real connection test: health probe + SSE open. The auth leg is proven
* 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(
url: String,
token: String,
): Result<Unit> {
val dial = dial(url, token)
return when (val result = dial.result) {
DialResult.Connected -> {
dial.socket.close(1000, "test complete")
Result.success(Unit)
}
is DialResult.AuthFailed -> {
Result.failure(IllegalStateException(result.message))
}
is DialResult.Failed -> {
Result.failure(IllegalStateException(result.message))
}
}
}
// ── Heartbeat job ─────────────────────────────────────────────────────
fun startHeartbeat() {
scope.launch {
while (isActive) {
delay(20_000)
if (_state.value is State.Connected) {
ping()
reapStale()
val gw =
HttpGateway(
client,
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)
}
} catch (e: HttpGateway.HttpAuthException) {
Result.failure(IllegalStateException("unauthorized — check the pairing token"))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(IllegalStateException("connection failed: ${e.message}"))
} finally {
job.cancel()
}
}
} catch (e: Exception) {
Result.failure(e)
}
}
// ── 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 {
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500)
}
}
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
@@ -0,0 +1,477 @@
package iris.net
import iris.media.Sha256
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaUploadAckPayload
import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* HTTP transport client (docs/19) — the only transport.
*
* The app sends over `POST /v1/frame` and receives over SSE
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
* media travels via `POST/GET /v1/media`.
*
* [events] is ONE SSE connection attempt (blocking read on
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
* outbox cursor, so resume is just the last seen id).
*/
class HttpGateway(
private val client: OkHttpClient,
private val baseUrl: String,
private val token: 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
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
// with extended call timeouts (see the *Client() helpers below).
private val healthClient: OkHttpClient = client.healthClient()
private val streamClient: OkHttpClient = client.streamClient()
private val pollClient: OkHttpClient = client.pollClient()
private val mediaClient: OkHttpClient = client.mediaClient()
/** 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
* 202 accept-and-ack. */
data class PostResult(
val ok: Boolean,
val status: Int,
val frame: Frame?,
)
/** Long-poll result: new high-water [cursor] + frames with cursor >
* the requested one (may be empty at timeout). */
data class PollResult(
val cursor: Long,
val frames: List<Frame>,
)
companion object {
val JSON = "application/json".toMediaType()
/** Default port of the gateway's HTTP leg (WS default is 8790). */
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):
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
* a different service, so the port is always replaced with the
* HTTP leg's default. Pure function (unit-tested).
*/
fun deriveHttpUrl(wsUrl: String): String {
val u = wsUrl.trim()
val (scheme, rest) =
when {
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
else -> return u // already http(s)
}
val authority = rest.substringBefore('/')
val host = authority.substringBefore(':')
return "$scheme://$host:$DEFAULT_PORT"
}
}
private fun authHeaders(): Headers {
val b =
Headers
.Builder()
.add("Authorization", "Bearer $token")
.add("X-Iris-Device", deviceId)
// 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. */
suspend fun health(): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/health")
.build()
healthClient
.newCall(request)
.execute()
.use { response ->
response.body?.close()
response.code == 200
}
}
/**
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
* (with the synchronous reply frame when the handler sent one); 4xx ->
* 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 =
withContext(Dispatchers.IO) {
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
val request =
Request
.Builder()
.url("$baseUrl/v1/frame")
.headers(authHeaders())
.post(wire.toRequestBody(JSON))
.build()
try {
client
.newCall(request)
.execute()
.use { response ->
val body = response.body?.string().orEmpty()
val parsed = parseFrame(body)
PostResult(response.isSuccessful, response.code, parsed)
}
} catch (e: Exception) {
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
PostResult(ok = false, status = 0, frame = null)
}
}
/**
* One SSE connection attempt: outbox catch-up from [cursor], then live
* 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
* cursor) when present. Returns on clean EOF; throws [IOException] on
* open/read failure. Callbacks run on the IO thread.
*/
suspend fun events(
cursor: Long,
onOpen: (() -> Unit)? = null,
onHello: (HelloAckPayload) -> Unit,
onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit,
) {
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/events?cursor=$cursor")
.headers(authHeaders())
.build()
streamClient
.newCall(request)
.execute()
.use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) {
throw IOException("SSE open failed: HTTP ${response.code}")
}
onOpen?.invoke()
val source = response.body?.source() ?: throw IOException("empty SSE body")
var eventId: String? = null
val dataLines = mutableListOf<String>()
while (true) {
val line = source.readUtf8Line() ?: break // EOF
when {
line.isEmpty() -> {
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n")
try {
val frame =
IrisJson.instance.decodeFromString(
Frame.serializer(),
data,
)
when (eventId) {
"hello" -> {
onHello(
frame.payloadAs<HelloAckPayload>()
?: HelloAckPayload(),
)
}
else -> {
onFrame(frame)
}
}
} catch (e: Exception) {
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
}
}
eventId = null
dataLines.clear()
}
line.startsWith(":") -> {
Unit
}
// heartbeat comment
line.startsWith("id:") -> {
line
.removePrefix("id:")
.trim()
.toLongOrNull()
?.let(onCursor)
}
line.startsWith("event:") -> {
eventId = line.removePrefix("event:").trim()
}
line.startsWith("data:") -> {
dataLines.add(line.removePrefix("data:").removePrefix(" "))
}
}
}
}
}
}
/**
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
* Returns the new high-water cursor + any frames with cursor > [cursor].
*/
suspend fun poll(cursor: Long): PollResult =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/poll?cursor=$cursor")
.headers(authHeaders())
.build()
pollClient
.newCall(request)
.execute()
.use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) {
throw IOException("poll failed: HTTP ${response.code}")
}
val body = response.body?.string().orEmpty()
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
val frames =
obj["frames"]
?.jsonArray
?.mapNotNull { el ->
try {
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
} catch (e: Exception) {
null
}
}.orEmpty()
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)
}
}
}
/**
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
* (25 s) — it would kill both receive paths while they are simply waiting
* 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).
*/
/** 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 =
newBuilder()
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(45_000, TimeUnit.MILLISECONDS)
.build()
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
* hold + 15 s margin. */
internal fun OkHttpClient.pollClient(): OkHttpClient =
newBuilder()
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(40_000, TimeUnit.MILLISECONDS)
.build()
internal fun OkHttpClient.healthClient(): OkHttpClient =
newBuilder()
.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()
@@ -30,13 +30,10 @@ object IrisJson {
// ── Frame type constants ────────────────────────────────────────────────
const val TYPE_HELLO = "hello"
const val TYPE_HELLO_ACK = "hello.ack"
const val TYPE_MESSAGE = "message"
const val TYPE_MESSAGE_SEND = "message.send"
const val TYPE_ERROR = "error"
const val TYPE_PING = "ping"
const val TYPE_PONG = "pong"
const val TYPE_TYPING = "typing"
// 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_COMMENTARY = "commentary"
// M4 — media (upload / offer / pull)
const val TYPE_MEDIA_UPLOAD_START = "media.upload.start"
const val TYPE_MEDIA_UPLOAD_END = "media.upload.end"
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
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
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) ───────────────────────────────────────────
@Serializable
@@ -241,21 +222,6 @@ data class MediaRef(
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
data class MediaUploadAckPayload(
val ok: Boolean,
@@ -272,16 +238,6 @@ data class MediaOfferPayload(
@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) ────────────────────────────────
@Serializable
@@ -353,7 +309,7 @@ data class MessageSendPayload(
@SerialName("auto_thread") val autoThread: Boolean = false,
)
// ── typing / error / ping ───────────────────────────────────────────────
// ── typing / error ──────────────────────────────────────────────────────
@Serializable
data class TypingPayload(
@@ -366,11 +322,6 @@ data class ErrorPayload(
val message: String,
)
@Serializable
data class PingPayload(
val ts: Long? = null,
)
// ── M3: channel directory (app -> server requests) ──────────────────────
@Serializable
@@ -548,28 +499,6 @@ data class StatusPayload(
// ── 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(
id: Int,
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 ───────────────────────────────────────────────────
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 ───────────────────────────────────────────────────
fun fcmRegisterFrame(
@@ -28,6 +28,7 @@ import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.ReadReceiptPayload
import iris.protocol.SearchHit
import iris.protocol.SearchResultsPayload
@@ -605,6 +606,10 @@ class IrisController(
// stays unmarked and the next (re)connect
// retries it.
historyLoaded.add(lane)
// Offline sends that never arrived go out
// now (delivered duplicates were dropped by
// loadHistory's dedupe above).
reconcileFailedSends(lane)
}
}
}
@@ -689,7 +694,7 @@ class IrisController(
// follows on hello.ack and re-asserts the truth).
_gatewayStatus.value = "online"
// 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.
// M5: refresh the push-dedupe watermark (docs/08 §8.7).
lastPushedCursor = s.lastPushedCursor
@@ -729,17 +734,21 @@ class IrisController(
if (store.ntfyTopic.isBlank()) {
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 ->
try {
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) {
// 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")
}
}
@@ -932,11 +941,36 @@ class IrisController(
localPath = it.path,
)
}
chat.addPending(trimmed, lane, media)
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId))
val messageId = chat.addPending(trimmed, lane, media)
client.sendMessage(
chatId,
trimmed,
threadId,
refs,
autoThread = wantsAutoThread(trimmed, threadId, chatId),
onResult = { status -> onSendResult(messageId, status) },
)
_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
* default channel's flat lane gets its own fresh thread, AI-named by the
* gateway (Telegram topic-mode workflow). Threading is only active on
@@ -966,9 +1000,52 @@ class IrisController(
threadId,
item.media.map { it.mediaId },
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 server removes them from the outbox and broadcasts
* `message.deleted`; the local cache drops them on that frame (or
@@ -245,9 +245,8 @@ fun ChatScreen(controller: IrisController) {
val nonThreadChannels = channels.filter { it.kind != "thread" }
fun doSend() {
// No-op while the socket is down (sendMessage drops silently); the send
// button is disabled in that state, this guards the IME "Send" action.
if (state !is GatewayClient.State.Connected) return
// Offline sends are allowed: the bubble stays queued (Pending) and is
// auto-resent on the next (re)connect (IrisController.onSendResult).
val ready = attachments.filter { it.mediaRef != null && it.error == null }
if (input.isBlank() && ready.isEmpty()) return
val text = input
@@ -739,12 +738,15 @@ fun ChatScreen(controller: IrisController) {
)
}
// Composer (M7: rounded pill + accent circular send button). Sending is gated
// on a live socket: sendMessage is a no-op while disconnected, so an
// ungated send would show a pending bubble that never resolves.
val isConnected = state is GatewayClient.State.Connected
val canSend =
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
// Composer (M7: rounded pill + accent circular send button). Sending is
// NOT gated on a live connection: an offline send stays queued
// (Pending) and is auto-resent on the next (re)connect (see
// IrisController.onSendResult / reconcileFailedSends). The
// automation lane stays read-only regardless.
// docs/19 §19.15 (v2): media works over the HTTP leg too, so
// attachments are sendable in both connected states (uploading
// still needs a live connection — the picker stays gated).
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) }
if (isAutomation) {
@@ -818,7 +820,7 @@ fun ChatScreen(controller: IrisController) {
)
}
}
IconButton(onClick = { showPicker = true }) {
IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) {
Text("📎", fontSize = 18.sp)
}
}
@@ -2119,7 +2121,8 @@ private fun StatusBubble(
) {
val (color, pulsing) =
when (state) {
is GatewayClient.State.Connected -> IrisColors.statusGreen to false
is GatewayClient.State.Connected,
-> IrisColors.statusGreen to false
GatewayClient.State.Connecting,
GatewayClient.State.Reconnecting,
@@ -44,18 +44,18 @@ fun ConnectScreen(
) {
val scope = rememberCoroutineScope()
// 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.
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
// the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
@@ -69,19 +69,19 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(32.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface)
.padding(16.dp),
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface)
.padding(16.dp),
) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
// Example LAN URL; wss:// works too for TLS gateways.
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
placeholder = { Text("ws://192.168.1.10:8790/ws") },
// Example LAN URL; https:// works too for TLS gateways.
placeholder = { Text("http://192.168.1.10:8791") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
@@ -136,4 +136,3 @@ fun ConnectScreen(
)
}
}
@@ -0,0 +1,203 @@
package iris.net
import iris.protocol.Frame
import iris.protocol.IrisJson
import iris.protocol.MessagePayload
import iris.protocol.TYPE_MESSAGE
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* docs/19: unit tests for the HTTP fallback leg client.
*
* The SSE line parser is the trickiest pure logic (event/id/data fields,
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
* pure function (unit-tested). Full transport behavior (health, POST, SSE
* catch-up, long-poll, delivery counting) is covered by the gateway-side
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
*/
class HttpGatewayTest {
// ── URL derivation ────────────────────────────────────────────────────
@Test
fun deriveHttpUrlReplacesSchemeAndPort() {
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
// No explicit port on the WS URL: still the HTTP leg's default port.
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
}
@Test
fun deriveHttpUrlPassesThroughHttpUrls() {
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
}
// ── SSE parser ────────────────────────────────────────────────────────
@Test
fun sseParsesHelloAndFrames() {
val parser = SseParser()
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
val lines =
listOf(
"event: hello",
"data: $hello",
"",
"id: 7",
"event: frame",
"data: $frame",
"",
)
var helloCount = 0
var frameCount = 0
var lastCursor: Long? = null
for (line in lines) {
parser.feed(
line,
onHello = { helloCount++ },
onFrame = { frameCount++ },
onCursor = { lastCursor = it },
)
}
assertEquals(1, helloCount)
assertEquals(1, frameCount)
assertEquals(7L, lastCursor)
}
@Test
fun sseIgnoresHeartbeatComments() {
val parser = SseParser()
var frames = 0
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
assertEquals(0, frames)
}
@Test
fun sseMultiLineDataJoinsWithNewline() {
val parser = SseParser()
var frame: Frame? = null
// SSE data may span multiple `data:` lines; the parser must join
// them with "\n" so the reassembled JSON still decodes. Split at a
// legal JSON whitespace point (right after a comma, between tokens).
val full =
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
val l1 = full.substring(0, cut)
val l2 = full.substring(cut)
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
assertEquals("a\nb", frame?.payloadAsText())
}
@Test
fun sseTracksLastEventIdAcrossFrames() {
val parser = SseParser()
val ids = mutableListOf<Long>()
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
for (id in listOf(1L, 2L, 3L)) {
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
}
assertEquals(listOf(1L, 2L, 3L), ids)
assertEquals(3L, parser.lastEventId)
}
@Test
fun sseMalformedLineDoesNotThrow() {
val parser = SseParser()
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
// No exception, no frame emitted for the malformed data.
}
@Test
fun sseDecodesFramePayload() {
val parser = SseParser()
var text: String? = null
val frame =
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
assertEquals("hello", text)
}
}
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
/**
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
* terminators); a blank line dispatches the buffered event. [lastEventId]
* is the most recent `id:` field (the outbox cursor) — the resume point for
* a reconnect.
*/
class SseParser {
var lastEventId: Long? = null
private set
private var eventId: String? = null
private val dataLines = mutableListOf<String>()
fun feed(
line: String,
onHello: (String) -> Unit,
onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit,
) {
when {
line.isEmpty() -> {
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n")
val frame =
try {
IrisJson.instance.decodeFromString(Frame.serializer(), data)
} catch (e: Exception) {
null
}
if (frame != null) {
when (eventId) {
"hello" -> onHello(data)
else -> onFrame(frame)
}
}
}
eventId = null
dataLines.clear()
}
line.startsWith(":") -> {
Unit
}
// comment / heartbeat
line.startsWith("id:") -> {
val id = line.removePrefix("id:").trim().toLongOrNull()
if (id != null) {
lastEventId = id
onCursor(id)
}
}
line.startsWith("event:") -> {
eventId = line.removePrefix("event:").trim()
}
line.startsWith("data:") -> {
dataLines.add(line.removePrefix("data:").removePrefix(" "))
}
}
}
}
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text
+32 -28
View File
@@ -1,12 +1,15 @@
# 07 — Media (upload, download, playback)
Media travels **over the WebSocket** as chunked binary frames (decision: no
separate HTTP server; keeps the plugin to `websockets` only). Both directions
use the same chunking.
Media travels **over HTTP** (`POST /v1/media` for upload,
`GET /v1/media/{id}` for pull; see `19-http-fallback-transport.md` §19.15).
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
`kind` ∈ `image | audio | video | document | voice`.
- `image` — `image/*` (jpg/png/webp/gif/heic).
- `audio` — `audio/*` (mp3/m4a/ogg/…) — music.
- `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`
**Flow:**
1. App picks a file (SAF) → reads size + MIME.
2. App sends `media.upload.start {media_ref, kind, mime, size, filename}`.
3. App streams the file as **binary WS frames** (e.g. 256 KiB chunks).
4. App sends `media.upload.end {media_ref, sha256}`.
5. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
1. App picks a file (SAF) → reads size + MIME, computes sha256.
2. App `POST /v1/media` with the raw file body; metadata in
`X-Iris-Media-*` headers (`media_ref`, `kind`, `mime`, `filename`,
`sha256`).
3. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
cache via hermes `cache_*_from_bytes`:
- image → `cache_image_from_bytes`
- audio/voice → `cache_audio_from_bytes`
- video → `cache_video_from_bytes`
- document → `cache_document_from_bytes`
→ returns a local path.
5b. 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
- document → `cache_document_from_bytes`
→ returns a local path.
4. Plugin replies `media.upload.ack {ok, media_ref}` (failures use `error`).
5. The path is attached to the next `message.send` via `media_refs`, becoming
`MessageEvent.media_urls` + `media_types`
(`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then
read the file.
**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
binary frames into a temp file (not memory) to bound RAM.
**Single-shot:** no chunking/resumability — HTTP carries the body; single-user
scale makes a one-shot upload sufficient.
## 7.3 Outbound (agent → app) — `media.offer` / `media.pull`
**Flow:**
1. Agent produces/references media (e.g. generates an image, or replies with a
`MEDIA:` tag / image URL). hermes base `extract_media` / `extract_images`
(`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
`media.offer {media_id, kind, mime, size, filename}` (inside/with the
`message` frame's `media[]`).
3. App sends `media.pull {media_id}`.
4. Plugin streams the file as **binary WS frames**; ends with
`media.pull.end {ok:true}`.
5. App writes to its cache dir and hands the path to the player/viewer.
3. App `GET /v1/media/{id}` — the full file body.
4. App writes to its cache dir and hands the path to the player/viewer.
**Security:** `validate_media_delivery_path` (`base.py:1684`) + the media
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)
@@ -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
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).
- Binary frames carry raw bytes only; framing/metadata is in the JSON header +
end frames.
- Reassembly is ordered (WS preserves order); a gap/corruption → abort +
`error {code:"internal"}` + retry the whole transfer.
- `sha256` in `media.upload.end` / a size check on pull verify integrity.
- Upload: `sha256` (precomputed by the app, sent in `X-Iris-Media-Sha256`)
is verified by the plugin; mismatch → `media.upload.ack {ok:false}`.
- Pull: the app checks the received size against the offered `size`.
- A failed transfer → retry the whole upload (single-shot, no resume).
## 7.6 App-side storage
@@ -94,4 +98,4 @@ only serves files hermes is allowed to deliver (no arbitrary file read).
- LRU eviction by size (configurable, default 500 MB) so old media doesn't fill
the device.
- A `MediaRepository` tracks `{media_id, local_path, kind, size, ts}` in Room so
bubbles can re-render players after process death.
bubbles can re-render players after process death.
+8
View File
@@ -60,6 +60,14 @@ security principal (the token is).
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
at the edge, forward WS to `127.0.0.1:8790`.
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's
fallback transport. It is a *second door with the same lock*: the same
Bearer token (constant-time `verify_token`) + the same device allowlist
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`.
`GET /v1/health` is unauthenticated by design (liveness only — it must
not reflect tokens, device ids, or versions).
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
in secure storage.
+9
View File
@@ -131,6 +131,15 @@ adb logcat -d > /tmp/logcat.txt
entries drop out); tap a row → the command is sent and the drawer closes;
type an unknown command → the drawer closes (the raw text can still be
sent; hermes answers with its unknown-command reply).
15. **HTTP fallback (docs/19):** with the WS port unreachable (e.g. the
gateway bound WS to a dead port, or a firewall dropping 8790 but not
8791), the app stays sendable: the status pill shows "connected · http"
(green), a sent message echoes back within ~1 s and the agent reply
streams in over SSE; the attach button is disabled (media needs the
live WS). When the WS comes back the pill returns to "connected" and
media works again. Automated: `e2e.py` scenario 13 (health +
`POST /v1/frame` + SSE turn, user-echo < 1.5 s) and
`ws_probe.py --http` (same assertion flags as the WS leg).
## 13.5 Debugging tips
+405
View File
@@ -0,0 +1,405 @@
# 19 — HTTP Fallback Transport (the "HTTP leg")
A second, **short-lived-connection** transport next to the WebSocket: the same
JSON frames, the same outbox/cursor, the same token — served over plain HTTP
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
waiting 2–20 s for a WS redial.
Status: **implemented — and now the ONLY transport.** The WebSocket leg has
been removed entirely from the codebase (gateway `ws_server.py` deleted;
WS-only frames `hello`/`ping`/`pong`/`media.upload.*`/`media.pull*` dropped
from `protocol.py` and `Protocol.kt`; `GatewayClient` is HTTP-only with no
`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
Today the WS is the *only* transport, and the app hard-gates sending on a live
socket (`ChatScreen.doSend()` no-ops unless `State.Connected`;
`GatewayClient.sendMessage()` drops when `socket == null`). Consequences:
- **App killed → reopened:** full cold dial (TCP + TLS + `hello`/`hello.ack`,
15 s dial timeout) before the user can send. On a flaky network the first
dial often fails → backoff → second dial. Observed: 2–20 s of "can't send".
- **Long-lived WS is the most fragile connection type on mobile:** idle
sockets expire in router/CGNAT NAT tables, die on WiFi↔cellular handover,
and are killed aggressively by OEM power management (MIUI on the test
device). There is no foreground service holding the WS.
- **Stale detection is slow:** 20 s ping interval, 60 s reap — a dead-but-
unclosed socket can sit for up to a minute before redial.
## 19.2 Why HTTP (and why not the alternatives)
Short-lived HTTP requests are dramatically more resilient on mobile networks
than a long-lived socket: no NAT table entry to expire, no proxy idle-kill,
each request is a fresh connection (fast with TLS resumption), and they work
through the restrictive proxies that mangle WebSockets. Sending a message
becomes a single `POST` that completes in well under a second on a LAN —
independent of whether the WS is up.
Alternatives considered and rejected (research, 2026-08):
| Option | Verdict |
| --- | --- |
| **MQTT broker** (QoS 1, persistent sessions) | Best protocol for flaky links, but new infra (broker process) + new Python dep (`paho-mqtt`, breaks the zero-new-deps rule) + new Kotlin dep + frame↔topic bridge. Overkill for a 1-user agent. |
| **ntfy as the send path** (app publishes to a topic the gateway subscribes to) | Adds a third party to the critical send path; public ntfy.sh is already known-flaky. Not worth it. |
| **WebTransport / QUIC** | The *real* fix for handover flakiness (connection migration), but no OkHttp support and `aioquic` is a new Python dep. Future option if this doc's approach is still not enough. |
| **gRPC** | New deps both sides; no advantage over WS+SSE here. |
| **Inverted connection** (app runs a local HTTP server, gateway pushes to the phone) | LAN-only, breaks on cellular/remote, security mess. Rejected. |
| **Matrix / full chat server** | Massive overkill for a personal agent. |
**Zero new Python dependencies is preserved:** the HTTP leg is stdlib
`http.server` (a `ThreadingHTTPServer` in a daemon thread) bridged into the
gateway's asyncio loop. The app side uses the OkHttp it already depends on
(hand-rolled SSE reader — the format is trivial; `okhttp-eventsource` is an
acceptable alternative if preferred).
## 19.3 Shape
```
┌──────────────────────── hermes gateway process ───────────────────────┐
│ AndroidAdapter │
│ │ frames (same protocol.Frame objects) │
│ ▼ │
│ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │
│ │ │ │
│ ▼ ▼ │
│ WsServer (asyncio, :8790) HttpServer (stdlib thread, :8791) │
│ primary: full protocol fallback: POST /v1/frame, │
│ incl. binary media GET /v1/events (SSE), /v1/poll │
└───────────────┬──────────────────────────────┬────────────────────────┘
│ WS (primary) │ HTTP (fallback)
┌─────────┴──────────────────────────────┴────────┐
│ APP: transport state machine │
│ WS up → WS only (media works, lowest latency)│
│ WS down → send via POST, receive via SSE/poll │
└─────────────────────────────────────────────────┘
```
**v1 scope**
| Over HTTP | WS-only |
| --- | --- |
| 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) | — |
| `sync` catch-up (same outbox, same cursor) | — |
| Media upload + pull (`POST /v1/media`, `GET /v1/media/{id}`, v2 — §19.15) | — |
With v2 the HTTP leg is feature-complete: media no longer needs the WS
(the composer's attach button is enabled in `HTTP_FALLBACK` too). The WS
binary media frames remain accepted for WS clients, but the app routes media
over HTTP whenever the WS is down.
## 19.4 Gateway: `gateway-plugin/http_server.py`
New module, started/stopped by `AndroidAdapter.connect()`/`disconnect()` next
to the WS server.
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
run in a **daemon thread** (one thread per connection — fine at single-user
scale). The handler thread never touches adapter state directly; it bridges
into the gateway's asyncio loop with
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
start, same loop the WS server runs on).
- **Config:** `ANDROID_HTTP_PORT` (default **8791**), same bind host as the WS
(`ANDROID_WS_HOST`). Optional TLS via `ANDROID_HTTP_CERT`/`ANDROID_HTTP_KEY`
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
trusted LAN by default, TLS for remote/Tailscale setups.
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
HTTP leg, show it in the inspector. The plugin must keep working WS-only.
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
### Endpoints
| Endpoint | Auth | Purpose |
| --- | --- | --- |
| `GET /v1/health` | none | Liveness probe → `200 {"ok": true}`. Leaks nothing (no token echo, no device info). The app races this against the WS dial at startup. |
| `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/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
- `Authorization: Bearer <token>`; verified with the existing constant-time
`verify_token()`; `401` on failure. Device identity via `X-Iris-Device`
header (same `device_id` the app uses for `hello`; same allowlist check).
- Request body cap **64 KiB**, `Content-Type: application/json` enforced
(frames are small; media never travels here in v1).
- Rate limit: token bucket per device, same parameters as the WS inbound
limit (`INBOUND_RATE_PER_S` / `INBOUND_BURST`); `429` on exceed.
- No CORS headers (app clients only); unknown paths → `404`.
## 19.5 SSE stream design (`GET /v1/events`)
Wire format (standard SSE, three fields):
```
id: 1043
event: frame
data: {"v":1,"type":"message","chat_id":"android:default",...}
: hb ← comment heartbeat every 15 s (keeps proxies alive)
```
- **`id` = outbox cursor.** This is what makes resume trivial: on reconnect
the client sends `Last-Event-ID` (or `?cursor=`) and the server replays
`outbox.replay(cursor)` — exactly the `sync` semantics, no new machinery.
- **Stream open sequence:**
1. Replay outbox rows with `cursor > N` (bounded by the existing
`_REPLAY_LIMIT`), each as an `event: frame` with its `id`.
2. One `event: hello` carrying the `hello.ack` payload
(`server_caps`, `sync_cursor`, `last_pushed_cursor`, `channels`) — the
HTTP equivalent of pairing-ack; the app treats it like `hello.ack`.
3. Live frames as they are produced.
- **Live fan-out hook:** in `adapter._broadcast_or_log`, after
`outbox.append()` returns the cursor, push `(cursor, frame_json)` into every
live HTTP subscriber's queue. The direct `status` broadcasts
(`ws_server.broadcast(protocol.status(...))`) get a second fan-out call
with `cursor = None` (SSE event without `id`).
- **Thread model:** each SSE connection owns its handler thread, which blocks
on a cross-thread `queue.get()` (via `run_coroutine_threadsafe`, 30 s
timeout → write `: hb` and loop) and writes to `wfile` + `flush()`.
- **Backpressure:** bounded queue (256). A subscriber that can't keep up is
dropped; the client reconnects with `Last-Event-ID` and catches up from the
outbox. Single-user scale makes this a non-event in practice.
- **App-side reader:** hand-rolled over OkHttp's streaming `ResponseBody`
(read lines; `id:` / `event:` / `data:`; blank line = dispatch). ~100 lines,
no new dependency. Reconnect with exponential backoff + `Last-Event-ID`.
## 19.6 Long-poll fallback (`GET /v1/poll`)
For networks/proxies that buffer or kill SSE:
- `GET /v1/poll?cursor=N` → server holds the request (asyncio waiter on the
subscriber queue) until a frame with `cursor > N` exists or **25 s** pass.
- Response: `200 {"cursor": <new high-water>, "frames": [ ... ]}` (frames may
be empty on timeout; the app immediately re-polls with the new cursor).
- The app switches to long-poll automatically after **two consecutive SSE
open failures**, and back to SSE on the next full (re)connect.
## 19.7 Request/response over HTTP
`POST /v1/frame` is accept-and-ack:
- `202 {"ok": true}` — frame accepted and dispatched.
- `4xx` with an **error frame as the JSON body** for validation rejections
(empty message, automation-channel read-only, rate limit → `429`, bad JSON
→ `400`). These are the same `error` frames the WS path sends via
`send_to`; over HTTP they double as the HTTP response.
- **Async responses** (user echo, `search` results, `channel.list`, the agent
reply, streaming updates) arrive on the **event stream** carrying the same
`id` — the app's existing request-id correlation works unchanged.
- Consequence: `send_to(device_id, …)` error replies for HTTP-originated
requests are instead **broadcast** (single-user model; the SSE stream
delivers them). The dispatch refactor must tag the origin so WS-originated
requests keep point-to-point errors.
## 19.8 Delivery counting & push interaction (critical)
`_broadcast_or_log` fires push when `delivered == 0`. With the HTTP leg, a
device reading SSE **is** a live subscriber:
```python
delivered = await self._ws_server.broadcast(frame)
delivered += await self._http_server.fanout(frame, cursor) # live SSE/poll subs
...
if delivered == 0: # → outbox + push (unchanged)
```
If this is forgotten, every message would push *and* stream to a device that
is already receiving it. Related bookkeeping:
- `has_devices()` / `status` must count HTTP subscribers as connected devices
(mark the device's transport `ws` | `http` in the connection registry).
- `last_pushed_cursor` / notification dedupe (`08-push.md` §8.8) is
unchanged — SSE-replayed frames carry the same `cursor` envelope as
`sync`-replayed ones, so the app's existing dedupe applies.
## 19.9 App side
New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
`GatewayClient` (or a thin `Transport` wrapper around it):
- **API:** `health(timeoutMs)`, `postFrame(json): Result`,
`events(cursor, onFrame, onHello): Job` (SSE reader), `poll(cursor): Result`.
- **State machine:**
| State | Send path | Receive path |
| --- | --- | --- |
| `WS_CONNECTED` | WS frame | WS |
| `HTTP_FALLBACK` | `POST /v1/frame` | SSE (or long-poll) |
| `CONNECTING` / `RECONNECTING` | queued/dropped as today | — |
- **On WS loss:** switch to `HTTP_FALLBACK` **immediately** — open the SSE
stream (catch-up from the local cursor is free) and route sends to POST.
No backoff gate on the send path; the WS redial loop keeps running in the
background.
- **At startup (the key UX fix):** race the WS dial against
`GET /v1/health` (2 s timeout). WS dial fails + health OK → straight into
`HTTP_FALLBACK`: the user can send in **< 1 s** after opening the app,
instead of waiting out dial timeouts and backoff.
- **On WS reconnect:** close the SSE stream, resume WS-only (lowest latency,
media available again).
- **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
`ChatScreen.doSend()` becomes `state is Connected || state is HttpFallback`.
- **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)
— both green; the fallback is a healthy state, not an error.
## 19.10 Security
- Same token, constant-time verify, same bind host, same device allowlist as
the WS (`09-pairing-security.md` threat model unchanged — the HTTP leg adds
no new trust boundary, only a second door with the same lock).
- `/v1/health` is unauthenticated by design (it answers "is the gateway
alive?"); it must not reflect tokens, device ids, or version strings.
- TLS: optional, same cert pattern as the WS; plaintext is a LAN-only
default, identical to today's WS posture.
- New attack-surface items to keep small: 64 KiB body cap, strict
content-type, per-device rate limit, no directory listing, no CORS.
## 19.11 Failure modes
| Failure | Behavior |
| --- | --- |
| Gateway fully down | Both legs dead → app shows offline; sends queue (app-side outbox, follow-up work) or are dropped with a visible "not sent" state. Push is the wake path when the gateway comes back (`08-push.md`). |
| WS down, HTTP up | Normal `HTTP_FALLBACK` operation — text chat fully functional, media paused. |
| SSE blocked by a proxy | Two failures → long-poll loop (§19.6). |
| HTTP port firewalled, WS up | WS-only operation (today's behavior); `health` fails at startup, no fallback attempted. |
| Both flaky | Existing WS backoff + SSE/poll backoff run independently; outbox + cursor keep both paths idempotent. |
| Slow SSE subscriber | Dropped at queue overflow; reconnects with `Last-Event-ID`, catches up from outbox. |
## 19.12 Testing
- **Python** (`hermes-agent/tests/gateway/test_android_http.py`, run via
`scripts/run_tests.sh`):
- auth: bad/missing token → 401; allowlist rejection; constant-time verify reused.
- `POST /v1/frame`: valid `message.send` dispatches (agent turn fires);
empty text → 400 error frame; automation channel → 409/400; rate limit → 429.
- SSE: catch-up rows carry correct `id`s; `event: hello` present; a live
frame appended after connect arrives on the stream; `Last-Event-ID`
resume replays exactly the delta; heartbeat observed within 15 s.
- long-poll: returns on new frame; empty 200 at timeout with advanced cursor.
- **delivery counting:** frame with only an SSE subscriber → `delivered ≥ 1`
→ **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
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,
`Last-Event-ID` bookkeeping); transport state machine transitions (fake
clock: WS-loss → immediate fallback; startup race → fallback in < 1 s).
- **E2E** (`e2e.py`, new scenario): point the app at a dead WS port with the
HTTP leg live → send a message → assert user echo + agent reply arrive via
SSE; timing assertion: send → user echo < 1 s on LAN. Live-verify on the
device via ADB (screenshot of the "connected · http" pill).
## 19.13 Non-goals (v1) / future
- ~~**Media over HTTP** (v2)~~ — **done** (§19.15): `POST /v1/media`
(whole-file body, sha256 contract per `07-media.md`) +
`GET /v1/media/{id}` for pull/playback. Attachments work in fallback mode.
- **App-side send outbox** (companion work, separate doc): queue sends locally
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
30 s" data-loss case.
- **QUIC / WebTransport** if handover flakiness persists after this + the
outbox (connection migration would make the fallback rare).
- Per-device tokens (`16-open-questions.md` #3) apply to both legs identically
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
| Slice | Files | Est. |
| --- | --- | --- |
| Gateway leg | new `gateway-plugin/http_server.py` (~450 lines); `adapter.py` hooks (start/stop, fan-out in `_broadcast_or_log` + status path, delivery counting, dispatch-origin tag); `protocol.py` unchanged | 2–3 d |
| App leg | new `app/shared/.../net/HttpGateway.kt` (SSE reader + poll); `GatewayClient.kt` state machine + startup race; `ChatScreen.kt` gate + status pill; composer media-disable in fallback | 2–3 d |
| Tests + e2e + docs | per §19.12; `frames.schema.json` unchanged (no new frame types); `09-pairing-security.md` + `13-testing.md` cross-references | 1–2 d |
Total: **~1 week**, each slice independently shippable (gateway leg is
inert until the app uses it; app leg degrades to today's behavior if the
HTTP port is closed).
+5 -2
View File
@@ -8,6 +8,7 @@ This folder is the single source of truth for *what to build and why*. Read it
top-to-bottom once, then use the numbered docs as a lookup while implementing.
> ⚠️ **READ FIRST — two hard rules**
>
> 1. **`hermes-agent/` (sibling of this folder) is a read-only research
> reference. It must NEVER be committed, pushed, or shipped.** It is
> git-ignored at the repo root. We only *install* our plugin into a live
@@ -20,7 +21,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
## Reading order
| # | File | When to read |
|---|------|--------------|
| --- | ------ | -------------- |
| 0 | [`00-overview.md`](00-overview.md) | Always first. Vision, scope, disclaimers, locked decisions. |
| 1 | [`01-architecture.md`](01-architecture.md) | Before touching code. System shape + rationale. |
| 2 | [`02-monorepo.md`](02-monorepo.md) | When scaffolding the repo. |
@@ -39,8 +40,10 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
| 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. |
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
| 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. |
Machine-readable / diagrams:
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
@@ -65,4 +68,4 @@ project** (`app/`) with a shared KMP module (`app/shared`).
- **Phase:** M0–M6 complete; M7 (polish + E2E + docs) in progress.
- **Owner decisions locked:** see [`16-open-questions.md`](16-open-questions.md).
- **Last updated:** 2026-08-19.
- **Last updated:** 2026-08-19.
+166 -282
View File
@@ -1,18 +1,20 @@
"""
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
with a pairing token and talks to the agent over a single WS transport
(chat, streaming, tools, media, pairing, push-token).
with a pairing token and talks to the agent over a single HTTP transport
(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
deps. Zero hermes-core changes.
Zero new Python dependencies: ``httpx`` is a hermes core dep. Zero
hermes-core changes.
Milestone M1: the gateway core loop (text round-trip). The WS server binds
and authenticates devices (``hello`` with constant-time token check), the
adapter emits ``message`` frames from ``send()`` and turns inbound
``message.send`` frames into ``MessageEvent``s for ``handle_message()``.
Milestone M1: the gateway core loop (text round-trip). The server binds and
authenticates devices (constant-time token check), the adapter emits
``message`` frames from ``send()`` and turns inbound ``message.send`` frames
into ``MessageEvent``s for ``handle_message()``.
Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
``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
``docs/14-milestones.md``).
Milestone M4: media. Inbound ``media.upload`` (chunked binary frames) is
reassembled in a temp file, verified (size + sha256), re-sniffed, and cached
via hermes ``cache_*_from_bytes``; the resulting refs attach to the next
Milestone M4: media. Inbound uploads (``POST /v1/media``) are streamed to a
temp file, verified (size + sha256), re-sniffed, and cached via hermes
``cache_*_from_bytes``; the resulting refs attach to the next
``message.send`` as ``MessageEvent.media_urls``. Outbound ``send_*`` calls
register the (delivery-validated) file in the media registry and emit
``media.offer``; ``media.pull`` streams the file back as chunked binary
frames, re-checking ``validate_media_delivery_path`` at pull time.
``media.offer``; ``GET /v1/media/{id}`` streams the file back, re-checking
``validate_media_delivery_path`` at pull time.
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
@@ -62,6 +64,7 @@ import contextlib
import json
import logging
import os
import queue
import re
import threading
import time
@@ -117,6 +120,7 @@ from . import protocol # noqa: E402
from . import purge as purge_bridge # noqa: E402
from . import search as search_bridge # noqa: E402
from .channels import get_directory # noqa: E402
from .http_server import HttpServer # noqa: E402
from .outbox import Outbox # noqa: E402
from .pairing import ( # noqa: E402
DeviceRegistry,
@@ -125,7 +129,6 @@ from .pairing import ( # noqa: E402
qr_payload,
)
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
from .ws_server import WsServer # noqa: E402
# ---------------------------------------------------------------------------
# Slash-command catalog (the app's "/" drawer)
@@ -450,14 +453,13 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8790
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
DEFAULT_HOME_CHANNEL = "android:default"
DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "fcm"
DEFAULT_OUTBOX_RETENTION_HOURS = 72
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.
_PRUNE_NOTIFY_INTERVAL_S = 3600.0
@@ -799,15 +801,12 @@ class _TurnState:
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`` /
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"))
@@ -853,9 +852,9 @@ def _env_enablement() -> dict | None:
host = os.getenv("ANDROID_WS_HOST", "").strip()
if host:
seed["host"] = host
port_raw = os.getenv("ANDROID_WS_PORT", "").strip()
if port_raw:
seed["port"] = _parse_port(port_raw)
http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
if http_port_raw:
seed["http_port"] = _parse_port(http_port_raw)
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
if push:
seed["push_backend"] = push
@@ -1029,10 +1028,16 @@ def interactive_setup() -> None:
else:
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)
port = prompt("WS port", default=str(_parse_port(get_env_value("ANDROID_WS_PORT") or "")))
save_env_value("ANDROID_WS_PORT", str(_parse_port(port)))
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
# HTTP default must be applied explicitly (docs/19: 8791).
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
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(
"Push backend (fcm/ntfy)",
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
@@ -1060,19 +1065,20 @@ def interactive_setup() -> None:
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
pairing token, the connection registry tracks live sockets, ``send()``
The HTTP server (``http_server.HttpServer``) authenticates devices with
the pairing token, the device registry tracks live subscribers, ``send()``
emits ``message`` frames, and inbound ``message.send`` frames become
``MessageEvent``s for ``handle_message()``.
"""
# WS has no message-size limit. The stream consumer resolves its per-chat
# chunking budget via ``max_message_length_for_chat`` -> this attribute
# (defaulting to 4096 when unset), which would split long replies — and
# complete HTML artifacts — across multiple fence-reopened messages. A
# large cap disables that chunking so a reply arrives as a single message.
# The HTTP transport has no per-message size limit. The stream consumer
# resolves its per-chat chunking budget via ``max_message_length_for_chat``
# -> this attribute (defaulting to 4096 when unset), which would split
# long replies — and complete HTML artifacts — across multiple
# fence-reopened messages. A large cap disables that chunking so a reply
# arrives as a single message.
MAX_MESSAGE_LENGTH = 1_000_000
def __init__(self, config, **kwargs):
@@ -1085,10 +1091,12 @@ class AndroidAdapter(BasePlatformAdapter):
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.port = _parse_port(
os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT))
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
self.http_port = _parse_port(
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
)
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
@@ -1120,8 +1128,8 @@ class AndroidAdapter(BasePlatformAdapter):
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
# 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_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
# Auth
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
@@ -1132,7 +1140,13 @@ class AndroidAdapter(BasePlatformAdapter):
# Runtime state
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).
self._http_server = HttpServer(self, self._devices)
# 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
# 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._connected = False
# M2: per-chat turn state for outbound frame classification.
self._turns: dict[str, _TurnState] = {}
@@ -1175,7 +1189,7 @@ class AndroidAdapter(BasePlatformAdapter):
# ── Connection lifecycle ──────────────────────────────────────────────
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:
logger.error("android: ANDROID_TOKEN must be set")
self._set_fatal_error(
@@ -1185,29 +1199,17 @@ class AndroidAdapter(BasePlatformAdapter):
)
return False
# Prevent two profiles from binding the same port/identity.
try:
from gateway.status import acquire_scoped_lock
lock_key = f"{self.host}:{self.port}"
if not acquire_scoped_lock("android", lock_key):
logger.error(
"android: %s:%s already in use by another profile", self.host, self.port
)
self._set_fatal_error(
"lock_conflict",
"WS port in use by another profile",
retryable=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
# The HTTP server is the only device-facing transport, so a bind
# failure is fatal (the app has no other way to reach the gateway).
# start() never raises; it disables the leg and logs on failure.
await self._http_server.start()
if not self._http_server.enabled:
logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
self._set_fatal_error(
"bind_failed",
f"HTTP port {self.http_port} unavailable",
retryable=False,
)
return False
# M5: announce gateway health to connected clients (none yet at
@@ -1215,7 +1217,7 @@ class AndroidAdapter(BasePlatformAdapter):
# Reset in case this adapter instance previously went down (the
# gateway may reconnect the same adapter after a fatal error).
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)
# M3: ensure the default (home) channel exists in the directory so the
# app's channel list and cron home delivery have a stable anchor.
@@ -1236,28 +1238,22 @@ class AndroidAdapter(BasePlatformAdapter):
self._connected = True
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
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
# the app can distinguish a clean gateway teardown from a plain
# network drop: the "Gateway restarting" chat notice is shown only
# when this frame was received (docs/04 §status).
self._gateway_status = protocol.STATUS_RESTARTING
with contextlib.suppress(Exception):
await self._ws_server.broadcast(protocol.status(self._gateway_status))
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)
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
try:
await self._ws_server.stop()
await self._http_server.stop()
except Exception:
logger.warning("android: WS server stop failed", exc_info=True)
logger.warning("android: HTTP server stop failed", exc_info=True)
# Best-effort shutdown: a close failure on an already-closed store is
# not actionable at disconnect time.
with contextlib.suppress(Exception):
@@ -1642,8 +1638,45 @@ class AndroidAdapter(BasePlatformAdapter):
# (e.g. tool_progress off) so they can't leak into the next turn.
_reset_tool_results()
# ── docs/19: HTTP-leg reply routing ───────────────────────────────────
def _http_register_sink(
self, device_id: str, entry: tuple[queue.Queue, threading.Event]
) -> None:
self._http_reply_sinks[device_id] = entry
def _http_pop_sink(self, device_id: str) -> tuple[queue.Queue, threading.Event] | None:
return self._http_reply_sinks.pop(device_id, None)
def _http_pop_sink_if(
self, device_id: str, sink: queue.Queue
) -> tuple[queue.Queue, threading.Event] | None:
"""Pop the sink entry only if it is still ours (a newer request from
the same device may have replaced it)."""
entry = self._http_reply_sinks.get(device_id)
if entry is None or entry[0] is not sink:
return None
return self._http_reply_sinks.pop(device_id, None)
async def _broadcast_both(self, frame: "protocol.Frame") -> None:
"""Bare (non-outbox) broadcast to live subscribers (docs/19): the
frame reaches every live SSE/long-poll subscriber."""
await self._http_server.fanout(frame, cursor=None)
async def _reply(self, device_id: str, frame: "protocol.Frame") -> None:
"""Point-to-point reply with broadcast fallback (docs/19 §19.7).
For an in-flight HTTP request (a reply sink is registered) the frame
goes into the HTTP response. Otherwise it is broadcast so the
device's SSE stream delivers it (single-user model).
"""
entry = self._http_reply_sinks.get(device_id)
if entry is not None:
entry[0].put(frame)
return
await self._broadcast_both(frame)
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
# 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.
@@ -1656,6 +1689,10 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception:
logger.warning("android: outbox append failed", exc_info=True)
return
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
# — count it in the delivery total or every message would push AND
# stream to a device that is already receiving it.
delivered = await self._http_server.fanout(frame, cursor)
if delivered == 0:
logger.info(
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
@@ -1753,12 +1790,7 @@ class AndroidAdapter(BasePlatformAdapter):
device_id = device.get("device_id")
if not device_id:
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:
continue
try:
@@ -1819,11 +1851,13 @@ class AndroidAdapter(BasePlatformAdapter):
tid = metadata.get("thread_id")
if isinstance(tid, str) and tid:
thread_id = tid
await self._ws_server.broadcast(protocol.typing(chat_id, True, thread_id=thread_id))
frame = protocol.typing(chat_id, True, thread_id=thread_id)
await self._http_server.fanout(frame, cursor=None)
async def stop_typing(self, chat_id: str) -> None:
"""Clear the typing indicator (``typing`` frame, on=false)."""
await self._ws_server.broadcast(protocol.typing(chat_id, False))
frame = protocol.typing(chat_id, False)
await self._http_server.fanout(frame, cursor=None)
# ── M4: outbound media (agent -> app) ─────────────────────────────────
#
@@ -1959,7 +1993,7 @@ class AndroidAdapter(BasePlatformAdapter):
)
if not text.strip() and not media_refs:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id
@@ -1978,7 +2012,7 @@ class AndroidAdapter(BasePlatformAdapter):
# enforcement).
target = self._channels.get(chat_id)
if target is not None and target.get("automation"):
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED,
@@ -2025,7 +2059,7 @@ class AndroidAdapter(BasePlatformAdapter):
thread_id = entry["chat_id"]
# Bare broadcast (like channel.create): the directory is
# re-served on hello.ack, so no outbox entry is needed.
await self._ws_server.broadcast(protocol.channel_created(entry, auto=True))
await self._broadcast_both(protocol.channel_created(entry, auto=True))
self._schedule_thread_title_upgrade(entry["chat_id"], text)
# M4: resolve media refs (single-use; unknown ref -> error).
@@ -2035,7 +2069,7 @@ class AndroidAdapter(BasePlatformAdapter):
for ref in media_refs:
entry = self._media.get_inbound(ref)
if entry is None:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id
@@ -2120,7 +2154,7 @@ class AndroidAdapter(BasePlatformAdapter):
await self.handle_message(event)
# M5: acknowledge the user message to the originating device (the
# app shows ✓✓) at the moment it is handed to the agent.
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.read_receipt(chat_id, message_id),
)
@@ -2157,7 +2191,7 @@ class AndroidAdapter(BasePlatformAdapter):
return
try:
asyncio.run_coroutine_threadsafe(
self._ws_server.broadcast(protocol.channel_renamed(renamed)),
self._broadcast_both(protocol.channel_renamed(renamed)),
loop,
)
except Exception:
@@ -2165,150 +2199,6 @@ class AndroidAdapter(BasePlatformAdapter):
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._ws_server.send_to(
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._ws_server.send_to(
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._ws_server.send_to(
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._ws_server.send_to(
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._ws_server.send_to(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._ws_server.send_to(
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._ws_server.send_to(
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._ws_server.send_to(device_id, protocol.error(e.code, e.message, id=frame.id))
return
await self._ws_server.send_to(
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._ws_server.send_to(
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._ws_server.send_to(
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._ws_server.send_to(
device_id, protocol.error(protocol.ERR_INTERNAL, f"pull failed: {e}", id=frame.id)
)
return
await self._ws_server.send_to(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) ───────────────────
#
# Each request is answered by broadcasting the matching ``channel.*``
@@ -2320,7 +2210,7 @@ class AndroidAdapter(BasePlatformAdapter):
payload = frame.payload
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id
@@ -2337,13 +2227,13 @@ class AndroidAdapter(BasePlatformAdapter):
try:
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
except ValueError as e:
await self._ws_server.send_to(
await self._reply(
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
)
return
resp = protocol.channel_created(entry)
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).
await self._broadcast_or_log(
entry["chat_id"],
@@ -2358,7 +2248,7 @@ class AndroidAdapter(BasePlatformAdapter):
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id
@@ -2367,7 +2257,7 @@ class AndroidAdapter(BasePlatformAdapter):
return
name = frame.payload.get("name")
if not isinstance(name, str) or not name.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id
@@ -2377,19 +2267,19 @@ class AndroidAdapter(BasePlatformAdapter):
try:
entry = self._channels.rename(chat_id, name)
except ValueError as e:
await self._ws_server.send_to(
await self._reply(
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
)
return
if entry is None:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
resp = protocol.channel_renamed(entry)
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).
await self._broadcast_or_log(
chat_id,
@@ -2404,7 +2294,7 @@ class AndroidAdapter(BasePlatformAdapter):
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id
@@ -2413,7 +2303,7 @@ class AndroidAdapter(BasePlatformAdapter):
return
entry = self._channels.set_default(chat_id)
if entry is None:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
@@ -2422,12 +2312,12 @@ class AndroidAdapter(BasePlatformAdapter):
# new is_default flag) so every device reconciles the default change.
resp = protocol.channel_renamed(entry)
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:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id
@@ -2437,7 +2327,7 @@ class AndroidAdapter(BasePlatformAdapter):
on = bool(frame.payload.get("on"))
entry = self._channels.set_favorite(chat_id, on)
if entry is None:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
@@ -2446,12 +2336,12 @@ class AndroidAdapter(BasePlatformAdapter):
# new favorite flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
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:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id
@@ -2465,26 +2355,26 @@ class AndroidAdapter(BasePlatformAdapter):
color = color if isinstance(color, str) and color else None
# Guard against a runaway base64 blob (a channel icon is small).
if icon is not None and len(icon) > 512 * 1024:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
)
return
entry = self._channels.set_icon(chat_id, icon, color)
if entry is None:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
resp = protocol.channel_renamed(entry)
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:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
@@ -2494,7 +2384,7 @@ class AndroidAdapter(BasePlatformAdapter):
on = bool(frame.payload.get("on"))
entry = self._channels.set_automation(chat_id, on)
if entry is None:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND,
@@ -2507,12 +2397,12 @@ class AndroidAdapter(BasePlatformAdapter):
# new automation flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
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:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id
@@ -2521,7 +2411,7 @@ class AndroidAdapter(BasePlatformAdapter):
return
entry = self._channels.delete(chat_id)
if entry is None:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND,
@@ -2554,7 +2444,7 @@ class AndroidAdapter(BasePlatformAdapter):
)
resp = protocol.channel_deleted(chat_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).
await self._broadcast_or_log(
chat_id,
@@ -2570,7 +2460,7 @@ class AndroidAdapter(BasePlatformAdapter):
channels = self._channels.list(include_archived=False)
resp = protocol.channel_list(channels)
resp.id = frame.id
await self._ws_server.send_to(device_id, resp)
await self._reply(device_id, resp)
# ── Slash-command catalog (app's "/" drawer) ──────────────────────────
@@ -2581,7 +2471,7 @@ class AndroidAdapter(BasePlatformAdapter):
the typed prefix client-side; the catalog is static per gateway run,
so no caching is needed here."""
resp = protocol.commands_catalog(_slash_command_catalog(), id=frame.id)
await self._ws_server.send_to(device_id, resp)
await self._reply(device_id, resp)
# ── M3: search (app -> agent) ─────────────────────────────────────────
@@ -2589,7 +2479,7 @@ class AndroidAdapter(BasePlatformAdapter):
payload = frame.payload
query = payload.get("query")
if not isinstance(query, str) or not query.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "search requires a query", id=frame.id),
)
@@ -2612,7 +2502,7 @@ class AndroidAdapter(BasePlatformAdapter):
db_path, query, scope=scope, chat_id=chat_id, thread_id=thread_id, limit=limit
)
resp = protocol.search_results(query, scope, hits, id=frame.id)
await self._ws_server.send_to(device_id, resp)
await self._reply(device_id, resp)
# ── M3: sync (reconnect catch-up) ─────────────────────────────────────
@@ -2639,9 +2529,9 @@ class AndroidAdapter(BasePlatformAdapter):
cursor=e.get("cursor"),
v=raw.get("v") if isinstance(raw.get("v"), int) else protocol.PROTOCOL_VERSION,
)
await self._ws_server.send_to(device_id, replayed)
await self._reply(device_id, replayed)
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
await self._ws_server.send_to(device_id, done)
await self._reply(device_id, done)
# ── Full message history (initial channel open / scroll-up) ───────────
@@ -2658,7 +2548,7 @@ class AndroidAdapter(BasePlatformAdapter):
chat_id = frame.chat_id or payload.get("chat_id")
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "history requires a chat_id", id=frame.id),
)
@@ -2689,7 +2579,7 @@ class AndroidAdapter(BasePlatformAdapter):
oldest_message_id=page["oldest_message_id"],
id=frame.id,
)
await self._ws_server.send_to(device_id, resp)
await self._reply(device_id, resp)
# ── Message deletion (app -> agent) ───────────────────────────────────
@@ -2708,7 +2598,7 @@ class AndroidAdapter(BasePlatformAdapter):
payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id
@@ -2724,7 +2614,7 @@ class AndroidAdapter(BasePlatformAdapter):
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
if not message_ids:
await self._ws_server.send_to(
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id
@@ -2766,8 +2656,8 @@ class AndroidAdapter(BasePlatformAdapter):
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
"""Update the device's push tokens (FCM rotation / ntfy topic).
Persists to the device registry AND refreshes the live connection so
the next push targets the current token without a stale read.
Persists to the device registry so the next push targets the current
token without a stale read.
"""
fcm_token = frame.payload.get("fcm_token")
ntfy_topic = frame.payload.get("ntfy_topic")
@@ -2780,12 +2670,6 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception:
logger.warning("android: fcm.register update failed", exc_info=True)
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)
# ── M5: approval / clarify banners ────────────────────────────────────
@@ -2987,7 +2871,7 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception:
logger.warning("android: create_handoff_thread failed", exc_info=True)
return None
await self._ws_server.broadcast(protocol.channel_created(entry))
await self._broadcast_both(protocol.channel_created(entry))
return entry["chat_id"]
@@ -3029,7 +2913,7 @@ def register(ctx):
validate_config=validate_config,
is_connected=is_connected,
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,
# Env-driven auto-configuration: seeds PlatformConfig.extra with
# 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
+843
View File
@@ -0,0 +1,843 @@
"""HTTP transport (docs/19): the gateway's device-facing server.
Short-lived-connection transport: the same JSON frames, the same
outbox/cursor, the same token — served over plain HTTP by the gateway.
The app sends over ``POST /v1/frame`` and receives over
``GET /v1/events`` (SSE) or ``GET /v1/poll`` (long-poll); media travels
via ``POST /v1/media`` / ``GET /v1/media/{id}`` (v2, docs/19 §19.15).
Zero new Python dependencies: stdlib ``http.server`` (a
``ThreadingHTTPServer`` in a daemon thread) bridged into the gateway's
asyncio loop with ``asyncio.run_coroutine_threadsafe``.
Endpoints (docs/19 §19.4):
* ``GET /v1/health`` — unauthenticated liveness probe.
* ``POST /v1/frame`` — accept-and-ack for any JSON frame the
app sends (media uses the /v1/media
endpoints; hello/ping are
transport-specific).
* ``GET /v1/events?cursor=N`` — SSE stream: outbox catch-up, then live
frames (``id`` = outbox cursor, so resume
is just ``Last-Event-ID``).
* ``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``) +
``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).
HTTP is the ONLY transport: a bind failure is FATAL (the app has no other
way to reach the gateway).
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import queue
import ssl
import threading
import time
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from . import dispatch, protocol
from . import media as media_bridge
from .pairing import verify_token
try: # main-repo import (same as adapter.py); absent in bare unit contexts
from gateway.platforms.base import validate_media_delivery_path
except ImportError: # pragma: no cover
validate_media_delivery_path = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# Default port for the HTTP transport (the WS-era default was 8790).
DEFAULT_HTTP_PORT = 8791
# Request body cap for POST /v1/frame. Frames are usually small, but a
# ``channel.icon`` carries a base64 blob up to 512 KiB (docs/10), so the cap
# 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
# dropped; it reconnects with Last-Event-ID and catches up from the outbox.
SUB_QUEUE_MAX = 256
# SSE comment heartbeat cadence (keeps proxies from idling the stream).
SSE_HEARTBEAT_S = 15.0
# Long-poll hold time (docs/19 §19.6).
POLL_TIMEOUT_S = 25.0
# How long POST /v1/frame waits for a synchronous validation rejection
# before acking 202 and letting the handler (e.g. the agent turn) run on.
ACCEPT_ACK_TIMEOUT_S = 5.0
# Sentinel pushed into subscriber queues on shutdown.
_STOP = object()
# Max length of a client-supplied media_ref (same as the WS path).
MAX_MEDIA_REF_LEN = 64
# MediaError code -> HTTP status for the /v1/media endpoints.
_MEDIA_STATUS = {
protocol.ERR_MEDIA_TOO_LARGE: 413,
protocol.ERR_NOT_FOUND: 404,
protocol.ERR_UNSUPPORTED: 400,
protocol.ERR_INTERNAL: 500,
}
def _with_cursor(frame: dict[str, Any], cursor: int) -> str:
"""Re-serialize an outbox frame dict with its cursor in the envelope
(same tagging ``sync`` replay uses, docs/08 §8.7)."""
d = dict(frame)
d["cursor"] = cursor
return json.dumps(d, separators=(",", ":"), ensure_ascii=False)
def _parse_cursor(*raws: Any) -> int:
"""First parseable non-negative int wins (``?cursor=`` beats
``Last-Event-ID``); 0 when nothing usable."""
for raw in raws:
if raw is None:
continue
try:
v = int(str(raw).strip())
except (TypeError, ValueError):
continue
if v >= 0:
return v
return 0
def _send_json(handler: BaseHTTPRequestHandler, status: int, obj: Any) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError):
handler.wfile.write(body)
handler.wfile.flush()
def _send_frame_json(handler: BaseHTTPRequestHandler, status: int, frame_json: str) -> None:
"""Send a protocol frame as the HTTP response body (docs/19 §19.7:
error frames double as the HTTP response)."""
body = frame_json.encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError):
handler.wfile.write(body)
handler.wfile.flush()
@dataclass
class _Subscriber:
"""One live HTTP subscriber (SSE stream or long-poll request)."""
device_id: str
kind: str # "sse" | "poll"
q: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=SUB_QUEUE_MAX))
closed: threading.Event = field(default_factory=threading.Event)
class HttpServer:
"""The plugin's HTTP server + live subscriber registry.
The handler threads never touch adapter state directly: inbound frames
are bridged into the gateway's asyncio loop (captured at ``start()``)
with ``asyncio.run_coroutine_threadsafe`` and dispatched through
``dispatch_frame`` (``dispatch.py``).
"""
def __init__(self, adapter: Any, devices: Any):
self._adapter = adapter
self._devices = devices
self._loop: asyncio.AbstractEventLoop | None = None
self._httpd: _ThreadingHTTPD | None = None
self._thread: threading.Thread | None = None
self._subs: dict[str, list[_Subscriber]] = {}
self._subs_lock = threading.Lock()
self._buckets: dict[str, dispatch._TokenBucket] = {}
self._buckets_lock = threading.Lock()
self._lock_key: str | None = None
self.enabled = False
self.bound_port = 0
# ── Lifecycle ─────────────────────────────────────────────────────────
async def start(self) -> None:
"""Bind and start serving. NEVER raises: a bind failure leaves
``enabled`` False, which the adapter treats as a fatal error
(HTTP is the only transport, docs/19 §19.4)."""
if self.enabled:
return
self._loop = asyncio.get_running_loop()
host = self._adapter.host
port = self._adapter.http_port
# Port-conflict lock: same flock pattern the WS uses.
try:
from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("android", lock_key):
logger.warning(
"android: HTTP port %s:%s in use by another profile; server disabled",
host,
port,
)
return
self._lock_key = lock_key
except ImportError:
self._lock_key = None # status module not available (e.g. tests)
try:
httpd = _ThreadingHTTPD((host, port), self)
self.bound_port = int(httpd.server_address[1])
if self._adapter.http_cert and self._adapter.http_key:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
except Exception as e:
logger.warning("android: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
self._release_lock()
return
self._httpd = httpd
self._thread = threading.Thread(
target=httpd.serve_forever, name="android-http", daemon=True
)
self._thread.start()
self.enabled = True
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
async def stop(self) -> None:
"""Stop serving and unblock all subscribers."""
self.enabled = False
with self._subs_lock:
subs = [s for lst in self._subs.values() for s in lst]
self._subs.clear()
for s in subs:
s.closed.set()
with contextlib.suppress(Exception):
s.q.put_nowait(_STOP)
httpd = self._httpd
self._httpd = None
if httpd is not None:
# shutdown() must be called from a thread other than the one
# running serve_forever(); we are on the asyncio loop thread.
with contextlib.suppress(Exception):
httpd.shutdown()
with contextlib.suppress(Exception):
httpd.server_close()
t = self._thread
self._thread = None
if t is not None and t is not threading.current_thread():
t.join(timeout=5.0)
self._release_lock()
def _release_lock(self) -> None:
with contextlib.suppress(ImportError):
from gateway.status import release_scoped_lock
if self._lock_key:
release_scoped_lock("android", self._lock_key)
self._lock_key = None
# ── Subscriber registry ───────────────────────────────────────────────
def has_devices(self) -> bool:
with self._subs_lock:
return bool(self._subs)
def device_ids(self) -> list:
with self._subs_lock:
return list(self._subs.keys())
def _add_sub(self, sub: _Subscriber) -> None:
with self._subs_lock:
self._subs.setdefault(sub.device_id, []).append(sub)
def _remove_sub(self, sub: _Subscriber) -> None:
with self._subs_lock:
lst = self._subs.get(sub.device_id)
if lst:
with contextlib.suppress(ValueError):
lst.remove(sub)
if not lst:
del self._subs[sub.device_id]
# ── Outbound ──────────────────────────────────────────────────────────
async def fanout(self, frame: protocol.Frame, cursor: int | None = None) -> int:
"""Push a frame to every live HTTP subscriber. Returns subscribers
reached — the HTTP half of the delivery count (docs/19 §19.8): a
device reading SSE is a live subscriber, so a frame fanned out here
must not also fire a push."""
if not self.enabled:
return 0
data = frame.to_json()
with self._subs_lock:
subs = [s for lst in self._subs.values() for s in lst]
sent = 0
for s in subs:
try:
s.q.put_nowait((cursor, data))
sent += 1
except queue.Full:
# Slow subscriber: drop it. The client reconnects with
# Last-Event-ID and catches up from the outbox.
logger.info("android: dropping slow HTTP subscriber %s", s.device_id)
s.closed.set()
self._remove_sub(s)
return sent
# ── Auth / limits (handler threads) ───────────────────────────────────
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
"""Verify Bearer token + device identity. Returns the device_id, or
None after sending a 401."""
auth = handler.headers.get("Authorization") or ""
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
if not verify_token(token, self._adapter.token):
_send_json(handler, 401, {"error": "unauthorized"})
return None
device_id = (handler.headers.get("X-Iris-Device") or "").strip()
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
_send_json(handler, 401, {"error": "X-Iris-Device header required"})
return None
if (
not self._adapter.allow_all
and self._adapter.allowed_users
and device_id not in self._adapter.allowed_users
):
logger.warning("android: http rejected: device %s not allowlisted", device_id)
_send_json(handler, 401, {"error": "device not allowed"})
return None
with contextlib.suppress(Exception):
self._devices.touch(device_id)
return device_id
def _rate_limited(self, device_id: str) -> bool:
"""Per-device token bucket, same parameters as the frame limit."""
with self._buckets_lock:
b = self._buckets.get(device_id)
if b is None:
b = self._buckets[device_id] = dispatch._TokenBucket(
dispatch.INBOUND_RATE_PER_S, dispatch.INBOUND_BURST
)
return not b.consume()
# ── POST /v1/frame ────────────────────────────────────────────────────
def _handle_frame(
self, handler: BaseHTTPRequestHandler, device_id: str, frame: protocol.Frame
) -> None:
"""Accept-and-ack (docs/19 §19.7): 202 once the frame is dispatched;
a synchronous validation rejection comes back as the 4xx body.
Long-running handlers (the agent turn) keep running after the ack —
their async output arrives on the event stream."""
loop = self._loop
if loop is None or not loop.is_running():
_send_frame_json(
handler,
503,
protocol.error(protocol.ERR_INTERNAL, "gateway loop not running").to_json(),
)
return
# Reply sink: while this request is being dispatched, frames the
# handler would send via send_to() are captured here instead (the
# adapter's _reply routes them in). ``abandoned`` is set once the
# HTTP response has been sent without consuming the sink (the
# long-running-handler case); the dispatch's finally then delivers
# any late replies via the event stream instead of losing them.
sink: queue.Queue = queue.Queue()
abandoned = threading.Event()
self._adapter._http_register_sink(device_id, (sink, abandoned))
try:
task = asyncio.run_coroutine_threadsafe(
self._dispatch_guarded(frame, device_id, sink, abandoned), loop
)
except Exception:
self._adapter._http_pop_sink(device_id)
_send_frame_json(
handler, 500, protocol.error(protocol.ERR_INTERNAL, "dispatch failed").to_json()
)
return
frames: list[protocol.Frame] = []
deadline = time.monotonic() + ACCEPT_ACK_TIMEOUT_S
while True:
# If the handler is done, drain any replies and stop (no wait).
# This keeps fast/ignored frames from incurring the sink timeout.
if task.done():
while True:
try:
frames.append(sink.get_nowait())
except queue.Empty:
break
break
try:
frames.append(sink.get(timeout=0.01))
except queue.Empty:
if time.monotonic() >= deadline:
# Long-running handler (the agent turn): ack now; late
# replies go to the event stream (the dispatch's finally
# sees ``abandoned`` and delivers them there).
abandoned.set()
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:
_send_json(handler, 202, {"ok": True})
elif len(frames) == 1:
f = frames[0]
status = (
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())
else:
# Multi-frame reply (e.g. a sync replay): deliver it all on the
# event stream; the ack stays plain.
for f in frames:
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(self._deliver_via_stream(f), loop)
_send_json(handler, 202, {"ok": True})
async def _dispatch_guarded(
self,
frame: protocol.Frame,
device_id: str,
sink: queue.Queue,
abandoned: threading.Event,
) -> None:
try:
await dispatch.dispatch_frame(self._adapter, frame, device_id)
except Exception:
logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True)
finally:
# Pop our sink entry (a newer request from the same device may
# have replaced it). If the HTTP response was already sent
# (abandoned), any replies still in the sink are delivered via
# the event stream instead of being lost. In the normal case the
# handler thread has already drained the sink, so nothing is
# left to deliver.
popped = self._adapter._http_pop_sink_if(device_id, sink)
if popped is not None and abandoned.is_set():
while True:
try:
f = sink.get_nowait()
except queue.Empty:
break
with contextlib.suppress(Exception):
await self._deliver_via_stream(f)
async def _deliver_via_stream(self, frame: protocol.Frame) -> None:
await self.fanout(frame, cursor=None)
# ── GET /v1/events (SSE) ──────────────────────────────────────────────
def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
qs = parse_qs(parsed.query)
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")
# Register BEFORE the replay so a frame appended in between is
# fanned out to us (and de-duped by cursor below) instead of lost.
self._add_sub(sub)
try:
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.end_headers()
# 1. Catch-up from the outbox (id = cursor; the envelope also
# carries the cursor for the app's push dedupe).
max_cursor = cursor
for e in self._adapter._outbox.replay(cursor):
c = int(e["cursor"])
max_cursor = max(max_cursor, c)
self._write_sse(handler, "frame", c, _with_cursor(e["frame"], c))
# 2. hello (the HTTP equivalent of hello.ack) + current status.
hello = protocol.hello_ack(
server_caps=self._adapter.server_caps(),
sync_cursor=self._adapter._outbox.latest_cursor(),
channels=self._adapter.channel_list(),
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
)
self._write_sse(handler, "hello", None, hello.to_json())
self._write_sse(
handler, "frame", None, protocol.status(self._adapter.gateway_status()).to_json()
)
# 3. Live frames (cursor=None frames have no id).
while not sub.closed.is_set():
try:
item = sub.q.get(timeout=SSE_HEARTBEAT_S)
except queue.Empty:
self._write_raw(handler, ": hb\n\n")
continue
if item is _STOP:
break
c, data = item
if c is not None and c <= max_cursor:
continue # already replayed above
self._write_sse(handler, "frame", c, data)
except (BrokenPipeError, ConnectionResetError, OSError):
# Client went away mid-stream: normal (the app reconnects with
# Last-Event-ID and catches up from the outbox).
pass
finally:
self._remove_sub(sub)
@staticmethod
def _write_sse(
handler: BaseHTTPRequestHandler, event: str, cursor: int | None, data: str
) -> None:
lines = ""
if cursor is not None:
lines += f"id: {cursor}\n"
lines += f"event: {event}\ndata: {data}\n\n"
handler.wfile.write(lines.encode("utf-8"))
handler.wfile.flush()
@staticmethod
def _write_raw(handler: BaseHTTPRequestHandler, text: str) -> None:
handler.wfile.write(text.encode("utf-8"))
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) ──────────────────────────────────────────
def _handle_poll(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
qs = parse_qs(parsed.query)
cursor = _parse_cursor(qs.get("cursor", [None])[0])
sub = _Subscriber(device_id=device_id, kind="poll")
self._add_sub(sub)
try:
frames: list[str] = []
max_cursor = cursor
for e in self._adapter._outbox.replay(cursor):
c = int(e["cursor"])
max_cursor = max(max_cursor, c)
frames.append(_with_cursor(e["frame"], c))
deadline = time.monotonic() + POLL_TIMEOUT_S
while not frames and not sub.closed.is_set() and time.monotonic() < deadline:
remaining = deadline - time.monotonic()
try:
item = sub.q.get(timeout=min(remaining, 5.0))
except queue.Empty:
continue
if item is _STOP:
break
c, data = item
if c is None or c <= max_cursor:
continue
max_cursor = c
frames.append(data)
hwm = max(max_cursor, self._adapter._outbox.latest_cursor())
_send_json(handler, 200, {"cursor": hwm, "frames": frames})
except (BrokenPipeError, ConnectionResetError, OSError):
# Client went away while we held the poll: normal.
pass
finally:
self._remove_sub(sub)
class _ThreadingHTTPD(ThreadingHTTPServer):
"""One thread per connection (fine at single-user scale); daemon
threads so a stuck handler can't block process exit."""
daemon_threads = True
allow_reuse_address = True
def __init__(self, addr: tuple[str, int], http_server: HttpServer):
super().__init__(addr, _Handler)
self.http_server = http_server
class _Handler(BaseHTTPRequestHandler):
# HTTP/1.0 (default): the connection closes after each response. That
# matches the transport's design (short-lived connections) and avoids
# Content-Length bookkeeping on the streamed SSE response.
server: _ThreadingHTTPD
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
logger.debug("android http: " + fmt, *args)
# ── Routing ───────────────────────────────────────────────────────────
def do_GET(self) -> None: # noqa: N802
hs = self.server.http_server
if not hs.enabled:
_send_json(self, 503, {"error": "http leg disabled"})
return
parsed = urlparse(self.path)
if parsed.path == "/v1/health":
# Unauthenticated by design: it answers "is the gateway
# alive?" and must not reflect tokens, device ids, or versions.
_send_json(self, 200, {"ok": True})
return
if parsed.path == "/v1/events":
device_id = hs._authenticate(self)
if device_id is not None:
hs._handle_sse(self, device_id, parsed)
return
if parsed.path == "/v1/poll":
device_id = hs._authenticate(self)
if device_id is not None:
hs._handle_poll(self, device_id, parsed)
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"})
def do_POST(self) -> None: # noqa: N802
hs = self.server.http_server
if not hs.enabled:
_send_json(self, 503, {"error": "http leg disabled"})
return
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":
_send_json(self, 404, {"error": "not found"})
return
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 frame rate limit exceeded"
).to_json(),
)
return
ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
if ctype != "application/json":
_send_frame_json(
self,
400,
protocol.error(
protocol.ERR_INTERNAL, "Content-Type must be application/json"
).to_json(),
)
return
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
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(
self,
413,
protocol.error(
protocol.ERR_INTERNAL, f"body must be 1..{MAX_BODY_BYTES} bytes"
).to_json(),
)
return
body = self.rfile.read(length)
frame = protocol.Frame.from_json(body)
if frame is None:
_send_frame_json(
self, 400, protocol.error(protocol.ERR_INTERNAL, "invalid frame").to_json()
)
return
hs._handle_frame(self, device_id, frame)
-25
View File
@@ -20,7 +20,6 @@ live under ``get_hermes_home()/"android"/media/tmp``.
Milestone M4.
"""
import asyncio
import contextlib
import hashlib
import logging
@@ -434,27 +433,3 @@ class MediaStore:
for k in stale:
del self._outbound[k]
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:
"""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).
"""
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:
"""Plain ws(s) URL the app connects to (shown next to the QR)."""
scheme = "wss" if secure else "ws"
return f"{scheme}://{host}:{int(port)}/ws"
"""Plain http(s) URL the app connects to (shown next to the QR)."""
scheme = "https" if secure else "http"
return f"{scheme}://{host}:{int(port)}"
# ---------------------------------------------------------------------------
+4 -22
View File
@@ -26,11 +26,8 @@ PROTOCOL_VERSION = 1
# ---------------------------------------------------------------------------
# Pairing / lifecycle
TYPE_HELLO = "hello"
TYPE_HELLO_ACK = "hello.ack"
TYPE_ERROR = "error"
TYPE_PING = "ping"
TYPE_PONG = "pong"
# Chat
TYPE_MESSAGE = "message"
@@ -82,12 +79,8 @@ TYPE_SYNC_DONE = "sync.done"
TYPE_HISTORY = "history"
# 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_OFFER = "media.offer"
TYPE_MEDIA_PULL = "media.pull"
TYPE_MEDIA_PULL_END = "media.pull.end"
# Push / notifications (M5)
TYPE_NOTIFICATION = "notification"
@@ -749,7 +742,8 @@ def media_offer(
thread_id: str | None = None,
message_id: str | None = None,
) -> 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
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)
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:
"""Response to ``media.upload.end``: the ref is cached and may be used in
a ``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
"""Response to ``POST /v1/media``: the ref is cached and may be used in a
``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
return Frame(
type=TYPE_MEDIA_UPLOAD_ACK,
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:
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)
+9 -1
View File
@@ -43,6 +43,12 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
- `--offer-grace S` — with `--pull-offer`, keep listening S seconds after
the final message for a `media.offer` (offers are emitted post-turn,
right after the final; default 15).
- `--http [--http-url http://host:port]` — docs/19: drive the turn over
the **HTTP fallback leg** instead of WS: `GET /v1/health`,
`POST /v1/frame` (the `message.send`), receive over SSE `GET
/v1/events`. The same assertion flags apply. The base URL defaults to
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
(`ANDROID_HTTP_PORT`).
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
@@ -51,7 +57,9 @@ acked, `6` timeout, `7` no final message, `8` upload/sync fail,
`12` assert-tools fail, `13` assert-commentary fail, `14` search fail
(error or zero hits), `15` channel.create/list fail, `16` channel.delete
fail, `17` watch timeout, `18` read.receipt arrived before the sent
message, `19` status frame with empty payload.
message, `19` status frame with empty payload, `20` `--http` health
check failed, `21` `--http` SSE open failed, `22` `--http`
`POST /v1/frame` rejected (4xx).
## E2E driver (`e2e.py`)
+29
View File
@@ -33,6 +33,7 @@ import sys
import uuid
import zlib
from pathlib import Path
from urllib.parse import urlparse
HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
@@ -303,6 +304,33 @@ def s12_sync(env, url, token):
return FAIL, f"probe rc={rc}"
def s13_http_fallback(env, url, token):
"""docs/19: the HTTP fallback leg. The probe drives a full turn over
health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
u = urlparse(url)
scheme = "https" if u.scheme == "wss" else "http"
http_port = os.getenv("ANDROID_HTTP_PORT", "8791")
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
"--send", "Reply with exactly: e2e http fallback OK",
"--timeout", "120")
if rc == 0:
m = re.search(r"== user echo in ([\d.]+)s", out)
echo = float(m.group(1)) if m else None
if echo is not None and echo > 1.5:
return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)"
return PASS, ("health + POST /v1/frame + SSE turn complete"
+ (f"; user echo in {echo:.2f}s" if echo is not None else ""))
if rc == 20:
return FAIL, "health check failed (HTTP leg not running?)"
if rc == 21:
return FAIL, "SSE open failed"
if rc == 22:
return FAIL, "POST /v1/frame rejected"
return FAIL, f"probe rc={rc}"
SCENARIOS = [
(1, "pair", s1_pair),
(2, "text round-trip", s2_text),
@@ -316,6 +344,7 @@ SCENARIOS = [
(10, "media out", s10_media_out),
(11, "push", s11_push),
(12, "reconnect/sync", s12_sync),
(13, "http fallback", s13_http_fallback),
]
+337 -276
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.
Coverage (docs/13-testing.md §13.1, media bullets):
* upload start -> binary chunks -> end reassembles + sha256 verified
* over-limit (declared and mid-stream) -> ``media_too_large``
* upload via ``POST /v1/media`` reassembles + sha256 verified
* over-limit (Content-Length) -> ``media_too_large``
* sha256 mismatch -> ``internal``
* ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the
``MessageEvent`` carries ``media_urls``/``media_types``
* ``send_*`` -> ``media.offer`` (fields + message association)
* ``media.pull`` serves only allowed paths (denied/unknown -> ``not_found``)
* ``GET /v1/media/{id}`` serves only allowed paths (denied/unknown -> ``not_found``)
* kind re-sniffing (don't trust the client)
Run via ``scripts/run_tests.sh tests/gateway/test_android.py``.
@@ -26,6 +26,9 @@ import importlib.util
import json
import os
import sys
import socket
import threading
from http.client import HTTPConnection
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -47,8 +50,14 @@ def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR")
if env:
return Path(env)
# hermes-agent/tests/gateway/test_android.py -> repo root is parents[3].
return Path(__file__).resolve().parents[3] / "gateway-plugin"
# 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():
@@ -105,7 +114,7 @@ def adapter(plugin, monkeypatch):
config = SimpleNamespace(
extra={
"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
},
home_channel=None,
@@ -122,48 +131,206 @@ def adapter(plugin, monkeypatch):
pass
async def _hello(ws) -> dict:
await ws.send(
json.dumps(
{
"v": 1,
"type": "hello",
"payload": {
"token": TOKEN,
"device_id": DEVICE_ID,
"device_name": "Test Device",
"caps": {},
},
}
class HttpTestClient:
"""Mimics the old WS client interface over the HTTP transport (docs/19).
``.send(json_str)`` -> ``POST /v1/frame``; ``.recv(timeout)`` -> the next
frame from the SSE stream (a dict); ``.upload(...)`` -> ``POST /v1/media``
returning the terminal frame (ack or error). The SSE stream is read in a
background thread that feeds an asyncio queue, so ``recv`` is awaitable
exactly like the old WS ``recv``.
"""
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},
)
)
raw = await asyncio.wait_for(ws.recv(), timeout=5)
ack = json.loads(raw)
assert ack["type"] == "hello.ack", f"expected hello.ack, got {ack}"
return ack
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(),
},
)
resp = conn.getresponse()
body = resp.read()
conn.close()
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
async def ws_client(adapter):
"""Connected + paired WS client; the adapter's server runs on an
ephemeral port for the duration of the test."""
from websockets.asyncio.client import connect
"""Connected + paired HTTP client; the adapter's server runs on an
ephemeral port for the duration of the test. Yields ``(client, ack)`` to
match the old WS fixture shape so test bodies need no signature change."""
await adapter.connect()
client = HttpTestClient(adapter._http_server.bound_port)
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(
f"ws://127.0.0.1:{port}/ws", max_size=4 * 1024 * 1024
) as ws:
ack = await _hello(ws)
yield ws, ack
ack = await client.start()
yield client, ack
finally:
await client.close()
await adapter.disconnect()
async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
"""Collect frames (dicts; binary frames as ("binary", bytes)) until
*predicate* matches a JSON frame. Returns all frames collected."""
"""Collect frames (dicts) until *predicate* matches a frame. Returns all
frames collected."""
frames: list = []
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
@@ -172,13 +339,9 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
if remaining <= 0:
raise AssertionError(
"timed out waiting for frame; got: "
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "binary" for f in frames)
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "?" for f in frames)
)
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
if isinstance(raw, (bytes, bytearray)):
frames.append(("binary", bytes(raw)))
continue
frame = json.loads(raw)
frame = await ws.recv(timeout=remaining)
frames.append(frame)
if predicate(frame):
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",
mime: str = "image/png", filename: str = "t.png",
request_id: int = 1) -> dict:
"""Drive a full media.upload flow; returns the terminal frame (ack or error)."""
await ws.send(
json.dumps(
{
"v": 1,
"id": request_id,
"type": "media.upload.start",
"payload": {
"media_ref": media_ref,
"kind": kind,
"mime": mime,
"size": len(data),
"filename": filename,
},
}
)
request_id: int = 1, sha256: str | None = None) -> dict:
"""Drive a media upload via the HTTP leg; returns the terminal frame
(ack or error)."""
return await ws.upload(
media_ref, data, kind=kind, mime=mime, filename=filename, sha256=sha256
)
# Two chunks to exercise reassembly.
half = len(data) // 2
await ws.send(data[:half])
await ws.send(data[half:])
await ws.send(
json.dumps(
{
"v": 1,
"id": request_id + 1,
"type": "media.upload.end",
"payload": {
"media_ref": media_ref,
"sha256": hashlib.sha256(data).hexdigest(),
},
}
# ── 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}"
)
)
frames = await recv_until(ws, lambda f: f.get("type") in ("media.upload.ack", "error"))
return frames[-1]
finally:
await client.close()
# Idempotent: the second call is a no-op on the already-stopped server.
await adapter.disconnect()
# ── 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)
assert terminal["type"] == "media.upload.ack", terminal
assert terminal["id"] == 2
assert terminal["payload"]["ok"] is True
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):
ws, _ = ws_client
limit = adapter.max_upload_bytes
await ws.send(
json.dumps(
{
"v": 1,
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_big",
"kind": "document",
"mime": "application/pdf",
"size": limit + 1,
"filename": "big.pdf",
},
}
)
# Over HTTP the server checks Content-Length before reading the body.
err = await upload_file(
ws, "mu_big", b"x" * (limit + 1), kind="document", mime="application/pdf",
filename="big.pdf",
)
frames = await recv_until(ws, lambda f: f.get("type") == "error")
err = frames[-1]
assert err["type"] == "error"
assert err["payload"]["code"] == "media_too_large"
assert err["id"] == 1
@pytest.mark.asyncio
async def test_upload_midstream_over_limit_rejected(adapter, ws_client):
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
limit = adapter.max_upload_bytes
await ws.send(
json.dumps(
{
"v": 1,
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_mid",
"kind": "document",
"mime": "application/octet-stream",
"size": limit,
"filename": "mid.bin",
},
}
)
)
# Declared size passes the start check; the stream exceeds it.
await ws.send(b"x" * (limit // 2))
await ws.send(b"x" * (limit // 2 + 1))
frames = await recv_until(ws, lambda f: f.get("type") == "error")
assert frames[-1]["payload"]["code"] == "media_too_large"
# The session is discarded: a late end cannot complete it.
await ws.send(
json.dumps(
{
"v": 1,
"id": 2,
"type": "media.upload.end",
"payload": {"media_ref": "mu_mid", "sha256": "0" * 64},
}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
assert frames[-1]["payload"]["code"] == "not_found"
err = await upload_file(ws, "mu_reuse", b"x" * (limit + 1), kind="document")
assert err["type"] == "error"
assert err["payload"]["code"] == "media_too_large"
# The ref is free: a valid upload with the same ref now succeeds.
ok = await upload_file(ws, "mu_reuse", PNG_1X1)
assert ok["type"] == "media.upload.ack", ok
assert ok["payload"]["ok"] is True
@pytest.mark.asyncio
async def test_upload_sha256_mismatch_rejected(adapter, ws_client):
ws, _ = ws_client
await ws.send(
json.dumps(
{
"v": 1,
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_bad",
"kind": "image",
"mime": "image/png",
"size": len(PNG_1X1),
"filename": "bad.png",
},
}
)
)
await ws.send(PNG_1X1)
await ws.send(
json.dumps(
{
"v": 1,
"id": 2,
"type": "media.upload.end",
"payload": {"media_ref": "mu_bad", "sha256": "0" * 64},
}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error")
assert frames[-1]["payload"]["code"] == "internal"
err = await upload_file(ws, "mu_bad", PNG_1X1, sha256="0" * 64)
assert err["type"] == "error"
assert err["payload"]["code"] == "internal"
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)
)
await ws.send(
json.dumps(
{"v": 1, "id": 9, "type": "media.pull", "payload": {"media_id": entry.media_id}}
)
)
chunks: list[bytes] = []
terminal = None
while terminal is None:
raw = await asyncio.wait_for(ws.recv(), timeout=10)
if isinstance(raw, (bytes, bytearray)):
chunks.append(bytes(raw))
continue
frame = json.loads(raw)
if frame.get("type") == "media.pull.end":
terminal = frame
assert terminal["id"] == 9
assert terminal["payload"]["ok"] is True
assert b"".join(chunks) == PNG_1X1
status, body = await ws.pull(entry.media_id)
assert status == 200
assert body == PNG_1X1
@pytest.mark.asyncio
async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
ws, _ = ws_client
# Unknown media_id.
await ws.send(
json.dumps({"v": 1, "id": 1, "type": "media.pull", "payload": {"media_id": "md_nope"}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
assert frames[-1]["payload"]["code"] == "not_found"
status, body = await ws.pull("md_nope")
assert status == 404
assert json.loads(body)["payload"]["code"] == "not_found"
# Known id, but the path fails delivery validation (denylist).
entry = adapter._media.register_outbound(
"/etc/passwd", "document", "text/plain", "passwd", 100
)
await ws.send(
json.dumps(
{"v": 1, "id": 2, "type": "media.pull", "payload": {"media_id": entry.media_id}}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
assert frames[-1]["payload"]["code"] == "not_found"
status, body = await ws.pull(entry.media_id)
assert status == 404
assert json.loads(body)["payload"]["code"] == "not_found"
# Known id, file deleted since the offer.
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)
)
img.unlink()
await ws.send(
json.dumps(
{"v": 1, "id": 3, "type": "media.pull", "payload": {"media_id": entry2.media_id}}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 3)
assert frames[-1]["payload"]["code"] == "not_found"
status, body = await ws.pull(entry2.media_id)
assert status == 404
assert json.loads(body)["payload"]["code"] == "not_found"
# ── M5: push backends (pure) ───────────────────────────────────────────────
@@ -1653,7 +1726,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
@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
await ws.send(
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.
await ws.send(
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)
assert dev["fcm_token"] == "rotated-token"
assert dev["ntfy_topic"] == "dev-topic"
conn = adapter._ws_server.connection(DEVICE_ID)
assert conn.fcm_token == "rotated-token"
assert conn.ntfy_topic == "dev-topic"
# The next offline push targets the rotated token.
# 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()
for _ in range(100):
if not adapter._ws_server.has_devices():
break
await asyncio.sleep(0.01)
with adapter._http_server._subs_lock:
adapter._http_server._subs.clear()
fake = _FakePush()
adapter._push = fake
await adapter.send("android:default", "after rotation", metadata={"notify": True})
@@ -1898,32 +1968,32 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
await adapter.send("android:default", "two", metadata={"notify": True})
assert adapter._outbox.latest_cursor() == 2
from websockets.asyncio.client import 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:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["sync_cursor"] == 2
await ws.send(
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
texts = [f["payload"]["text"] for f in frames if f.get("type") == "message"]
assert texts == ["one", "two"]
done = frames[-1]
assert done["id"] == 10
assert done["payload"]["cursor"] == 2
# A sync at the current cursor replays nothing.
await ws.send(
json.dumps({"v": 1, "id": 11, "type": "sync", "payload": {"cursor": 2}})
)
frames = await recv_until(
ws, lambda f: f.get("type") == "sync.done" and f.get("id") == 11
)
assert len(frames) == 1
ack = await ws.start()
assert ack["payload"]["sync_cursor"] == 2
await ws.send(
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
texts = [f["payload"]["text"] for f in frames if f.get("type") == "message"]
assert texts == ["one", "two"]
done = frames[-1]
assert done["id"] == 10
assert done["payload"]["cursor"] == 2
# A sync at the current cursor replays nothing.
await ws.send(
json.dumps({"v": 1, "id": 11, "type": "sync", "payload": {"cursor": 2}})
)
frames = await recv_until(
ws, lambda f: f.get("type") == "sync.done" and f.get("id") == 11
)
assert len(frames) == 1
finally:
await ws.close()
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):
"""A device that never received a push reports last_pushed_cursor=0 in
hello.ack (docs/08 §8.7 dedupe watermark)."""
from websockets.asyncio.client import connect
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port)
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["last_pushed_cursor"] == 0
ack = await ws.start()
assert ack["payload"]["last_pushed_cursor"] == 0
finally:
await ws.close()
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
sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same
chat coalesce into one push; a failed push does not advance the cursor."""
from websockets.asyncio.client import connect
fake = _FakePush()
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
@@ -1978,12 +2044,12 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["last_pushed_cursor"] == 3
ack = await ws.start()
assert ack["payload"]["last_pushed_cursor"] == 3
finally:
await ws.close()
await adapter.disconnect()
@@ -1992,25 +2058,23 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
"""Frames replayed by sync carry their outbox cursor in the envelope so
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
frames carry no cursor."""
from websockets.asyncio.client import connect
await adapter.send("android:default", "one", metadata={"notify": True})
await adapter.send("android:default", "two", metadata={"notify": True})
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
await _hello(ws)
await ws.send(
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
msgs = [f for f in frames if f.get("type") == "message"]
assert [f["cursor"] for f in msgs] == [1, 2]
# sync.done itself carries no envelope cursor.
assert "cursor" not in frames[-1]
await ws.start()
await ws.send(
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
msgs = [f for f in frames if f.get("type") == "message"]
assert [f["cursor"] for f in msgs] == [1, 2]
# sync.done itself carries no envelope cursor.
assert "cursor" not in frames[-1]
finally:
await ws.close()
await adapter.disconnect()
@@ -2129,30 +2193,27 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
@pytest.mark.asyncio
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()
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
await ws.send(
json.dumps(
{
"v": 1,
"type": "hello",
"payload": {
"token": "wrong-token",
"device_id": DEVICE_ID,
"device_name": "Bad",
"caps": {},
},
}
)
port = adapter._http_server.bound_port
def _req() -> int:
conn = HTTPConnection("127.0.0.1", port, timeout=5)
conn.request(
"GET",
"/v1/events",
headers={"Authorization": "Bearer wrong-token", "X-Iris-Device": DEVICE_ID},
)
raw = await asyncio.wait_for(ws.recv(), timeout=5)
err = json.loads(raw)
assert err["type"] == "error"
assert err["payload"]["code"] == "auth"
resp = conn.getresponse()
resp.read()
status = resp.status
conn.close()
return status
status = await asyncio.to_thread(_req)
assert status == 401
finally:
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
-435
View File
@@ -1,435 +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
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))
elif frame.type == protocol.TYPE_MESSAGE_SEND:
await self._adapter.on_message_send(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_CREATE:
await self._adapter.on_channel_create(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_RENAME:
await self._adapter.on_channel_rename(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
await self._adapter.on_channel_set_default(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
await self._adapter.on_channel_favorite(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_ICON:
await self._adapter.on_channel_icon(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
await self._adapter.on_channel_set_automation(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await self._adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST:
await self._adapter.on_channel_list(frame, device_id)
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
await self._adapter.on_commands_catalog(frame, device_id)
elif frame.type == protocol.TYPE_SEARCH:
await self._adapter.on_search(frame, device_id)
elif frame.type == protocol.TYPE_SYNC:
await self._adapter.on_sync(frame, device_id)
elif frame.type == protocol.TYPE_HISTORY:
await self._adapter.on_history(frame, device_id)
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
await self._adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
await self._adapter.on_media_upload_start(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
await self._adapter.on_media_upload_end(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_PULL:
await self._adapter.on_media_pull(frame, device_id)
elif frame.type == protocol.TYPE_FCM_REGISTER:
await self._adapter.on_fcm_register(frame, device_id)
# Unknown types are ignored (forward-compat).
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)