Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c5ecbb028 | ||
|
|
bd5a1c3335 | ||
|
|
2c740c9f04 | ||
|
|
3ec89680ed | ||
|
|
42860d38cd | ||
|
|
ef4b3bdb6c | ||
|
|
44bbb16cd3 | ||
|
|
1f5b7e68fc | ||
|
|
4bb8d6fa7b | ||
|
|
6395e73927 | ||
|
|
c956232b96 | ||
|
|
9814cdca55 | ||
|
|
0c337c9384 | ||
|
|
273e3f5aff | ||
|
|
179080d6d9 | ||
|
|
ac1f42b7d5 | ||
|
|
88591fca89 | ||
|
|
00c5f5daa2 | ||
|
|
69b6c005cd | ||
|
|
c902215a15 | ||
|
|
d39368bc51 | ||
|
|
414dc08b9f | ||
|
|
3b78a17bd7 | ||
|
|
a5efb9ec96 | ||
|
|
e5228f2089 | ||
|
|
c1413c494f | ||
|
|
bb23e6ab48 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+100
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-5
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+35
@@ -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(
|
||||
|
||||
+54
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+29
-4
@@ -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
|
||||
|
||||
+359
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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__
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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": [
|
||||
[]
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user