Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
179080d6d9 | ||
|
|
00c5f5daa2 | ||
|
|
69b6c005cd | ||
|
|
c902215a15 | ||
|
|
d39368bc51 | ||
|
|
414dc08b9f | ||
|
|
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
|
||||
|
||||
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### 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 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user