Compare commits

..
Author SHA1 Message Date
Bailey Dixon 6c5ecbb028 release(android): android-v1.16.0 2026-09-09 22:32:25 -04:00
Bailey Dixon bd5a1c3335 Merge pull request #573 from Codename-11/release/plugin-1.11.2
release(server): server-v1.11.2
2026-09-09 22:25:27 -04:00
Bailey Dixon 2c740c9f04 release(server): server-v1.11.2 2026-09-09 22:23:26 -04:00
Bailey Dixon 3ec89680ed Merge pull request #572 from Codename-11/fix/android-endpoint-cache-race
fix(android): serialize endpoint probe invalidation
2026-09-09 22:04:10 -04:00
Bailey Dixon 42860d38cd chore: merge queued Android fixes for endpoint verification 2026-09-09 21:49:24 -04:00
Bailey Dixon ef4b3bdb6c Merge pull request #571 from Codename-11/fix/android-gateway-cold-start
fix(android): wake gateway on cold foreground
2026-09-09 21:48:53 -04:00
Bailey Dixon 44bbb16cd3 chore: merge current dev before gateway startup integration 2026-09-09 21:35:37 -04:00
Bailey Dixon 1f5b7e68fc Merge pull request #570 from Codename-11/fix/android-basic-auth-paste
fix(android): normalize pasted dashboard credentials
2026-09-09 21:35:00 -04:00
Bailey Dixon 4bb8d6fa7b chore: merge current dev for endpoint invalidation verification 2026-09-09 21:20:42 -04:00
Bailey Dixon 6395e73927 Merge remote-tracking branch 'origin/dev' into fix/android-basic-auth-paste
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:20:33 -04:00
Bailey Dixon c956232b96 fix(android): serialize endpoint probe invalidation 2026-09-09 21:20:16 -04:00
Bailey Dixon 9814cdca55 chore: merge current dev for gateway startup verification 2026-09-09 21:17:51 -04:00
Bailey Dixon 0c337c9384 Merge pull request #569 from Codename-11/fix/plugin-availability-probe
fix(plugin): share relay availability checks
2026-09-09 21:06:46 -04:00
Bailey Dixon 273e3f5aff fix(android): wake gateway on cold foreground 2026-09-09 21:05:02 -04:00
Bailey DixonandJack Hunzicker 179080d6d9 fix(plugin): share relay availability checks
Co-authored-by: Jack Hunzicker <JackHunzicker@users.noreply.github.com>
2026-09-09 20:57:22 -04:00
Bailey Dixon ac1f42b7d5 Merge remote-tracking branch 'origin/dev' into fix/android-basic-auth-paste
# Conflicts:
#	CHANGELOG.md
2026-09-09 20:21:19 -04:00
Bailey Dixon 88591fca89 fix(android): normalize pasted dashboard credentials
Refs #541
2026-09-09 20:20:48 -04:00
Bailey Dixon 00c5f5daa2 Merge pull request #566 from trevornk/fix/connection-owned-dashboard-auth
fix(android): bind dashboard auth to connection-owned routes
2026-09-09 20:14:28 -04:00
Bailey Dixon 69b6c005cd chore: merge current dev for dashboard auth verification 2026-09-09 19:50:39 -04:00
Bailey Dixon c902215a15 Merge pull request #560 from JackHunzicker/contrib/git-state-callback-test-order
test(android): await git commit success callback
2026-09-09 19:37:25 -04:00
Bailey Dixon d39368bc51 Merge pull request #568 from Codename-11/fix/470-bot-mode-list-identity
fix(android): prevent duplicate Bot Mode keys across connections
2026-09-09 19:34:28 -04:00
Bailey Dixon 414dc08b9f Merge branch 'dev' into contrib/git-state-callback-test-order 2026-09-09 19:28:12 -04:00
Bailey Dixon 3b78a17bd7 chore: merge current dev for Bot Mode verification 2026-09-09 19:22:10 -04:00
Bailey Dixon a5efb9ec96 fix(android): scope Bot Mode item identity to connection and profile 2026-09-09 19:21:34 -04:00
Bailey Dixon e5228f2089 Merge pull request #567 from Codename-11/fix/android-detached-subagent-preview
fix(android): preserve chat activity and correct feedback ownership
2026-09-09 17:18:15 -04:00
trevornk c1413c494f fix(android): bind dashboard auth to connection-owned routes
Refs #565
2026-09-09 11:44:14 -05:00
Jack bb23e6ab48 test(android): await git commit success callback
Mutation success is published before detail refresh finishes and the callback runs. Await callback delivery explicitly instead of treating the state flow as a callback-completion barrier.

Signed-off-by: Jack <JLHunzicker@gmail.com>
2026-09-07 22:27:56 -05:00
46 changed files with 1647 additions and 144 deletions
+5 -1
View File
@@ -111,4 +111,8 @@ jobs:
plugin/tests/test_git_state.py \
plugin/tests/test_git_state_write.py \
plugin/tests/test_git_state_extras.py \
plugin/tests/test_mobile_plugin_store.py
plugin/tests/test_mobile_plugin_store.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
+5 -1
View File
@@ -107,7 +107,11 @@ jobs:
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_proactive_channel.py \
plugin/tests/test_android_phone_status.py
plugin/tests/test_android_phone_status.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
package:
name: Build and publish Plugin package
+13
View File
@@ -6,12 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [Android 1.16.0] - 2026-09-09
### Fixed
- Android no longer crashes when a route probe finishes while a network change invalidates the endpoint cache.
- Android opens an authenticated Gateway chat on the first foreground launch instead of waiting for a background-and-resume cycle to leave the waking state. (#495, #528)
- Android Dashboard sign-in removes pasted line breaks from username and password fields, matching the browser login while preserving every other credential character. (#541)
- Android keeps saved Dashboard sign-ins bound to their connection when switching gateways, rather than letting a stale resolver route invalidate another connection's session.
- Bot Mode no longer crashes when different connections have bots with the same profile name. Both the conversation list and Active Now strip preserve each bot's connection, and opening progress appears only on the selected bot.
- Android feedback uses themed banners and action cards instead of platform toasts and default snackbars. Dashboard errors no longer misidentify missing resources as an outdated Relay. Developer settings includes local-only message previews.
- Missing chat attachments show their error and retry in the attachment card without repeated global popups. Global action messages occupy the top message area instead of covering the composer.
- Chat distinguishes session preparation from response streaming and retains initialization errors that arrive before the session acknowledgement. Long-press the agent header to open a live session-diagnostics drawer.
- Delegated-agent activity survives parent replies and leaves compact history entries for later read-only review. The activity strip appears only while work runs; historical process views cannot stop or dismiss live work. (#447)
## [Plugin 1.11.2] - 2026-09-09
### Fixed
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
- **`android_*` tools resolve bridge credentials written after host startup.** Requests retry profile-scoped env and active bridge-session credentials after a stale token is rejected, and vision navigation now shares the same current Relay transport instead of the retired standalone default.
- **`android_setup` accepts both its canonical and legacy schema keys.** `bridge_session_token` and `pairing_code` are accepted, while a missing token returns a structured error.
- **Android tool setup tests use a temporary Hermes home.** Test runs no longer write bridge settings into a developer environment.
+6 -4
View File
@@ -1,15 +1,17 @@
# Hermes-Relay Plugin v__VERSION__
**Release Date:** August 31, 2026
**Release Date:** September 9, 2026
## Summary
This patch restores native installation compatibility on affected Hermes versions and makes Relay prompt context advertise only capabilities the selected session can actually call. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
This patch makes Android and Desktop tool availability fast and reliable when Relay is unavailable, starts late-created Android bridge sessions without restarting Hermes, and restores compatibility with both current and legacy `android_setup` arguments. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
## Fixed
- **Native installer compatibility.** The plugin keeps its complete current manifest while avoiding the installer/runtime schema mismatch that caused `manifest_version 2` installs to fail after an apparent Hermes update.
- **Capability-gated phone context.** Phone-control and cross-platform delivery guidance now follows the selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` callables.
- **Fast, accurate tool availability.** Android and Desktop tool checks use explicit IPv4 loopback and one bounded health snapshot instead of repeated per-tool connection attempts. Multi-PC capability advertisements remain isolated, and unavailable Relay clients continue to fail closed.
- **Late Android bridge recovery.** `android_*` calls retry profile-scoped and active bridge-session credentials after a stale token is rejected, so a phone connected after Hermes startup becomes usable without restarting the host.
- **Compatible Android setup arguments.** `android_setup` accepts the canonical `bridge_session_token` and `pairing_code` fields as well as their legacy aliases, with structured errors when no usable credential is supplied.
- **Isolated setup tests.** Android tool setup tests use a temporary Hermes home instead of writing bridge settings into the operator environment.
## Install / update
+16 -17
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.15.1
# Hermes-Relay Android v1.16.0
**Release Date:** September 2, 2026
**Release Date:** September 9, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.15.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.16.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,29 +12,28 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch improves chat, media, and voice reliability. It reduces memory-heavy work, keeps attachment previews stable through rotation, and makes follow-up message behavior and voice errors easier to understand.
This release makes startup and connection switching safer, prevents a network-change crash, and keeps Bot Mode and delegated work stable across multiple Hermes gateways. Chat feedback, attachment failures, and session preparation are also easier to understand and review.
## Changed
- Choose Correct now or Queue next from a slim tray behind the composer. Chat settings sets the default; the tray overrides one message. Stop pauses the queue, Resume continues it, and editing or removing an item preserves its successors.
- Chat and Voice use readable centered layouts on wider screens, including landscape Voice Focus.
- Delegated-agent activity remains available after parent replies as compact, bounded, read-only history. The live strip appears only while work is active, and historical views cannot control a running process.
- Android feedback uses themed banners and action cards. A long-press on the agent header opens session diagnostics, and Developer settings can preview message surfaces locally.
## Fixed
- Correction and delivery labels remain visible inside user-message bubbles.
- Voice errors open in a scrollable dialog with separate Retry and Dismiss actions.
- Attachment previews remain open through rotation, and video previews preserve their proportions.
- Release optimization preserves the native speech configuration required for wake-word startup.
- Standard Hermes attachments download directly to disk with bounded size checks.
- Session refresh avoids repeated request loops; history loads, Markdown, image previews, and media exports keep memory use bounded.
- Image-generation progress stays visible through gaps between interim replies and returned media.
- The first prompt waits for Gateway session readiness. Ownership refusals retain the prompt for retry and show the original server error.
- An authenticated Gateway chat opens on the first foreground launch instead of waiting for a background-and-resume cycle.
- Network changes can invalidate route probes without racing the endpoint cache or crashing Android.
- Saved Dashboard sign-ins stay bound to their owning connection when switching gateways; an outgoing route cannot invalidate another connection's session.
- Dashboard sign-in removes pasted line breaks from username and password fields while preserving every other credential character.
- Bot Mode and Active Now keep connection identity when different gateways expose the same profile name, and progress opens only on the selected bot.
- Missing attachments keep their error and Retry action in the attachment card without repeated global messages.
- Chat distinguishes session preparation from response streaming and retains initialization errors received before session acknowledgement.
## Install / Verify
- App version: **1.15.1** (versionCode **54**).
- App version: **1.16.0** (versionCode **55**).
- Standard Chat, sessions, profiles, Manage, voice, and ordinary media use current upstream Hermes. Speech-to-text still requires a configured provider on the host.
- Hermes-Relay Plugin **1.11.1** remains the current optional plugin release; this Android patch does not require a new plugin version.
- Paused text queues can be restored. Attachment bytes are not persisted in preferences; unrestorable attachment queues must be reviewed and sent again.
- Hermes-Relay Plugin **1.11.2** is the matching optional release for Relay tools; the Android connection, Bot Mode, and delegated-activity fixes do not require the plugin.
- Already-erased or revoked Dashboard credentials still require a legitimate sign-in; this release prevents cross-connection invalidation going forward.
- Explicit Direct API/API-only connections remain supported and are not used as silent failover for Dashboard-owned chats.
- Granular Device Control and the system Voice Focus overlay remain sideload-only.
@@ -0,0 +1,100 @@
package com.hermesandroid.relay.network.shared
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.InterruptedIOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@RunWith(AndroidJUnit4::class)
class EndpointResolverConcurrencyInstrumentedTest {
@Test
fun probeCompletionRacingInvalidation_staysCrashFreeOnAndroidCollections() = runBlocking {
repeat(25) { iteration ->
val candidateCount = 8
val requestsStarted = CountDownLatch(candidateCount)
val releaseRequests = CountDownLatch(1)
val raceGate = CountDownLatch(1)
val requestSequence = AtomicInteger(0)
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
if (requestSequence.incrementAndGet() <= candidateCount) {
requestsStarted.countDown()
releaseRequests.await(5, TimeUnit.SECONDS)
throw InterruptedIOException("instrumented invalidation race")
}
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("{}".toResponseBody())
.build()
}
.build()
val resolver = EndpointResolver(client)
val candidates = (1..candidateCount).map { index ->
EndpointCandidate(
role = "instrumented-$iteration-$index",
priority = 0,
api = ApiEndpoint(host = "127.0.0.1", port = 1, tls = false),
)
}
try {
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(candidates, EndpointSurface.Api)
}
assertTrue(requestsStarted.await(5, TimeUnit.SECONDS))
val invalidation = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
resolver.clearCache()
}
val completions = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
releaseRequests.countDown()
}
raceGate.countDown()
withTimeout(2_000L) {
invalidation.await()
completions.await()
staleResolve.await()
}
assertTrue(resolver.cacheSnapshot().isEmpty())
resolver.clearCache()
val freshWinner = withTimeout(2_000L) {
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
}
assertEquals(candidates.first(), freshWinner)
assertTrue(
resolver.probeOutcomes.value.getValue(
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
).reachable,
)
} finally {
raceGate.countDown()
releaseRequests.countDown()
client.dispatcher.executorService.shutdown()
}
}
}
}
@@ -5,11 +5,12 @@ import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@@ -17,25 +18,28 @@ import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -85,6 +89,8 @@ class GatewayForegroundRecoveryInstrumentedTest {
@Volatile
private var persistedHistory: List<MessageItem> = emptyList()
private val historySignInRequired = MutableStateFlow(false)
private val coldStartAdmissionEnabled = MutableStateFlow(false)
private val coldStartGatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
@Before
fun setUp() {
@@ -119,6 +125,19 @@ class GatewayForegroundRecoveryInstrumentedTest {
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
val admissionEnabled by coldStartAdmissionEnabled.collectAsStateWithLifecycle()
val admissionAvailability by coldStartGatewayAvailability.collectAsStateWithLifecycle()
LaunchedEffect(admissionEnabled, admissionAvailability) {
if (admissionEnabled) {
viewModel.setChatVisible(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = admissionAvailability,
),
)
}
}
MaterialTheme {
Column(Modifier.testTag("contract-transcript")) {
GatewayBackgroundProcessStrip(
@@ -156,6 +175,53 @@ class GatewayForegroundRecoveryInstrumentedTest {
fixture.awaitRpc("session.resume")
}
@Test
fun authenticatedUnknownColdLaunch_opensObservationSocketWithoutLifecycleBounce() {
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
coldStartGatewayAvailability.value = GatewayAvailability.Unknown
coldStartAdmissionEnabled.value = true
compose.waitUntil(5_000) {
gatewayClient.connectionState.value == GatewayConnectionState.Ready
}
serverSocket = fixture.awaitServerSocket()
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
controlMethods.forEach { method ->
assertEquals(
"cold observation sent $method",
baseline.getValue(method),
fixture.rpcCount(method),
)
}
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
@@ -1,3 +1,3 @@
v1.15.1 - Steadier chat, media, and voice
v1.16.0 - Safer startup, connections, and activity
More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.
Gateway chat now opens reliably on a cold launch. Saved Dashboard sign-ins stay bound to the correct connection, pasted credentials ignore accidental line breaks, and network changes no longer race the route cache. Bot Mode supports duplicate profile names across gateways, while delegated work, session setup, attachment errors, and feedback remain visible and easier to review.
+73
View File
@@ -1,6 +1,79 @@
{
"schema": 3,
"versions": [
{
"version": "1.16.0",
"title": "Safer startup, connections, and activity",
"date": "2026-09-09",
"summary": "Gateway chat opens reliably from a cold launch, saved sign-ins stay with the correct connection, and network changes no longer race the route cache. Bot Mode and delegated-work feedback also remain stable across multiple gateways and later review.",
"changes": [
{
"id": "gateway-cold-start",
"kind": "fixed",
"title": "Open Gateway chat on the first launch",
"summary": "An authenticated Gateway wakes and opens from a cold foreground start instead of waiting for the app to background and resume.",
"highlight": true
},
{
"id": "connection-owned-signin",
"kind": "fixed",
"title": "Keep saved sign-ins with their connection",
"summary": "Switching gateways cannot reuse an outgoing resolver route to invalidate another connection's saved Dashboard session.",
"highlight": true
},
{
"id": "network-change-invalidation",
"kind": "fixed",
"title": "Recover safely when the network changes",
"summary": "Route-probe completion and endpoint-cache invalidation are serialized so Wi-Fi, mobile-data, VPN, or Tailscale changes do not trigger the reported crash.",
"highlight": true
},
{
"id": "bot-mode-connection-identity",
"kind": "fixed",
"title": "Open same-named bots from multiple gateways",
"summary": "Bot Mode and Active Now keep connection and profile identity together, avoiding duplicate list keys and opening progress on the selected bot."
},
{
"id": "delegated-activity-receipts",
"kind": "improved",
"title": "Review delegated work after it finishes",
"summary": "Compact, bounded activity receipts survive parent replies and remain available read-only, while the live strip appears only during active work.",
"highlight": true
},
{
"id": "chat-feedback-surfaces",
"kind": "improved",
"title": "Keep feedback with the surface that owns it",
"summary": "Themed banners and action cards replace platform popups, global actions stay clear of the composer, and local Developer previews make feedback states inspectable."
},
{
"id": "attachment-error-recovery",
"kind": "fixed",
"title": "Retry missing attachments in place",
"summary": "A missing attachment keeps its error and Retry action in the attachment card without producing repeated global messages."
},
{
"id": "session-preparation-diagnostics",
"kind": "improved",
"title": "See session preparation and initialization failures",
"summary": "Chat distinguishes session preparation from response streaming, retains early initialization errors, and opens session diagnostics from the agent header."
},
{
"id": "pasted-dashboard-credentials",
"kind": "fixed",
"title": "Paste Dashboard credentials without hidden line breaks",
"summary": "Username and password fields remove pasted carriage returns and line feeds while preserving every other credential character."
}
],
"compatibility": [
"Standard Chat, sessions, profiles, Manage, voice, Bot Mode, and delegated activity continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
"Hermes-Relay Plugin 1.11.2 is the matching optional release for Relay tools. Existing erased or revoked Dashboard credentials still require a legitimate sign-in.",
"Granular Device Control and the system Voice Focus overlay remain sideload-only."
],
"playNotes": "Gateway chat now opens reliably on a cold launch. Saved Dashboard sign-ins stay bound to the correct connection, pasted credentials ignore accidental line breaks, and network changes no longer race the route cache. Bot Mode supports duplicate profile names across gateways, while delegated work, session setup, attachment errors, and feedback remain visible and easier to review.",
"sections": []
},
{
"version": "1.15.1",
"title": "Steadier chat, media, and voice",
+14 -14
View File
@@ -1,24 +1,24 @@
v1.15.1 - Steadier chat, media, and voice
v1.16.0 - Safer startup, connections, and activity
Summary
* Chats use less memory, attachment previews stay in place, and voice failures are easier to recover from. Follow-up controls make it clear whether a message changes the current response or waits for the next turn.
* Gateway chat opens reliably from a cold launch, saved sign-ins stay with the correct connection, and network changes no longer race the route cache. Bot Mode and delegated-work feedback also remain stable across multiple gateways and later review.
Highlights
* Choose when follow-up messages are sent — A slim tray behind the composer offers Correct now or Queue next. Chat settings sets the default, and a composer choice applies to one message. Stop pauses pending work until Resume; editing or removing an item keeps the remaining queue usable.
* Keep attachment previews open through rotation — Image, video, audio, PDF, text, and file previews stay open as the screen rotates. Videos retain their original proportions.
* Keep large chats and media manageable — Automatic session refresh no longer loops. Routine history loads, chat rendering, image previews, and media exports use bounded memory instead of allocating entire large responses.
* Open Gateway chat on the first launch — An authenticated Gateway wakes and opens from a cold foreground start instead of waiting for the app to background and resume.
* Keep saved sign-ins with their connection — Switching gateways cannot reuse an outgoing resolver route to invalidate another connection's saved Dashboard session.
* Recover safely when the network changes — Route-probe completion and endpoint-cache invalidation are serialized so Wi-Fi, mobile-data, VPN, or Tailscale changes do not trigger the reported crash.
* Review delegated work after it finishes — Compact, bounded activity receipts survive parent replies and remain available read-only, while the live strip appears only during active work.
Improved
* Make better use of wider screens — Chat and Voice keep text and controls on readable centered layouts. Landscape Voice Focus separates identity controls from conversation activity.
* Keep feedback with the surface that owns it — Themed banners and action cards replace platform popups, global actions stay clear of the composer, and local Developer previews make feedback states inspectable.
* See session preparation and initialization failures — Chat distinguishes session preparation from response streaming, retains early initialization errors, and opens session diagnostics from the agent header.
Fixed
* Read message delivery status clearly — Correction and delivery labels use contrasting text instead of disappearing into the message bubble.
* Read and dismiss voice errors — Voice errors open in a contained dialog with scrollable details and separate Retry and Dismiss actions, without overlapping chat controls.
* Fix wake-word startup in release builds — Release optimization now preserves the native speech configuration names needed to initialize wake-word detection.
* Download attachments with less memory — Standard Hermes attachments stream into the on-disk cache while download size limits remain enforced.
* Keep image-generation progress visible — The working indicator stays visible between interim replies and the generated image, including gateways that omit tool activity events.
* Wait for new chats to be ready — The first message waits for the Gateway session to initialize. Ownership refusals keep the prompt retryable and show the server's error.
* Open same-named bots from multiple gateways — Bot Mode and Active Now keep connection and profile identity together, avoiding duplicate list keys and opening progress on the selected bot.
* Retry missing attachments in place — A missing attachment keeps its error and Retry action in the attachment card without producing repeated global messages.
* Paste Dashboard credentials without hidden line breaks — Username and password fields remove pasted carriage returns and line feeds while preserving every other credential character.
Compatibility
* Standard Chat, sessions, media, and voice continue to use upstream Hermes. Voice transcription still requires a configured speech-to-text provider on the Hermes host.
* Paused text queues can be restored. Attachment bytes are not stored in preferences; an attachment queue that cannot be restored must be reviewed and sent again.
* Standard Chat, sessions, profiles, Manage, voice, Bot Mode, and delegated activity continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.
* Hermes-Relay Plugin 1.11.2 is the matching optional release for Relay tools. Existing erased or revoked Dashboard credentials still require a legitimate sign-in.
* Granular Device Control and the system Voice Focus overlay remain sideload-only.
@@ -149,7 +149,10 @@ class EndpointResolver(
)
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val inFlightProbes = ConcurrentHashMap<String, Deferred<Boolean>>()
// Every access is owned by [probeStateLock]. This must not be a
// concurrently-mutated collection: clearCache() takes a stable snapshot
// while completion callbacks remove finished probes.
private val inFlightProbes = mutableMapOf<String, Deferred<Boolean>>()
private val probeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val probeStateLock = Any()
private var probeGeneration = 0L
@@ -486,7 +489,13 @@ class EndpointResolver(
probe(candidate, surface, generation)
}.also { deferred ->
inFlightProbes[key] = deferred
deferred.invokeOnCompletion { inFlightProbes.remove(key, deferred) }
deferred.invokeOnCompletion {
synchronized(probeStateLock) {
// Identity-aware removal prevents an invalidated
// probe from removing its fresh replacement.
inFlightProbes.remove(key, deferred)
}
}
deferred.start()
}
}
@@ -1525,10 +1525,14 @@ class DashboardApiClient(
password: String,
next: String = "/",
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
// Match the Dashboard's single-line HTML username/password controls:
// remove only forbidden line breaks and preserve every other code point.
val normalizedUsername = username.replace("\r", "").replace("\n", "")
val normalizedPassword = password.replace("\r", "").replace("\n", "")
val payload = buildJsonObject {
put("provider", provider)
put("username", username)
put("password", password)
put("username", normalizedUsername)
put("password", normalizedPassword)
put("next", next)
}
val httpUrl = resolveUrl("/auth/password-login")
@@ -73,6 +73,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Connection
@@ -84,6 +85,13 @@ import kotlinx.coroutines.launch
internal enum class BotModeFilter { All, Bots, Groups }
/** Bundle-compatible key; length-prefix the connection so delimiters cannot alias owners. */
internal val BotRosterEntry.lazyItemKey: String
get() {
val owner = route?.key ?: return "bot:unbound:${profile.name}"
return "bot:route:${owner.connectionId.length}:${owner.connectionId}:${owner.profileName}"
}
private sealed interface BotModeRow {
val activityAtMs: Long
@@ -109,7 +117,7 @@ fun BotModeScreen(
val scope = rememberCoroutineScope()
val resources = LocalResources.current
val snackbar = remember { SnackbarHostState() }
var openingProfile by remember { mutableStateOf<String?>(null) }
var openingRoute by remember { mutableStateOf<BotGatewayRouteKey?>(null) }
var showCreateBot by remember { mutableStateOf(false) }
var creatingBot by remember { mutableStateOf(false) }
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -136,10 +144,11 @@ fun BotModeScreen(
onBack = onBack,
onRefresh = connectionViewModel::refreshBotMode,
onSelectGateway = { selectedGatewayId = it },
openingProfile = openingProfile,
openingRoute = openingRoute,
onOpenBot = { bot ->
val route = bot.route ?: return@BotModeContent
openingProfile = bot.profile.name
if (openingRoute != null) return@BotModeContent
openingRoute = route.key
scope.launch {
val result = connectionViewModel.ensureCanonicalBotChat(route)
.map { it.resolvedSessionId }
@@ -147,7 +156,7 @@ fun BotModeScreen(
onSuccess = { onOpenBotChat(route, it) },
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
)
openingProfile = null
openingRoute = null
}
},
onOpenGroup = { onOpenGroup(it.key) },
@@ -206,7 +215,7 @@ internal fun BotModeContent(
onBack: () -> Unit,
onRefresh: () -> Unit,
onSelectGateway: (String?) -> Unit,
openingProfile: String? = null,
openingRoute: BotGatewayRouteKey? = null,
onOpenBot: (BotRosterEntry) -> Unit,
onOpenGroup: (BotGroupRoom) -> Unit,
onNewBot: () -> Unit,
@@ -404,12 +413,12 @@ internal fun BotModeContent(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
items(activeBots, key = { it.profile.name }) { bot ->
items(activeBots, key = { it.lazyItemKey }) { bot ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(78.dp)
.clickable(enabled = openingProfile == null) { onOpenBot(bot) },
.clickable(enabled = openingRoute == null) { onOpenBot(bot) },
) {
Box {
botAvatar(bot, 56.dp)
@@ -455,7 +464,7 @@ internal fun BotModeContent(
itemsIndexed(
items = rows,
key = { _, row -> when (row) {
is BotModeRow.Bot -> "bot:${row.value.profile.name}"
is BotModeRow.Bot -> row.value.lazyItemKey
is BotModeRow.Group -> "group:${row.value.key}"
} },
) { index, row ->
@@ -463,7 +472,7 @@ internal fun BotModeContent(
is BotModeRow.Bot -> BotConversationRow(
bot = row.value,
connectionLabel = row.value.route?.connectionLabel,
opening = openingProfile == row.value.profile.name,
opening = openingRoute != null && openingRoute == row.value.route?.key,
onClick = { onOpenBot(row.value) },
avatar = { botAvatar(row.value, 56.dp) },
nowMs = nowMs,
@@ -463,6 +463,21 @@ internal fun shouldShowRetainedHistoryDashboardSignIn(
gatewayAvailability == GatewayAvailability.SignInRequired &&
!apiReachable
/**
* A foreground Gateway-owned Chat must be allowed to open its observation
* socket before `gateway.ready` can make chatReady true. Authentication and
* protocol failures are terminal; ordinary reachability failures remain
* visible so the Gateway client's bounded retry policy can recover them.
*/
internal fun shouldOwnVisibleGateway(
appForeground: Boolean,
isGatewayTransport: Boolean,
gatewayAvailability: GatewayAvailability,
): Boolean = appForeground &&
isGatewayTransport &&
gatewayAvailability != GatewayAvailability.SignInRequired &&
gatewayAvailability != GatewayAvailability.Unsupported
internal fun shouldPresentChatFailureDuringDashboardSignIn(
failure: ChatFailureNotice,
dashboardSignInRequired: Boolean,
@@ -1212,8 +1227,12 @@ fun ChatScreen(
// the foreground. setChatVisible owns that edge; an ordinary Gateway open
// warms only the observation socket and never attaches a saved session.
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
val visibleGatewayOwner = appForeground && chatReady && isGatewayTransport
LaunchedEffect(isGatewayTransport, appForeground, chatGatewayAvailability) {
val visibleGatewayOwner = shouldOwnVisibleGateway(
appForeground = appForeground,
isGatewayTransport = isGatewayTransport,
gatewayAvailability = chatGatewayAvailability,
)
chatViewModel.setChatVisible(visibleGatewayOwner)
// updateGatewayClient owns the one-time catalog/reasoning bootstrap for
// a newly-ready socket. Repeating it here created a duplicate cold-open
@@ -2987,9 +2987,10 @@ class ChatViewModel : ViewModel() {
_reasoningDisplay.value = null
}
}
if (changed && client != null && streamRecovery != null &&
AppForegroundTracker.isForeground.value
) {
// Visibility can arrive before the runtime binder publishes its client.
// Start the same socket-only warmup in either ordering; prewarmGateway
// retains the directory barrier and exact-checkpoint ownership rules.
if (changed && client != null && chatVisible) {
prewarmGateway()
}
if (changed && client != null) requestSessionActivityRefresh()
@@ -517,11 +517,22 @@ internal fun resolveEffectiveDashboardUrl(
connection.authenticatedDashboardOrigin
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
?.let { return it }
endpoint?.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { return it }
endpoint?.dashboard?.url
// The resolver publishes independently of the active connection. During a
// switch its last winner can still belong to the outgoing installation.
// Never use that winner as authority for the incoming connection's bearer.
val routes = connection.routeCandidates.ifEmpty {
Connection.buildRouteCandidates(
apiServerUrl = connection.apiServerUrl,
relayUrl = connection.relayUrl,
dashboardUrl = connection.configuredDashboardUrl,
)
}
val ownedEndpoint = endpoint?.takeIf { it in routes }
ownedEndpoint?.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { return it }
ownedEndpoint?.dashboard?.url
?.takeIf { it.isNotBlank() }
?.let { return it }
endpoint?.api?.url?.let { apiUrl ->
ownedEndpoint?.api?.url?.let { apiUrl ->
connection.dashboardUrl
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
?.let { return it }
@@ -1747,15 +1758,20 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
/**
* Dashboard URL for the active connection **on the currently-resolved
* route** — snapshot twin of [effectiveDashboardUrl], which it delegates
* to. Standard voice and the availability probe read this per call, so
* route**. Read the authoritative id/list synchronously, not the combined
* [effectiveDashboardUrl] StateFlow: its previous emission can outlive a
* connection switch and must not authorize the new owner's credentials.
* Standard voice and the availability probe read this per call, so
* an auto-managed dashboard URL follows LAN/Tailscale handoffs the same
* way Manage does; an explicit dashboard override stays pinned. (This
* used to read the persisted `resolvedDashboardUrl`, which kept voice
* aimed at the LAN host after the resolver had moved chat to Tailscale.)
*/
fun activeDashboardUrl(): String? =
effectiveDashboardUrl.value.takeIf { it.isNotBlank() }
resolveEffectiveDashboardUrl(
connection = activeConnectionSnapshot(),
endpoint = connectionManager.activeEndpoint.value,
).takeIf { it.isNotBlank() }
/**
* Promote the exact reviewed Dashboard origin that completed cookie/OIDC
@@ -477,6 +477,151 @@ class EndpointResolverTest {
)
}
@Test
fun clearCache_handlesConcurrentProbeCompletions_withoutThrowingOrPublishingStaleState() = runTest {
val candidateCount = 24
val staleRequestsStarted = CountDownLatch(candidateCount)
val releaseStaleRequests = CountDownLatch(1)
val staleRequestsFinished = CountDownLatch(candidateCount)
val raceGate = CountDownLatch(1)
val requestSequence = AtomicInteger(0)
val blockingClient = fastClient.newBuilder()
.addInterceptor { chain ->
if (requestSequence.incrementAndGet() <= candidateCount) {
staleRequestsStarted.countDown()
try {
releaseStaleRequests.await(5, TimeUnit.SECONDS)
} finally {
staleRequestsFinished.countDown()
}
throw InterruptedIOException("concurrent invalidation test probe")
}
chain.proceed(chain.request())
}
.build()
val resolver = EndpointResolver(blockingClient, clock = { clockMillis.get() })
val candidates = (1..candidateCount).map { index ->
candidate("concurrent-clear-$index", priority = 0, server = reachableServer)
}
try {
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(candidates, EndpointSurface.Api)
}
assertTrue(
"every physical probe must be active before the completion/invalidation race",
staleRequestsStarted.await(5, TimeUnit.SECONDS),
)
val invalidation = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
resolver.clearCache()
}
val completions = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
releaseStaleRequests.countDown()
}
raceGate.countDown()
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(2_000L) {
invalidation.await()
completions.await()
staleResolve.await()
}
}
assertTrue(staleRequestsFinished.await(5, TimeUnit.SECONDS))
assertTrue(resolver.cacheSnapshot().isEmpty())
resolver.clearCache()
val freshWinner = withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(2_000L) {
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
}
}
assertEquals(candidates.first(), freshWinner)
assertTrue(
"a completion racing invalidation must not overwrite the fresh generation",
resolver.probeOutcomes.value.getValue(
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
).reachable,
)
} finally {
raceGate.countDown()
releaseStaleRequests.countDown()
}
}
@Test
fun invalidatedProbeCompletion_cannotRemoveFreshReplacement() = runTest {
val staleRequestStarted = CountDownLatch(1)
val releaseStaleRequest = CountDownLatch(1)
val staleRequestFinished = CountDownLatch(1)
val freshRequestStarted = CountDownLatch(1)
val releaseFreshRequest = CountDownLatch(1)
val requestSequence = AtomicInteger(0)
val blockingClient = fastClient.newBuilder()
.addInterceptor { chain ->
when (requestSequence.incrementAndGet()) {
1 -> {
staleRequestStarted.countDown()
try {
releaseStaleRequest.await(5, TimeUnit.SECONDS)
} finally {
staleRequestFinished.countDown()
}
throw InterruptedIOException("invalidated identity test probe")
}
2 -> {
freshRequestStarted.countDown()
releaseFreshRequest.await(5, TimeUnit.SECONDS)
chain.proceed(chain.request())
}
else -> chain.proceed(chain.request())
}
}
.build()
val resolver = EndpointResolver(blockingClient, clock = { clockMillis.get() })
val candidate = candidate("replacement-identity-test", priority = 0, server = reachableServer)
try {
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(listOf(candidate), EndpointSurface.Api)
}
assertTrue(staleRequestStarted.await(5, TimeUnit.SECONDS))
resolver.clearCache()
val freshResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(listOf(candidate), EndpointSurface.Api)
}
assertTrue(freshRequestStarted.await(5, TimeUnit.SECONDS))
releaseStaleRequest.countDown()
assertTrue(staleRequestFinished.await(5, TimeUnit.SECONDS))
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(1_000L) { staleResolve.await() }
}
val joiningResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(listOf(candidate), EndpointSurface.Api)
}
releaseFreshRequest.countDown()
withContext(Dispatchers.Default.limitedParallelism(1)) {
assertEquals(candidate, withTimeout(2_000L) { freshResolve.await() })
assertEquals(candidate, withTimeout(2_000L) { joiningResolve.await() })
}
assertEquals(
"the late stale completion must leave the fresh shared probe registered",
2,
requestSequence.get(),
)
} finally {
releaseStaleRequest.countDown()
releaseFreshRequest.countDown()
}
}
// ---------------------------------------------------------------
// Test 6 — cached-reachable result is re-probed after TTL
// ---------------------------------------------------------------
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
@@ -659,6 +660,40 @@ class DashboardApiClientTest {
assertEquals("basic", session.provider)
}
@Test
fun passwordLogin_stripsOnlyBrowserForbiddenLineBreaksFromCredentials() = runTest {
val preservedCredential = " \t\u00A0påss\u200B "
val cases = listOf(
listOf("user", "line\rbreak", "user", "linebreak"),
listOf("user", "line\nbreak", "user", "linebreak"),
listOf("user", "line\r\nbreak", "user", "linebreak"),
listOf("us\r\ner", "secret", "user", "secret"),
listOf(
preservedCredential,
preservedCredential,
preservedCredential,
preservedCredential,
),
)
repeat(cases.size) {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"ok": true, "next": "/"}"""),
)
}
val client = DashboardApiClient(baseUrl = server.url("/").toString())
cases.forEach { (username, password, expectedUsername, expectedPassword) ->
client.loginPassword(username = username, password = password).getOrThrow()
val body = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject
assertEquals(expectedUsername, body["username"]?.jsonPrimitive?.content)
assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content)
}
}
private fun storedCookie(
name: String,
value: String,
@@ -7,8 +7,9 @@ import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
import com.hermesandroid.relay.viewmodel.ChatConnectState
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatConnectState
@@ -196,6 +197,50 @@ class RelayAppStatusTest {
assertEquals(ChatRuntimeStatus.Connecting, status)
}
@Test
fun `foreground Gateway owns cold observation before gateway ready`() {
assertTrue(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = GatewayAvailability.Unknown,
),
)
assertTrue(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = GatewayAvailability.Unreachable,
),
)
assertFalse(
shouldOwnVisibleGateway(
appForeground = false,
isGatewayTransport = true,
gatewayAvailability = GatewayAvailability.Unknown,
),
)
assertFalse(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = false,
gatewayAvailability = GatewayAvailability.Unknown,
),
)
listOf(
GatewayAvailability.SignInRequired,
GatewayAvailability.Unsupported,
).forEach { terminal ->
assertFalse(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = terminal,
),
)
}
}
@Test
fun `dashboard sign-out is not masked by a reachable sibling API`() {
val status = resolveAppChatRuntimeStatus(
@@ -2,8 +2,11 @@ package com.hermesandroid.relay.ui.screens
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotModeState
@@ -13,6 +16,7 @@ import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -65,10 +69,64 @@ class BotModeScreenTest {
compose.onNodeWithText("Lucy").assertDoesNotExist()
}
@Test
fun `same named bots on different gateways render and open their own conversation`() {
val opened = mutableListOf<String>()
render(onOpenBot = { opened += it.route!!.connectionId })
compose.onNodeWithText("Lucy").performClick()
compose.onNodeWithText("Researcher").performClick()
assertEquals(listOf("home", "lab"), opened)
}
@Test
fun `same named active bots on different gateways render and open their own owner`() {
val opened = mutableListOf<String>()
render(nowMs = NOW, onOpenBot = { opened += it.route!!.connectionId })
compose.onAllNodesWithText("Lucy")[0].performClick()
compose.onAllNodesWithText("Researcher")[0].performClick()
assertEquals(listOf("home", "lab"), opened)
}
@Test
fun `opening progress only replaces the selected owners preview`() {
render(openingRoute = BotGatewayRouteKey("home", "default"))
compose.onNodeWithText("Drafted a rollout plan").assertDoesNotExist()
compose.onNodeWithText("Findings ready").assertExists()
}
@Test
fun `item identity survives presentation refresh and separates delimiter containing owners`() {
val bot = state().roster.bots.first()
val refreshed = bot.copy(
displayName = "Renamed",
handle = "new-handle",
stale = true,
route = BotGatewayRoute(bot.route!!.key, "New label", "new-install-metadata"),
canonicalSession = BotSessionSummary(id = "compressed-tip"),
)
assertEquals(bot.lazyItemKey, refreshed.lazyItemKey)
assertNotEquals(
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("a:b", "c"), "Same label")).lazyItemKey,
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("a", "b:c"), "Same label")).lazyItemKey,
)
assertNotEquals(
bot.lazyItemKey,
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("home", "other"), "Hermes")).lazyItemKey,
)
assertNotEquals(bot.lazyItemKey, bot.copy(route = null).lazyItemKey)
}
private fun render(
onOpenBot: (BotRosterEntry) -> Unit = {},
onOpenGroup: (BotGroupRoom) -> Unit = {},
selectedGatewayId: String? = null,
nowMs: Long = NOW + 200_000L,
openingRoute: BotGatewayRouteKey? = null,
) {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
@@ -77,13 +135,14 @@ class BotModeScreenTest {
connections = listOf(connection(), labConnection()),
activeConnection = connection(),
selectedGatewayId = selectedGatewayId,
openingRoute = openingRoute,
onBack = {},
onRefresh = {},
onSelectGateway = {},
onOpenBot = onOpenBot,
onOpenGroup = onOpenGroup,
onNewBot = {},
nowMs = NOW + 200_000L,
nowMs = nowMs,
)
}
}
@@ -106,10 +165,10 @@ class BotModeScreenTest {
),
),
BotRosterEntry(
profile = Profile(name = "researcher", model = "gpt-5.6", description = "Research"),
profile = Profile(name = "default", model = "gpt-5.6", description = "Research"),
displayName = "Researcher",
route = com.hermesandroid.relay.data.BotGatewayRoute(
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "researcher"),
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "default"),
connectionLabel = "Lab server",
),
canonicalSession = BotSessionSummary(
@@ -182,6 +182,55 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun coldGatewayClientBeforeVisibilityOpensObservationWithoutControlRpc() {
viewModel.setChatVisible(false)
replaceGatewayClient(ticketTimeoutMs = 5_000L)
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val ticketMintsBefore = gatewayHarness.ticketMints.get()
viewModel.setChatVisible(true)
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Ready }
assertEquals(ticketMintsBefore + 1, gatewayHarness.ticketMints.get())
controlMethods.forEach { method ->
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
}
}
@Test
fun coldGatewayVisibilityBeforeClientBindingOpensObservationWithoutControlRpc() {
viewModel.setChatVisible(false)
replaceGatewayClient(ticketTimeoutMs = 5_000L, bind = false)
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val ticketMintsBefore = gatewayHarness.ticketMints.get()
viewModel.setChatVisible(true)
viewModel.updateGatewayClient(gatewayClient)
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Ready }
assertEquals(ticketMintsBefore + 1, gatewayHarness.ticketMints.get())
controlMethods.forEach { method ->
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
}
}
@Test
fun offlineGatewaySendPublishesRetryableFailureAndKeepsPrompt() {
DiagnosticsLog.clear()
@@ -4322,7 +4371,10 @@ class ChatViewModelGatewayInboundTurnTest {
),
)
private fun replaceGatewayClient(ticketTimeoutMs: Long): GatewayChatClient {
private fun replaceGatewayClient(
ticketTimeoutMs: Long,
bind: Boolean = true,
): GatewayChatClient {
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
@@ -4340,7 +4392,7 @@ class ChatViewModelGatewayInboundTurnTest {
scope = gatewayScope,
reconnectJitterUnit = { Math.nextDown(1.0) },
)
viewModel.updateGatewayClient(gatewayClient)
if (bind) viewModel.updateGatewayClient(gatewayClient)
return gatewayClient
}
@@ -13,6 +13,31 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class EffectiveDashboardRouteTest {
@Test
fun `outgoing resolver endpoint cannot retarget incoming connection credentials`() {
val incomingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://b.example.invalid"),
)
val outgoingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://a.example.invalid"),
)
val incoming = connection(
dashboardUrl = "https://b.example.invalid",
apiServerUrl = "",
).copy(routeCandidates = listOf(incomingRoute))
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, outgoingRoute),
)
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, incomingRoute),
)
}
@Test
fun `late dashboard probe cannot publish across connection or route change`() {
assertTrue(
@@ -82,7 +107,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
}
@@ -102,7 +127,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"https://hermes.example.com",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
assertEquals("http://100.71.8.56:8642", resolveEffectiveApiServerUrl(connection.apiServerUrl, tailscale))
}
@@ -138,7 +163,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"https://hermes.example.com:443",
resolveEffectiveDashboardUrl(connection, fallback),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(fallback)), fallback),
)
}
@@ -156,7 +181,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
}
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
@@ -147,12 +148,14 @@ class GitStateWriteViewModelTest {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
var committedTarget: GitTarget? = null
val committedTarget = CompletableDeferred<GitTarget>()
vm.commit("add feature") { committedTarget = it }
vm.commit("add feature") { committedTarget.complete(it) }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertEquals("alpha", committedTarget?.repoId)
// Success is published before detail refresh and the callback complete.
val target = withTimeout(5_000) { committedTarget.await() }
assertEquals("alpha", target.repoId)
}
@Test
@@ -0,0 +1,359 @@
package com.hermesandroid.relay.viewmodel
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.network.relay.ConnectionManager
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
import com.hermesandroid.relay.network.upstream.NativeDashboardTokenStore
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.Properties
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Extends the switch-coordinator/route-pool seams with real HTTP and production
* token JSON. Only Android Keystore is substituted: its raw string backend is
* file-backed and rereads the file on every access. No NativeDashboardTokens
* object survives a store reload. ConnectionStore owns its real JSON encoding.
* Loopback HTTP is the documented fixture exception, not a production TLS bypass.
*/
class MultiGatewayAuthPersistenceTest {
@get:Rule
val files = TemporaryFolder()
@Test
fun switchingWithOutgoingResolverSnapshotPreservesBothSerializedSessions() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
// The coordinator publishes B's id before the endpoint resolver
// finishes. Keep A's endpoint deliberately stale through the probe.
fixture.switchTo("b")
fixture.switchTo("a")
fixture.switchTo("b")
fixture.assertBothStored()
assertEquals(0, fixture.a.foreignBearerRequests.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
assertEquals(0, fixture.a.rejectedRefreshes.get())
assertEquals(0, fixture.b.rejectedRefreshes.get())
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun coldReloadPreservesBothSessionsWithoutAnySwitch() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun rotatedCredentialsSurviveClientAndStoreRecreation() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.a.rejectCurrentAccess = true
fixture.assertAuthenticated("a")
assertEquals(1, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
fixture.reload()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
assertEquals(1, fixture.a.refreshes.get())
}
}
@Test
fun mismatchedAndIneligibleRoutesNeverAttachOrClearStoredBearer() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
val wrongRoute = fixture.transport.dashboardClientFor("a", fixture.b.url)
assertFalse(wrongRoute.currentSession().getOrThrow().authenticated)
wrongRoute.shutdown()
fixture.eligible = false
// A new controller ensures this assertion tests eligibility itself,
// independently of the separate cached-client policy transition.
fixture.replaceTransport()
val ineligible = fixture.transport.dashboardClientFor("a", fixture.a.url)
assertFalse(ineligible.currentSession().getOrThrow().authenticated)
ineligible.shutdown()
fixture.assertBothStored()
assertEquals(0, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
}
}
private inner class Fixture(private val scope: CoroutineScope) : AutoCloseable {
val a = AuthPeer("a")
val b = AuthPeer("b")
private val context = mockk<Context>().also { every { it.applicationContext } returns it }
private val preferences = PreferencesBackend()
private val suffix = UUID.randomUUID().toString()
private val rawStores = listOf("a", "b").associateWith {
FileStrings(files.newFile("$suffix-$it.properties"))
}
private val definitions = listOf(a, b).map { peer ->
Connection(
id = peer.id,
label = peer.id,
apiServerUrl = "",
relayUrl = "",
dashboardUrl = peer.url,
routeCandidates = listOf(EndpointCandidate(
role = "lan",
dashboard = com.hermesandroid.relay.data.DashboardEndpoint(peer.url),
)),
tokenStoreKey = "fixture-$suffix-${peer.id}",
)
}
private var store = ConnectionStore(preferences, scope)
private var endpoint: EndpointCandidate? = null
private var tokenStores = emptyMap<String, NativeDashboardTokenStore>()
var eligible = true
lateinit var transport: UpstreamTransportController
private set
suspend fun initialize() {
store.isHydrated.first { it }
definitions.forEach { store.addConnection(it) }
store.setActiveConnection("a")
endpoint = connection("a").routeCandidates.single()
tokenStores = definitions.associate { connection ->
SecureStoreCache.getOrBuild(connection.tokenStoreKey) { rawStores.getValue(connection.id) }
connection.id to EncryptedNativeDashboardTokenStore(context, connection.tokenStoreKey)
}
replaceTransport()
}
private fun connection(id: String): Connection = store.connections.value.single { it.id == id }
// Same synchronous ownership inputs as ConnectionViewModel.activeDashboardUrl.
// Endpoint publication intentionally lags connection-id publication.
private fun activeUrl(): String = resolveEffectiveDashboardUrl(
store.connections.value.firstOrNull { it.id == store.activeConnectionId.value },
endpoint,
)
fun replaceTransport() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
transport = UpstreamTransportController(
context = context,
activeConnectionIdProvider = { store.activeConnectionId.value },
dashboardUrlProvider = { activeUrl() },
gatewayKeepAliveProvider = { false },
tokenStoreKeyProvider = { connection(it).tokenStoreKey },
trustedDashboardUrlProvider = { id ->
if (id == store.activeConnectionId.value) activeUrl() else connection(id).resolvedDashboardUrl
},
nativeDashboardBearerEligibleProvider = { id ->
eligible && nativeDashboardBearerCompatible(
connection(id).dashboardLastStatus?.authProviders
?.takeIf { it.isNotEmpty() }
?: connection(id).dashboardAuthProviders,
)
},
dashboardTokenStoreFactory = { key ->
tokenStores.getValue(definitions.single { it.tokenStoreKey == key }.id)
},
dashboardCookieStoreFactory = { _, _ -> InMemoryDashboardCookieStore() },
)
}
fun signInBoth() {
listOf(a, b).forEach { peer ->
val client = NativeDashboardAuthClient(peer.url, tokenStores.getValue(peer.id))
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
client.exchangeCallback(authorization, "/callback?code=fixture&state=${authorization.state}")
}
assertBothStored()
}
suspend fun assertAuthenticated(id: String) {
val client = transport.dashboardClientFor(id, connection(id).resolvedDashboardUrl)
try {
val status = client.getStatus().getOrThrow()
assertTrue(status.authRequired)
store.setDashboardStatus(
id,
DashboardConnectionStatus(authRequired = status.authRequired, authProviders = status.authProviders),
)
assertTrue("REST session must authenticate for its owner", client.currentSession().getOrThrow().authenticated)
assertTrue("WebSocket admission must authenticate for its owner", client.requestWsTicket().isSuccess)
} finally {
client.shutdown()
}
}
suspend fun switchTo(id: String) {
val manager = mockk<ConnectionManager>(relaxed = true)
val auth = mockk<AuthManager>(relaxed = true)
every { auth.authState } returns MutableStateFlow<AuthState>(AuthState.Unpaired)
every { auth.hasPairContext } returns false
val coordinator = ConnectionSwitchCoordinator(
connectionStore = store,
connectionManager = manager,
scope = scope,
authManagerFactory = { auth },
installAuthManager = {},
setApiServerUrl = {},
setRelayUrl = {},
persistUrls = { _, _ -> },
rebuildApiClient = {
val client = transport.dashboardClientFor(id, activeUrl())
try {
client.getStatus().getOrThrow()
client.currentSession().getOrThrow()
} finally {
client.shutdown()
}
},
)
transport.resetGatewayForConnectionSwitch()
coordinator.switchConnection(id).join()
endpoint = connection(id).routeCandidates.single()
assertBothStored()
assertAuthenticated(id)
}
fun assertBothStored() {
assertTrue("A's serialized token must remain readable", tokenStores.getValue("a").load() != null)
assertTrue("B's serialized token must remain readable", tokenStores.getValue("b").load() != null)
}
suspend fun reload() {
definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
// ConnectionStore decodes its persisted connections_v1 JSON again;
// the native stores decode their saved JSON from files again.
store = ConnectionStore(preferences, scope)
store.isHydrated.first { it }
tokenStores = definitions.associate {
it.id to EncryptedNativeDashboardTokenStore(context, it.tokenStoreKey)
}
endpoint = connection(store.activeConnectionId.value!!).routeCandidates.single()
replaceTransport()
}
override fun close() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
a.close()
b.close()
}
}
private class AuthPeer(val id: String) : AutoCloseable {
private val server = MockWebServer()
private var generation = 0
@Volatile
var rejectCurrentAccess = false
val refreshes = AtomicInteger()
val rejectedRefreshes = AtomicInteger()
val foreignBearerRequests = AtomicInteger()
val url: String
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.path == "/api/status") {
return MockResponse().setBody(
"""{"auth_required":true,"auth_providers":["basic"],"auth_flows":["cookie","native_pkce"]}""",
)
}
if (request.path == "/auth/native/token") return tokens()
if (request.path == "/auth/native/refresh") {
if (!request.body.readUtf8().contains("fixture-refresh-$id-$generation")) {
rejectedRefreshes.incrementAndGet()
return MockResponse().setResponseCode(401)
}
generation += 1
rejectCurrentAccess = false
refreshes.incrementAndGet()
return tokens()
}
val header = request.getHeader("Authorization")
if (header != null && !header.startsWith("Bearer fixture-access-$id-")) {
foreignBearerRequests.incrementAndGet()
}
if (header != "Bearer fixture-access-$id-$generation" || rejectCurrentAccess) {
return MockResponse().setResponseCode(401)
}
return when (request.path) {
"/api/auth/me" -> MockResponse().setBody("""{"authenticated":true,"provider":"basic"}""")
"/api/auth/ws-ticket" -> MockResponse().setBody("""{"ticket":"fixture-ticket"}""")
else -> MockResponse().setResponseCode(404)
}
}
}
server.start()
url = server.url("/").newBuilder().host("127.0.0.1").build().toString().trimEnd('/')
}
private fun tokens() = MockResponse().setBody(
"""{"access_token":"fixture-access-$id-$generation","refresh_token":"fixture-refresh-$id-$generation","expires_at":4102444800,"provider":"basic"}""",
)
override fun close() = server.shutdown()
}
/** Raw storage seam only: production NativeDashboardTokenStore owns JSON. */
private class FileStrings(private val file: File) : SessionTokenStore {
override val hasHardwareBackedStorage = false
private fun read() = Properties().apply { file.inputStream().use { load(it) } }
private fun write(values: Properties) = file.outputStream().use { values.store(it, null) }
@Synchronized override fun getString(key: String): String? = read().getProperty(key)
@Synchronized override fun putString(key: String, value: String) { write(read().apply { setProperty(key, value) }) }
@Synchronized override fun remove(key: String) { write(read().apply { remove(key) }) }
@Synchronized override fun contains(key: String): Boolean = read().containsKey(key)
@Synchronized override fun clearAll() { write(Properties()) }
}
private class PreferencesBackend : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
transform(state.value).also { state.value = it }
}
}
+7 -1
View File
@@ -3982,7 +3982,13 @@ compression path instead of forking the relationship. New Bot creates an upstrea
only the small `hermes-bots` metadata marker; profile skills/model remain managed
through the established Hermes surfaces.
Every Bot owner is the immutable `(connectionId, profile)` pair. A typed route
Every Bot owner is the immutable `(connectionId, profile)` pair. Both the active
Bot strip and conversation list use that pair for stable Compose item identity;
opening progress belongs to the same exact owner. Profile names, display labels,
and handles alone are not unique across installations. Upstream
[`profiles.list`](https://github.com/NousResearch/hermes-agent/blob/2db0c7a2d8f29debe7d1cbfb4a72f4f98dc00808/tui_gateway/methods_profiles.py)
returns installation-local profile names and profile-local session summaries.
A typed route
pool holds separate clients for separate owners, validates bearer authority
against that connection's exact trusted Dashboard base, adds the profile to the
WebSocket URL, mints a fresh one-use ticket on every dial, and uses request or
+2 -2
View File
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.15.1 - Steadier chat, media, and voice
v1.16.0 - Safer startup, connections, and activity
More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.
Gateway chat now opens reliably on a cold launch. Saved Dashboard sign-ins stay bound to the correct connection, pasted credentials ignore accidental line breaks, and network changes no longer race the route cache. Bot Mode supports duplicate profile names across gateways, while delegated work, session setup, attachment errors, and feedback remain visible and easier to review.
```
## Category
+1 -1
View File
@@ -496,7 +496,7 @@ Errors: relay connect-error / timeout / 5xx → `502 Bad Gateway` with a human-r
## Health Check
```bash
curl http://localhost:8767/health
curl http://127.0.0.1:8767/health
```
Returns JSON with server status and version.
+1 -1
View File
@@ -938,7 +938,7 @@ Uses `asyncio.create_subprocess_exec` with PTY for non-blocking I/O. tmux gives
Wraps the existing relay protocol. When the agent calls `android_*` tools, the tool handler routes through the relay server's bridge channel to the phone.
**Change from upstream:** The bridge channel is part of the multiplexed WSS connection instead of a separate `ws://` relay on port 8766. The legacy standalone `plugin/tools/android_relay.py` was retired in Phase 3 Wave 1 (2026-04-12) and its functionality migrated to two files in the unified relay: `plugin/tools/android_tool.py` (Hermes tools pointing at `http://localhost:8767` — baseline 14 plus v0.4 expansion) and `plugin/relay/channels/bridge.py` (the `BridgeHandler.handle_command(...)` dispatcher that mints request IDs, sends `bridge.command` envelopes over the shared WSS pipe, and awaits matching `bridge.response` envelopes with a 30s timeout). HTTP routes are registered on `plugin/relay/server.py` between `# === PHASE3-bridge-server ===` markers and delegate through the same handler. Wire protocol is frozen — envelopes match the legacy relay byte-for-byte.
**Change from upstream:** The bridge channel is part of the multiplexed WSS connection instead of a separate `ws://` relay on port 8766. The legacy standalone `plugin/tools/android_relay.py` was retired in Phase 3 Wave 1 (2026-04-12) and its functionality migrated to two files in the unified relay: `plugin/tools/android_tool.py` (Hermes tools pointing at `http://127.0.0.1:8767` by default — baseline 14 plus v0.4 expansion) and `plugin/relay/channels/bridge.py` (the `BridgeHandler.handle_command(...)` dispatcher that mints request IDs, sends `bridge.command` envelopes over the shared WSS pipe, and awaits matching `bridge.response` envelopes with a 30s timeout). HTTP routes are registered on `plugin/relay/server.py` between `# === PHASE3-bridge-server ===` markers and delegate through the same handler. Wire protocol is frozen — envelopes match the legacy relay byte-for-byte.
#### 6.4.1 `android_*` tool surface
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.15.1"
appVersionCode = "54"
appVersionName = "1.16.0"
appVersionCode = "55"
agp = "9.4.0"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Hermes-Relay",
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
"icon": "Activity",
"version": "1.11.1",
"version": "1.11.2",
"tab": {
"path": "/relay",
"position": "after:skills"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hermes-relay-dashboard",
"version": "1.11.1",
"version": "1.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-relay-dashboard",
"version": "1.11.1",
"version": "1.11.2",
"devDependencies": {
"esbuild": "^0.25.12",
"qrcode": "^1.5.4"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-relay-dashboard",
"version": "1.11.1",
"version": "1.11.2",
"private": true,
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
"scripts": {
+3 -3
View File
@@ -42,7 +42,7 @@ Off by default:
Environment variables (env wins over config.yaml ``extra``):
PHONE_ENABLED "1"/"true"/"yes"/"on" enables the platform (required)
PHONE_RELAY_URL Relay base URL. Default: reuse ANDROID_BRIDGE_URL,
else http://localhost:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
else http://127.0.0.1:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
PHONE_RELAY_TOKEN Optional bearer for the relay POST (loopback is
unauthenticated by default; sent only if set)
PHONE_HOME_CHANNEL Default chat_id for cron / home-channel delivery
@@ -156,7 +156,7 @@ def _relay_base_url() -> str:
Honors ``PHONE_RELAY_URL`` first, then reuses the same convention as
``plugin/tools/android_tool.py`` (``ANDROID_BRIDGE_URL`` /
``ANDROID_RELAY_PORT`` / ``RELAY_PORT``) so a single override flips both
the android tools and this adapter. Defaults to ``http://localhost:8767``.
the android tools and this adapter. Defaults to ``http://127.0.0.1:8767``.
"""
explicit = os.getenv("PHONE_RELAY_URL", "").strip()
if explicit:
@@ -165,7 +165,7 @@ def _relay_base_url() -> str:
if bridge:
return bridge.rstrip("/")
port = os.getenv("ANDROID_RELAY_PORT", os.getenv("RELAY_PORT", "8767")).strip() or "8767"
return f"http://localhost:{port}"
return f"http://127.0.0.1:{port}"
def _home_channel() -> str:
+1 -1
View File
@@ -2,7 +2,7 @@ name: hermes-relay
# Temporary v1 shim for Hermes installers that reject manifests the runtime supports; see docs/project/TODO.md.
manifest_version: 1
api_version: 1
version: 1.11.1
version: 1.11.2
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
author: Axiom Labs
license: MIT
+1 -1
View File
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
# reports this plugin version, and stale values make live diagnosis harder than
# it should be.
__version__ = "1.11.1"
__version__ = "1.11.2"
from .server import create_app, main # noqa: E402 — must come after __version__
+8 -1
View File
@@ -250,7 +250,14 @@ class TestSharedBridgeTransport(unittest.TestCase):
{"ANDROID_BRIDGE_URL": "", "ANDROID_BRIDGE_TIMEOUT": "30"},
):
os.environ.pop("ANDROID_BRIDGE_URL")
self.assertEqual(android_tool._bridge_url(), "http://localhost:8767")
self.assertEqual(android_tool._bridge_url(), "http://127.0.0.1:8767")
def test_preserves_explicit_loopback_overrides(self) -> None:
for override in ("http://localhost:8767", "http://[::1]:8767"):
with self.subTest(override=override), mock.patch.dict(
os.environ, {"ANDROID_BRIDGE_URL": override}
):
self.assertEqual(android_tool._bridge_url(), override)
def test_get_uses_android_tool_bridge_transport(self) -> None:
response = mock.Mock()
+10 -1
View File
@@ -343,6 +343,8 @@ class TestSetup:
)
monkeypatch.delenv("ANDROID_BRIDGE_TOKEN", raising=False)
monkeypatch.delenv("ANDROID_BRIDGE_URL", raising=False)
monkeypatch.delenv("ANDROID_RELAY_PORT", raising=False)
monkeypatch.delenv("RELAY_PORT", raising=False)
android_tool._reset_token_cache()
yield
android_tool._reset_token_cache()
@@ -356,10 +358,17 @@ class TestSetup:
result = json.loads(android_setup("ABC123"))
# Config should be saved regardless of relay import
assert os.environ.get("ANDROID_BRIDGE_TOKEN") == "ABC123"
assert "localhost" in os.environ.get("ANDROID_BRIDGE_URL", "")
assert os.environ.get("ANDROID_BRIDGE_URL") == "http://127.0.0.1:8767"
assert any(
call.request.url == "http://127.0.0.1:8767/health"
for call in responses.calls
)
assert "ANDROID_BRIDGE_TOKEN=ABC123" in (
Path(os.environ["HERMES_HOME"]) / ".env"
).read_text()
assert "ANDROID_BRIDGE_URL=http://127.0.0.1:8767" in (
Path(os.environ["HERMES_HOME"]) / ".env"
).read_text()
@responses.activate
def test_setup_accepts_legacy_pairing_code_kwarg(self, monkeypatch):
@@ -0,0 +1,253 @@
"""Focused tests for shared Desktop tool availability snapshots."""
from __future__ import annotations
import os
import threading
import unittest
from unittest.mock import patch
import requests
from plugin.tools import desktop_tool
class _Response:
def __init__(self, body, status_code: int = 200) -> None:
self._body = body
self.status_code = status_code
def json(self):
if isinstance(self._body, Exception):
raise self._body
return self._body
def _health(*, connected=True, clients=None, advertised_tools=None):
body = {
"connected": connected,
"advertised_tools": advertised_tools or [],
}
if clients is not None:
body["clients"] = [
{"device_id": f"pc-{index}", "advertised_tools": tools}
for index, tools in enumerate(clients)
]
return body
class DesktopToolAvailabilityTests(unittest.TestCase):
def setUp(self) -> None:
desktop_tool._clear_availability_cache()
def tearDown(self) -> None:
desktop_tool._clear_availability_cache()
def test_default_relay_url_uses_ipv4_loopback_and_override_is_preserved(self) -> None:
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(desktop_tool._relay_url(), "http://127.0.0.1:8767")
with patch.dict(os.environ, {"DESKTOP_RELAY_URL": "http://[::1]:9876"}, clear=True):
self.assertEqual(desktop_tool._relay_url(), "http://[::1]:9876")
def test_one_health_snapshot_serves_tools_and_desktop_health(self) -> None:
response = _Response(
_health(
clients=[["desktop_read_file", "desktop_health"]],
advertised_tools=["desktop_read_file", "desktop_health"],
)
)
with patch.object(desktop_tool.requests, "get", return_value=response) as get:
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertFalse(desktop_tool._check_tool("desktop_terminal"))
self.assertTrue(desktop_tool._check_relay())
get.assert_called_once()
self.assertTrue(get.call_args.args[0].endswith("/desktop/health"))
def test_multi_client_disjoint_tools_and_legacy_client_match_handler_semantics(self) -> None:
response = _Response(
_health(
clients=[["desktop_read_file"], ["desktop_terminal"], []],
advertised_tools=["desktop_terminal"],
)
)
with patch.object(desktop_tool.requests, "get", return_value=response):
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertTrue(desktop_tool._check_tool("desktop_terminal"))
self.assertTrue(desktop_tool._check_tool("desktop_checksum"))
self.assertFalse(desktop_tool._check_tool("desktop_computer_status"))
def test_old_health_response_without_clients_uses_legacy_rules(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(advertised_tools=["desktop_terminal"])),
):
self.assertTrue(desktop_tool._check_tool("desktop_terminal"))
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
desktop_tool._clear_availability_cache()
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(advertised_tools=[])),
):
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertFalse(desktop_tool._check_tool("desktop_computer_status"))
def test_reachable_no_client_keeps_health_available_but_tools_unavailable(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(connected=False, clients=[])),
):
self.assertTrue(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
def test_unreachable_relay_disables_health_and_tools_from_one_cached_miss(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
side_effect=requests.ConnectionError("offline"),
) as get:
self.assertFalse(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
get.assert_called_once()
def test_malformed_availability_fields_fail_closed(self) -> None:
malformed = [
{},
{"connected": 1, "advertised_tools": [], "clients": []},
{"connected": False, "advertised_tools": "desktop_read_file", "clients": []},
{"connected": True, "advertised_tools": [], "clients": "invalid"},
{"connected": True, "advertised_tools": [], "clients": []},
{"connected": False, "advertised_tools": ["desktop_read_file"], "clients": []},
{"connected": True, "advertised_tools": [], "clients": [{}]},
{"connected": True, "advertised_tools": [], "clients": [["desktop_read_file"]]},
{"connected": True, "advertised_tools": [], "clients": [{"advertised_tools": [1]}]},
]
for body in malformed:
with self.subTest(body=body):
desktop_tool._clear_availability_cache()
with patch.object(desktop_tool.requests, "get", return_value=_Response(body)):
self.assertFalse(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
def test_slow_probe_receives_full_ttl_after_it_completes(self) -> None:
clock = [0.0]
def get(*args, **kwargs):
clock[0] = 10.0
return _Response(_health(connected=False, clients=[]))
with (
patch.object(desktop_tool.time, "monotonic", side_effect=lambda: clock[0]),
patch.object(desktop_tool.requests, "get", side_effect=get) as request,
):
self.assertTrue(desktop_tool._check_relay())
clock[0] = 12.9
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(request.call_count, 1)
clock[0] = 13.0
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(request.call_count, 2)
def test_endpoint_and_token_changes_do_not_reuse_a_snapshot(self) -> None:
response = _Response(_health(connected=False, clients=[]))
with patch.object(desktop_tool.requests, "get", return_value=response) as get:
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:8767", "DESKTOP_RELAY_TOKEN": "one"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertTrue(desktop_tool._check_relay())
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:8767", "DESKTOP_RELAY_TOKEN": "two"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:9999", "DESKTOP_RELAY_TOKEN": "two"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(get.call_count, 3)
def test_endpoint_history_is_bounded_and_expired_entries_are_pruned(self) -> None:
clock = [0.0]
response = _Response(_health(connected=False, clients=[]))
with (
patch.object(desktop_tool.time, "monotonic", side_effect=lambda: clock[0]),
patch.object(desktop_tool.requests, "get", return_value=response),
):
for port in range(desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES + 5):
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": f"http://127.0.0.1:{9000 + port}"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(
len(desktop_tool._availability_cache),
desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES,
)
self.assertEqual(
len(desktop_tool._availability_probe_generation),
desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES,
)
clock[0] = desktop_tool._AVAILABILITY_CACHE_TTL_SECONDS
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:9999"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(len(desktop_tool._availability_cache), 1)
self.assertEqual(len(desktop_tool._availability_probe_generation), 1)
def test_older_concurrent_probe_cannot_overwrite_newer_cache_entry(self) -> None:
first_started = threading.Event()
release_first = threading.Event()
call_count = 0
call_count_lock = threading.Lock()
def get(*args, **kwargs):
nonlocal call_count
with call_count_lock:
call_count += 1
call_number = call_count
if call_number == 1:
first_started.set()
release_first.wait(timeout=2)
return _Response(
_health(clients=[["desktop_read_file"]], advertised_tools=["desktop_read_file"])
)
release_first.set()
return _Response(_health(connected=False, clients=[]))
results: list[bool] = []
with patch.object(desktop_tool.requests, "get", side_effect=get):
first = threading.Thread(
target=lambda: results.append(desktop_tool._check_tool("desktop_read_file"))
)
first.start()
self.assertTrue(first_started.wait(timeout=2))
second = threading.Thread(
target=lambda: results.append(desktop_tool._check_tool("desktop_read_file"))
)
second.start()
first.join(timeout=2)
second.join(timeout=2)
self.assertFalse(first.is_alive())
self.assertFalse(second.is_alive())
self.assertEqual(call_count, 2) # Duplicate concurrent probes are allowed.
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
self.assertCountEqual(results, [True, False])
if __name__ == "__main__":
unittest.main()
+12 -4
View File
@@ -172,7 +172,7 @@ class GatingTests(_EnvIsolated):
class RelayUrlTests(_EnvIsolated):
def test_default(self) -> None:
self.assertEqual(pp._relay_base_url(), "http://localhost:8767")
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8767")
def test_explicit_phone_relay_url_wins(self) -> None:
os.environ["PHONE_RELAY_URL"] = "https://relay.example:9000/"
@@ -183,12 +183,20 @@ class RelayUrlTests(_EnvIsolated):
os.environ["ANDROID_BRIDGE_URL"] = "http://192.168.1.5:8767/"
self.assertEqual(pp._relay_base_url(), "http://192.168.1.5:8767")
def test_preserves_explicit_loopback_overrides(self) -> None:
for key in ("PHONE_RELAY_URL", "ANDROID_BRIDGE_URL"):
for override in ("http://localhost:8767/", "http://[::1]:8767/"):
with self.subTest(key=key, override=override):
os.environ[key] = override
self.assertEqual(pp._relay_base_url(), override.rstrip("/"))
os.environ.pop(key)
def test_port_override(self) -> None:
os.environ["ANDROID_RELAY_PORT"] = "8888"
self.assertEqual(pp._relay_base_url(), "http://localhost:8888")
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8888")
os.environ.pop("ANDROID_RELAY_PORT")
os.environ["RELAY_PORT"] = "7777"
self.assertEqual(pp._relay_base_url(), "http://localhost:7777")
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:7777")
def test_token_header_only_when_set(self) -> None:
url, headers = pp._relay_url_and_headers()
@@ -254,7 +262,7 @@ class EnvEnablementTests(_EnvIsolated):
assert seed is not None
self.assertTrue(seed["enabled"])
self.assertEqual(seed["home_channel"], {"chat_id": "myphone", "name": "Phone"})
self.assertEqual(seed["relay_url"], "http://localhost:8767")
self.assertEqual(seed["relay_url"], "http://127.0.0.1:8767")
self.assertFalse(seed["typing_indicator"])
+6 -6
View File
@@ -76,18 +76,18 @@ except ImportError: # pragma: no cover - direct-script fallback
# ── Config ────────────────────────────────────────────────────────────────────
#
# Architecture: Phone connects OUT to Hermes server via WebSocket (NAT-friendly).
# The unified Hermes-Relay server runs on localhost:8767 and multiplexes the
# The unified Hermes-Relay server runs on 127.0.0.1:8767 and multiplexes the
# bridge channel alongside chat, terminal, media, and voice. The legacy
# standalone bridge relay on port 8766 was retired in Phase 3 Wave 1.
#
# Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS bridge channel──> Phone
# Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS bridge channel──> Phone
#
# For local/USB dev, tools can also talk directly to the phone's HTTP server
# by setting ANDROID_BRIDGE_URL to the phone's IP.
def _bridge_url() -> str:
"""URL of the relay (default) or direct phone connection."""
return os.getenv("ANDROID_BRIDGE_URL", "http://localhost:8767")
return os.getenv("ANDROID_BRIDGE_URL", "http://127.0.0.1:8767")
def _hermes_home() -> Path:
"""Return the request-scoped Hermes home when the host exposes one."""
@@ -1446,7 +1446,7 @@ def android_setup(
public_ip = _get_public_ip()
# Save config to ~/.hermes/.env
relay_url = f"http://localhost:{port}"
relay_url = f"http://127.0.0.1:{port}"
try:
from hermes_cli.config import save_env_value
save_env_value("ANDROID_BRIDGE_URL", relay_url)
@@ -1468,7 +1468,7 @@ def android_setup(
relay_running = False
phone_connected = False
try:
health = requests.get(f"http://localhost:{port}/health", timeout=2)
health = requests.get(f"{relay_url}/health", timeout=2)
if health.status_code == 200:
relay_running = True
except Exception:
@@ -1487,7 +1487,7 @@ def android_setup(
"status": "error",
"message": (
"Unified Hermes-Relay is not running on "
f"localhost:{port}. Start it with "
f"127.0.0.1:{port}. Start it with "
"`systemctl --user start hermes-relay` and retry."
),
"server_address": server_address,
+156 -29
View File
@@ -49,17 +49,17 @@ Tools registered (Phase B + remote-PC ergonomics, alpha.7):
Architecture mirrors ``android_tool.py``:
Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS desktop channel──> Desktop CLI
Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS desktop channel──> Desktop CLI
Each handler POSTs to ``/desktop/<tool_name>`` on the relay. The relay
forwards a ``desktop.command`` envelope to the connected desktop client
(see ``plugin/relay/channels/desktop.py``), awaits a ``desktop.response``,
and returns the structured result.
``check_fn`` pings ``/desktop/_ping?tool=<name>`` — 200 if a client is
connected and advertises the tool, 503 otherwise. This is how Hermes
becomes aware: with no client, the tool fails closed and the LLM learns
to stop calling it.
``check_fn`` shares a short-lived ``/desktop/health`` snapshot across the
Desktop toolset. Availability matches the relay's multi-client advertisement
rules, including compatibility for connected legacy clients. With no client,
client-routed tools fail closed and the LLM learns to stop calling them.
``desktop_health`` is the one tool that does NOT round-trip to the client
— the relay already has the client's heartbeat-advertised metadata, so we
@@ -71,7 +71,9 @@ from __future__ import annotations
import json
import os
import time
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any, Optional
import requests
@@ -112,8 +114,8 @@ def _trusted_call_context(kwargs: dict[str, Any]) -> dict[str, str]:
def _relay_url() -> str:
"""URL of the unified relay. Defaults to localhost:8767."""
return os.getenv("DESKTOP_RELAY_URL", "http://localhost:8767")
"""URL of the unified relay. Defaults to its IPv4 loopback listener."""
return os.getenv("DESKTOP_RELAY_URL", "http://127.0.0.1:8767")
def _relay_token() -> Optional[str]:
@@ -191,38 +193,163 @@ def _get(path: str, params: Optional[dict] = None) -> dict:
return data
def _check_tool(tool_name: str) -> bool:
"""Returns True if a desktop client is connected AND advertises ``tool_name``.
_AVAILABILITY_CACHE_TTL_SECONDS = 3.0
_AVAILABILITY_CACHE_MAX_ENTRIES = 32
Hits ``/desktop/_ping?tool=<tool_name>``. 200 = available, 503 = no
client / tool not advertised.
"""
@dataclass(frozen=True)
class _DesktopAvailability:
"""Validated relay health state shared by one serialized registry pass."""
reachable: bool
valid: bool
connected: bool
client_toolsets: tuple[frozenset[str], ...]
_UNREACHABLE_AVAILABILITY = _DesktopAvailability(False, False, False, ())
_availability_cache: dict[
tuple[str, str | None], tuple[float, _DesktopAvailability]
] = {}
_availability_probe_generation: dict[tuple[str, str | None], object] = {}
def _availability_cache_key() -> tuple[str, str | None]:
"""Separate snapshots by endpoint and the credential used to reach it."""
return (_relay_url().rstrip("/"), _relay_token())
def _clear_availability_cache() -> None:
"""Reset process-local availability state for focused tests."""
_availability_cache.clear()
_availability_probe_generation.clear()
def _prune_availability_cache(now: float) -> None:
"""Discard expired endpoint/credential history from long-lived hosts."""
expired = [
key
for key, (expires_at, _) in _availability_cache.items()
if now >= expires_at
]
for key in expired:
_availability_cache.pop(key, None)
_availability_probe_generation.pop(key, None)
def _validated_toolset(value: Any) -> frozenset[str] | None:
if not isinstance(value, list):
return None
if any(not isinstance(name, str) or not name for name in value):
return None
return frozenset(value)
def _parse_availability(data: Any) -> _DesktopAvailability:
"""Validate current and pre-multi-client ``/desktop/health`` responses."""
if not isinstance(data, dict) or type(data.get("connected")) is not bool:
return _DesktopAvailability(True, False, False, ())
connected = data["connected"]
advertised = _validated_toolset(data.get("advertised_tools"))
if advertised is None:
return _DesktopAvailability(True, False, False, ())
if "clients" not in data:
# Older relays exposed only the latest/sole client's advertised tools.
# An empty set while connected retains the relay's legacy-client
# optimism for every tool except the experimental computer-use family.
if not connected and advertised:
return _DesktopAvailability(True, False, False, ())
return _DesktopAvailability(True, True, connected, (advertised,) if connected else ())
clients = data["clients"]
if not isinstance(clients, list):
return _DesktopAvailability(True, False, False, ())
toolsets: list[frozenset[str]] = []
for client in clients:
if not isinstance(client, dict):
return _DesktopAvailability(True, False, False, ())
tools = _validated_toolset(client.get("advertised_tools"))
if tools is None:
return _DesktopAvailability(True, False, False, ())
toolsets.append(tools)
# The current relay always emits one clients[] row per connected target.
# Contradictory shapes are unsafe to interpret as tool availability.
if connected != bool(toolsets) or (not connected and advertised):
return _DesktopAvailability(True, False, False, ())
return _DesktopAvailability(True, True, connected, tuple(toolsets))
def _probe_availability() -> _DesktopAvailability:
try:
r = requests.get(
f"{_relay_url()}/desktop/_ping",
params={"tool": tool_name},
response = requests.get(
f"{_relay_url().rstrip('/')}/desktop/health",
headers=_auth_headers(),
timeout=2,
)
return r.status_code == 200
except Exception:
return _UNREACHABLE_AVAILABILITY
if response.status_code != 200:
return _DesktopAvailability(True, False, False, ())
try:
return _parse_availability(response.json())
except Exception:
return _DesktopAvailability(True, False, False, ())
def _availability() -> _DesktopAvailability:
"""Return one cached health snapshot for all Desktop availability checks.
Hermes invokes shared-tool availability checks serially during a registry
pass, so one health request serves the whole Desktop toolset. No threading
primitive is introduced into the plugin: simultaneous callers may perform
duplicate probes, but generation ordering prevents an older probe from
overwriting the cache entry from a newer one.
"""
key = _availability_cache_key()
now = time.monotonic()
_prune_availability_cache(now)
cached = _availability_cache.get(key)
if cached is not None and now < cached[0]:
return cached[1]
generation = object()
_availability_probe_generation[key] = generation
snapshot = _probe_availability()
expires_at = time.monotonic() + _AVAILABILITY_CACHE_TTL_SECONDS
if _availability_probe_generation.get(key) is generation:
if (
key not in _availability_cache
and len(_availability_cache) >= _AVAILABILITY_CACHE_MAX_ENTRIES
):
evicted = min(
_availability_cache,
key=lambda cached_key: _availability_cache[cached_key][0],
)
_availability_cache.pop(evicted, None)
_availability_probe_generation.pop(evicted, None)
_availability_cache[key] = (expires_at, snapshot)
return snapshot
def _check_tool(tool_name: str) -> bool:
"""Match DesktopHandler.has_client_for across every connected client."""
snapshot = _availability()
if not snapshot.reachable or not snapshot.valid or not snapshot.connected:
return False
return any(tool_name in tools for tools in snapshot.client_toolsets) or (
not tool_name.startswith("desktop_computer_")
and any(not tools for tools in snapshot.client_toolsets)
)
def _check_relay() -> bool:
"""``check_fn`` for ``desktop_health`` — the relay must be reachable, but
a client need not be connected. The whole point of ``desktop_health`` is
to tell the agent whether a client IS connected, so it must remain callable
when one is not."""
try:
r = requests.get(
f"{_relay_url()}/desktop/health",
headers=_auth_headers(),
timeout=2,
)
return r.status_code == 200
except Exception:
return False
"""Keep ``desktop_health`` callable on a valid relay with no client."""
snapshot = _availability()
return snapshot.reachable and snapshot.valid
# ── Tool implementations ───────────────────────────────────────────────────────
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-relay"
version = "1.11.1"
version = "1.11.2"
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
requires-python = ">=3.11"
dependencies = [
+13 -3
View File
@@ -333,8 +333,18 @@ def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
missing_fields = sorted({"id", "session_key", "status"} - item_strings)
if missing_fields:
raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields))
required_markers = ("_sessions_lock", "_sessions.items()", "_session_live_item(")
missing_markers = [marker for marker in required_markers if marker not in handler_text]
snapshot_node = handler
snapshot_text = handler_text
if "_snapshot_sessions(" in handler_text:
snapshot_node = methods.function("_snapshot_sessions")
snapshot_text = methods.segment(snapshot_node)
required_snapshot_markers = ("_sessions_lock", "_sessions.items()")
missing_snapshot_markers = [
marker for marker in required_snapshot_markers if marker not in snapshot_text
]
missing_markers = list(missing_snapshot_markers)
if "_session_live_item(" not in handler_text:
missing_markers.append("_session_live_item(")
if missing_markers or "sessions" not in _string_constants(handler):
raise ValueError(
"session.active_list no longer snapshots the live registry: "
@@ -352,7 +362,7 @@ def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
server.evidence(status, "starting, working, waiting, and idle derivation"),
server.evidence(item, "live row carries runtime and durable identities"),
methods.evidence(
handler, "active list snapshots the process-wide in-memory registry"
snapshot_node, "active list snapshots the process-wide in-memory registry"
),
),
)
@@ -110,11 +110,16 @@ def _(rid, params):
session, error = _sess_nowait(params, rid)
return _live_session_payload(params["session_id"], session)
def _snapshot_sessions(rid):
with _sessions_lock:
return list(_sessions.items()), None
@method("session.active_list")
def _(rid, params):
snapshot, error = _snapshot_sessions(rid)
if error:
return error
current = str(params.get("current_session_id") or "")
with _sessions_lock:
snapshot = list(_sessions.items())
rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
return _ok(rid, {"sessions": rows})
'''
@@ -218,6 +218,7 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
active = (await observer.receive_json())["result"]["sessions"]
self.assertEqual("working", active[0]["status"])
self.assertNotIn("profile", active[0])
async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "default", "limit": 500, "offset": 0, "order": "asc"},
@@ -244,6 +245,19 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(["session.active_list"], observer_methods)
self.assertNotIn("session.interrupt", observer_methods)
async def test_cold_start_observer_opens_socket_without_control_rpc(self) -> None:
_, base_url = await self.start("cold_start_observation")
observer, _ = await self.connect(base_url)
await self.rpc(observer, 1, "session.active_list")
active = (await observer.receive_json())["result"]["sessions"]
self.assertEqual([], active)
async with self.session.get(f"{base_url}/__fixture__/evidence") as response:
evidence = await response.json()
methods = [entry["method"] for entry in evidence["entries"] if "method" in entry]
self.assertEqual({"session.active_list"}, set(methods))
self.assertTrue(any(entry.get("event_type") == "gateway.ready" for entry in evidence["entries"]))
async def test_rapid_chunks_tools_and_interims_keep_wire_order(self) -> None:
_, base_url = await self.start("rapid_tools_interims")
ws, _ = await self.connect(base_url)
@@ -455,6 +469,7 @@ class ScenarioTestCase(unittest.TestCase):
"active_status_lifecycle",
"active_status_profile_scope",
"active_status_unsupported",
"cold_start_observation",
"cross_client_observation",
"initial_history_bind",
"ordinary_turn",
@@ -508,6 +523,10 @@ class ScenarioTestCase(unittest.TestCase):
("gateway.settled_session_info",),
load_scenario("terminal_gap_session_info").contract_requirements,
)
self.assertEqual(
("gateway.session_active_list",),
load_scenario("cold_start_observation").contract_requirements,
)
self.assertEqual(
("gateway.message_complete", "gateway.session_active_list"),
load_scenario("cross_client_observation").contract_requirements,
@@ -0,0 +1,17 @@
{
"name": "cold_start_observation",
"live_session_id": "fixture-cold-live",
"stored_session_id": "fixture-cold-stored",
"profile": "default",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": true,
"snapshots": [
[]
]
}
}