Compare commits

...
Author SHA1 Message Date
Bailey DixonandJack Hunzicker 179080d6d9 fix(plugin): share relay availability checks
Co-authored-by: Jack Hunzicker <JackHunzicker@users.noreply.github.com>
2026-09-09 20:57:22 -04:00
Bailey Dixon 00c5f5daa2 Merge pull request #566 from trevornk/fix/connection-owned-dashboard-auth
fix(android): bind dashboard auth to connection-owned routes
2026-09-09 20:14:28 -04:00
Bailey Dixon 69b6c005cd chore: merge current dev for dashboard auth verification 2026-09-09 19:50:39 -04:00
Bailey Dixon c902215a15 Merge pull request #560 from JackHunzicker/contrib/git-state-callback-test-order
test(android): await git commit success callback
2026-09-09 19:37:25 -04:00
Bailey Dixon d39368bc51 Merge pull request #568 from Codename-11/fix/470-bot-mode-list-identity
fix(android): prevent duplicate Bot Mode keys across connections
2026-09-09 19:34:28 -04:00
Bailey Dixon 414dc08b9f Merge branch 'dev' into contrib/git-state-callback-test-order 2026-09-09 19:28:12 -04:00
trevornk c1413c494f fix(android): bind dashboard auth to connection-owned routes
Refs #565
2026-09-09 11:44:14 -05:00
Jack bb23e6ab48 test(android): await git commit success callback
Mutation success is published before detail refresh finishes and the callback runs. Await callback delivery explicitly instead of treating the state flow as a callback-completion barrier.

Signed-off-by: Jack <JLHunzicker@gmail.com>
2026-09-07 22:27:56 -05:00
16 changed files with 878 additions and 61 deletions
+5 -1
View File
@@ -111,4 +111,8 @@ jobs:
plugin/tests/test_git_state.py \
plugin/tests/test_git_state_write.py \
plugin/tests/test_git_state_extras.py \
plugin/tests/test_mobile_plugin_store.py
plugin/tests/test_mobile_plugin_store.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
+5 -1
View File
@@ -107,7 +107,11 @@ jobs:
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_proactive_channel.py \
plugin/tests/test_android_phone_status.py
plugin/tests/test_android_phone_status.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
package:
name: Build and publish Plugin package
+2
View File
@@ -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
@@ -13,6 +13,31 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class EffectiveDashboardRouteTest {
@Test
fun `outgoing resolver endpoint cannot retarget incoming connection credentials`() {
val incomingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://b.example.invalid"),
)
val outgoingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://a.example.invalid"),
)
val incoming = connection(
dashboardUrl = "https://b.example.invalid",
apiServerUrl = "",
).copy(routeCandidates = listOf(incomingRoute))
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, outgoingRoute),
)
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, incomingRoute),
)
}
@Test
fun `late dashboard probe cannot publish across connection or route change`() {
assertTrue(
@@ -82,7 +107,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
}
@@ -102,7 +127,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"https://hermes.example.com",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
assertEquals("http://100.71.8.56:8642", resolveEffectiveApiServerUrl(connection.apiServerUrl, tailscale))
}
@@ -138,7 +163,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"https://hermes.example.com:443",
resolveEffectiveDashboardUrl(connection, fallback),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(fallback)), fallback),
)
}
@@ -156,7 +181,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
}
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
@@ -147,12 +148,14 @@ class GitStateWriteViewModelTest {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
var committedTarget: GitTarget? = null
val committedTarget = CompletableDeferred<GitTarget>()
vm.commit("add feature") { committedTarget = it }
vm.commit("add feature") { committedTarget.complete(it) }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertEquals("alpha", committedTarget?.repoId)
// Success is published before detail refresh and the callback complete.
val target = withTimeout(5_000) { committedTarget.await() }
assertEquals("alpha", target.repoId)
}
@Test
@@ -0,0 +1,359 @@
package com.hermesandroid.relay.viewmodel
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.network.relay.ConnectionManager
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
import com.hermesandroid.relay.network.upstream.NativeDashboardTokenStore
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.Properties
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Extends the switch-coordinator/route-pool seams with real HTTP and production
* token JSON. Only Android Keystore is substituted: its raw string backend is
* file-backed and rereads the file on every access. No NativeDashboardTokens
* object survives a store reload. ConnectionStore owns its real JSON encoding.
* Loopback HTTP is the documented fixture exception, not a production TLS bypass.
*/
class MultiGatewayAuthPersistenceTest {
@get:Rule
val files = TemporaryFolder()
@Test
fun switchingWithOutgoingResolverSnapshotPreservesBothSerializedSessions() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
// The coordinator publishes B's id before the endpoint resolver
// finishes. Keep A's endpoint deliberately stale through the probe.
fixture.switchTo("b")
fixture.switchTo("a")
fixture.switchTo("b")
fixture.assertBothStored()
assertEquals(0, fixture.a.foreignBearerRequests.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
assertEquals(0, fixture.a.rejectedRefreshes.get())
assertEquals(0, fixture.b.rejectedRefreshes.get())
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun coldReloadPreservesBothSessionsWithoutAnySwitch() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun rotatedCredentialsSurviveClientAndStoreRecreation() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.a.rejectCurrentAccess = true
fixture.assertAuthenticated("a")
assertEquals(1, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
fixture.reload()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
assertEquals(1, fixture.a.refreshes.get())
}
}
@Test
fun mismatchedAndIneligibleRoutesNeverAttachOrClearStoredBearer() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
val wrongRoute = fixture.transport.dashboardClientFor("a", fixture.b.url)
assertFalse(wrongRoute.currentSession().getOrThrow().authenticated)
wrongRoute.shutdown()
fixture.eligible = false
// A new controller ensures this assertion tests eligibility itself,
// independently of the separate cached-client policy transition.
fixture.replaceTransport()
val ineligible = fixture.transport.dashboardClientFor("a", fixture.a.url)
assertFalse(ineligible.currentSession().getOrThrow().authenticated)
ineligible.shutdown()
fixture.assertBothStored()
assertEquals(0, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
}
}
private inner class Fixture(private val scope: CoroutineScope) : AutoCloseable {
val a = AuthPeer("a")
val b = AuthPeer("b")
private val context = mockk<Context>().also { every { it.applicationContext } returns it }
private val preferences = PreferencesBackend()
private val suffix = UUID.randomUUID().toString()
private val rawStores = listOf("a", "b").associateWith {
FileStrings(files.newFile("$suffix-$it.properties"))
}
private val definitions = listOf(a, b).map { peer ->
Connection(
id = peer.id,
label = peer.id,
apiServerUrl = "",
relayUrl = "",
dashboardUrl = peer.url,
routeCandidates = listOf(EndpointCandidate(
role = "lan",
dashboard = com.hermesandroid.relay.data.DashboardEndpoint(peer.url),
)),
tokenStoreKey = "fixture-$suffix-${peer.id}",
)
}
private var store = ConnectionStore(preferences, scope)
private var endpoint: EndpointCandidate? = null
private var tokenStores = emptyMap<String, NativeDashboardTokenStore>()
var eligible = true
lateinit var transport: UpstreamTransportController
private set
suspend fun initialize() {
store.isHydrated.first { it }
definitions.forEach { store.addConnection(it) }
store.setActiveConnection("a")
endpoint = connection("a").routeCandidates.single()
tokenStores = definitions.associate { connection ->
SecureStoreCache.getOrBuild(connection.tokenStoreKey) { rawStores.getValue(connection.id) }
connection.id to EncryptedNativeDashboardTokenStore(context, connection.tokenStoreKey)
}
replaceTransport()
}
private fun connection(id: String): Connection = store.connections.value.single { it.id == id }
// Same synchronous ownership inputs as ConnectionViewModel.activeDashboardUrl.
// Endpoint publication intentionally lags connection-id publication.
private fun activeUrl(): String = resolveEffectiveDashboardUrl(
store.connections.value.firstOrNull { it.id == store.activeConnectionId.value },
endpoint,
)
fun replaceTransport() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
transport = UpstreamTransportController(
context = context,
activeConnectionIdProvider = { store.activeConnectionId.value },
dashboardUrlProvider = { activeUrl() },
gatewayKeepAliveProvider = { false },
tokenStoreKeyProvider = { connection(it).tokenStoreKey },
trustedDashboardUrlProvider = { id ->
if (id == store.activeConnectionId.value) activeUrl() else connection(id).resolvedDashboardUrl
},
nativeDashboardBearerEligibleProvider = { id ->
eligible && nativeDashboardBearerCompatible(
connection(id).dashboardLastStatus?.authProviders
?.takeIf { it.isNotEmpty() }
?: connection(id).dashboardAuthProviders,
)
},
dashboardTokenStoreFactory = { key ->
tokenStores.getValue(definitions.single { it.tokenStoreKey == key }.id)
},
dashboardCookieStoreFactory = { _, _ -> InMemoryDashboardCookieStore() },
)
}
fun signInBoth() {
listOf(a, b).forEach { peer ->
val client = NativeDashboardAuthClient(peer.url, tokenStores.getValue(peer.id))
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
client.exchangeCallback(authorization, "/callback?code=fixture&state=${authorization.state}")
}
assertBothStored()
}
suspend fun assertAuthenticated(id: String) {
val client = transport.dashboardClientFor(id, connection(id).resolvedDashboardUrl)
try {
val status = client.getStatus().getOrThrow()
assertTrue(status.authRequired)
store.setDashboardStatus(
id,
DashboardConnectionStatus(authRequired = status.authRequired, authProviders = status.authProviders),
)
assertTrue("REST session must authenticate for its owner", client.currentSession().getOrThrow().authenticated)
assertTrue("WebSocket admission must authenticate for its owner", client.requestWsTicket().isSuccess)
} finally {
client.shutdown()
}
}
suspend fun switchTo(id: String) {
val manager = mockk<ConnectionManager>(relaxed = true)
val auth = mockk<AuthManager>(relaxed = true)
every { auth.authState } returns MutableStateFlow<AuthState>(AuthState.Unpaired)
every { auth.hasPairContext } returns false
val coordinator = ConnectionSwitchCoordinator(
connectionStore = store,
connectionManager = manager,
scope = scope,
authManagerFactory = { auth },
installAuthManager = {},
setApiServerUrl = {},
setRelayUrl = {},
persistUrls = { _, _ -> },
rebuildApiClient = {
val client = transport.dashboardClientFor(id, activeUrl())
try {
client.getStatus().getOrThrow()
client.currentSession().getOrThrow()
} finally {
client.shutdown()
}
},
)
transport.resetGatewayForConnectionSwitch()
coordinator.switchConnection(id).join()
endpoint = connection(id).routeCandidates.single()
assertBothStored()
assertAuthenticated(id)
}
fun assertBothStored() {
assertTrue("A's serialized token must remain readable", tokenStores.getValue("a").load() != null)
assertTrue("B's serialized token must remain readable", tokenStores.getValue("b").load() != null)
}
suspend fun reload() {
definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
// ConnectionStore decodes its persisted connections_v1 JSON again;
// the native stores decode their saved JSON from files again.
store = ConnectionStore(preferences, scope)
store.isHydrated.first { it }
tokenStores = definitions.associate {
it.id to EncryptedNativeDashboardTokenStore(context, it.tokenStoreKey)
}
endpoint = connection(store.activeConnectionId.value!!).routeCandidates.single()
replaceTransport()
}
override fun close() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
a.close()
b.close()
}
}
private class AuthPeer(val id: String) : AutoCloseable {
private val server = MockWebServer()
private var generation = 0
@Volatile
var rejectCurrentAccess = false
val refreshes = AtomicInteger()
val rejectedRefreshes = AtomicInteger()
val foreignBearerRequests = AtomicInteger()
val url: String
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.path == "/api/status") {
return MockResponse().setBody(
"""{"auth_required":true,"auth_providers":["basic"],"auth_flows":["cookie","native_pkce"]}""",
)
}
if (request.path == "/auth/native/token") return tokens()
if (request.path == "/auth/native/refresh") {
if (!request.body.readUtf8().contains("fixture-refresh-$id-$generation")) {
rejectedRefreshes.incrementAndGet()
return MockResponse().setResponseCode(401)
}
generation += 1
rejectCurrentAccess = false
refreshes.incrementAndGet()
return tokens()
}
val header = request.getHeader("Authorization")
if (header != null && !header.startsWith("Bearer fixture-access-$id-")) {
foreignBearerRequests.incrementAndGet()
}
if (header != "Bearer fixture-access-$id-$generation" || rejectCurrentAccess) {
return MockResponse().setResponseCode(401)
}
return when (request.path) {
"/api/auth/me" -> MockResponse().setBody("""{"authenticated":true,"provider":"basic"}""")
"/api/auth/ws-ticket" -> MockResponse().setBody("""{"ticket":"fixture-ticket"}""")
else -> MockResponse().setResponseCode(404)
}
}
}
server.start()
url = server.url("/").newBuilder().host("127.0.0.1").build().toString().trimEnd('/')
}
private fun tokens() = MockResponse().setBody(
"""{"access_token":"fixture-access-$id-$generation","refresh_token":"fixture-refresh-$id-$generation","expires_at":4102444800,"provider":"basic"}""",
)
override fun close() = server.shutdown()
}
/** Raw storage seam only: production NativeDashboardTokenStore owns JSON. */
private class FileStrings(private val file: File) : SessionTokenStore {
override val hasHardwareBackedStorage = false
private fun read() = Properties().apply { file.inputStream().use { load(it) } }
private fun write(values: Properties) = file.outputStream().use { values.store(it, null) }
@Synchronized override fun getString(key: String): String? = read().getProperty(key)
@Synchronized override fun putString(key: String, value: String) { write(read().apply { setProperty(key, value) }) }
@Synchronized override fun remove(key: String) { write(read().apply { remove(key) }) }
@Synchronized override fun contains(key: String): Boolean = read().containsKey(key)
@Synchronized override fun clearAll() { write(Properties()) }
}
private class PreferencesBackend : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
transform(state.value).also { state.value = it }
}
}
+1 -1
View File
@@ -496,7 +496,7 @@ Errors: relay connect-error / timeout / 5xx → `502 Bad Gateway` with a human-r
## Health Check
```bash
curl http://localhost:8767/health
curl http://127.0.0.1:8767/health
```
Returns JSON with server status and version.
+1 -1
View File
@@ -938,7 +938,7 @@ Uses `asyncio.create_subprocess_exec` with PTY for non-blocking I/O. tmux gives
Wraps the existing relay protocol. When the agent calls `android_*` tools, the tool handler routes through the relay server's bridge channel to the phone.
**Change from upstream:** The bridge channel is part of the multiplexed WSS connection instead of a separate `ws://` relay on port 8766. The legacy standalone `plugin/tools/android_relay.py` was retired in Phase 3 Wave 1 (2026-04-12) and its functionality migrated to two files in the unified relay: `plugin/tools/android_tool.py` (Hermes tools pointing at `http://localhost:8767` — baseline 14 plus v0.4 expansion) and `plugin/relay/channels/bridge.py` (the `BridgeHandler.handle_command(...)` dispatcher that mints request IDs, sends `bridge.command` envelopes over the shared WSS pipe, and awaits matching `bridge.response` envelopes with a 30s timeout). HTTP routes are registered on `plugin/relay/server.py` between `# === PHASE3-bridge-server ===` markers and delegate through the same handler. Wire protocol is frozen — envelopes match the legacy relay byte-for-byte.
**Change from upstream:** The bridge channel is part of the multiplexed WSS connection instead of a separate `ws://` relay on port 8766. The legacy standalone `plugin/tools/android_relay.py` was retired in Phase 3 Wave 1 (2026-04-12) and its functionality migrated to two files in the unified relay: `plugin/tools/android_tool.py` (Hermes tools pointing at `http://127.0.0.1:8767` by default — baseline 14 plus v0.4 expansion) and `plugin/relay/channels/bridge.py` (the `BridgeHandler.handle_command(...)` dispatcher that mints request IDs, sends `bridge.command` envelopes over the shared WSS pipe, and awaits matching `bridge.response` envelopes with a 30s timeout). HTTP routes are registered on `plugin/relay/server.py` between `# === PHASE3-bridge-server ===` markers and delegate through the same handler. Wire protocol is frozen — envelopes match the legacy relay byte-for-byte.
#### 6.4.1 `android_*` tool surface
+3 -3
View File
@@ -42,7 +42,7 @@ Off by default:
Environment variables (env wins over config.yaml ``extra``):
PHONE_ENABLED "1"/"true"/"yes"/"on" enables the platform (required)
PHONE_RELAY_URL Relay base URL. Default: reuse ANDROID_BRIDGE_URL,
else http://localhost:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
else http://127.0.0.1:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
PHONE_RELAY_TOKEN Optional bearer for the relay POST (loopback is
unauthenticated by default; sent only if set)
PHONE_HOME_CHANNEL Default chat_id for cron / home-channel delivery
@@ -156,7 +156,7 @@ def _relay_base_url() -> str:
Honors ``PHONE_RELAY_URL`` first, then reuses the same convention as
``plugin/tools/android_tool.py`` (``ANDROID_BRIDGE_URL`` /
``ANDROID_RELAY_PORT`` / ``RELAY_PORT``) so a single override flips both
the android tools and this adapter. Defaults to ``http://localhost:8767``.
the android tools and this adapter. Defaults to ``http://127.0.0.1:8767``.
"""
explicit = os.getenv("PHONE_RELAY_URL", "").strip()
if explicit:
@@ -165,7 +165,7 @@ def _relay_base_url() -> str:
if bridge:
return bridge.rstrip("/")
port = os.getenv("ANDROID_RELAY_PORT", os.getenv("RELAY_PORT", "8767")).strip() or "8767"
return f"http://localhost:{port}"
return f"http://127.0.0.1:{port}"
def _home_channel() -> str:
+8 -1
View File
@@ -250,7 +250,14 @@ class TestSharedBridgeTransport(unittest.TestCase):
{"ANDROID_BRIDGE_URL": "", "ANDROID_BRIDGE_TIMEOUT": "30"},
):
os.environ.pop("ANDROID_BRIDGE_URL")
self.assertEqual(android_tool._bridge_url(), "http://localhost:8767")
self.assertEqual(android_tool._bridge_url(), "http://127.0.0.1:8767")
def test_preserves_explicit_loopback_overrides(self) -> None:
for override in ("http://localhost:8767", "http://[::1]:8767"):
with self.subTest(override=override), mock.patch.dict(
os.environ, {"ANDROID_BRIDGE_URL": override}
):
self.assertEqual(android_tool._bridge_url(), override)
def test_get_uses_android_tool_bridge_transport(self) -> None:
response = mock.Mock()
+10 -1
View File
@@ -343,6 +343,8 @@ class TestSetup:
)
monkeypatch.delenv("ANDROID_BRIDGE_TOKEN", raising=False)
monkeypatch.delenv("ANDROID_BRIDGE_URL", raising=False)
monkeypatch.delenv("ANDROID_RELAY_PORT", raising=False)
monkeypatch.delenv("RELAY_PORT", raising=False)
android_tool._reset_token_cache()
yield
android_tool._reset_token_cache()
@@ -356,10 +358,17 @@ class TestSetup:
result = json.loads(android_setup("ABC123"))
# Config should be saved regardless of relay import
assert os.environ.get("ANDROID_BRIDGE_TOKEN") == "ABC123"
assert "localhost" in os.environ.get("ANDROID_BRIDGE_URL", "")
assert os.environ.get("ANDROID_BRIDGE_URL") == "http://127.0.0.1:8767"
assert any(
call.request.url == "http://127.0.0.1:8767/health"
for call in responses.calls
)
assert "ANDROID_BRIDGE_TOKEN=ABC123" in (
Path(os.environ["HERMES_HOME"]) / ".env"
).read_text()
assert "ANDROID_BRIDGE_URL=http://127.0.0.1:8767" in (
Path(os.environ["HERMES_HOME"]) / ".env"
).read_text()
@responses.activate
def test_setup_accepts_legacy_pairing_code_kwarg(self, monkeypatch):
@@ -0,0 +1,253 @@
"""Focused tests for shared Desktop tool availability snapshots."""
from __future__ import annotations
import os
import threading
import unittest
from unittest.mock import patch
import requests
from plugin.tools import desktop_tool
class _Response:
def __init__(self, body, status_code: int = 200) -> None:
self._body = body
self.status_code = status_code
def json(self):
if isinstance(self._body, Exception):
raise self._body
return self._body
def _health(*, connected=True, clients=None, advertised_tools=None):
body = {
"connected": connected,
"advertised_tools": advertised_tools or [],
}
if clients is not None:
body["clients"] = [
{"device_id": f"pc-{index}", "advertised_tools": tools}
for index, tools in enumerate(clients)
]
return body
class DesktopToolAvailabilityTests(unittest.TestCase):
def setUp(self) -> None:
desktop_tool._clear_availability_cache()
def tearDown(self) -> None:
desktop_tool._clear_availability_cache()
def test_default_relay_url_uses_ipv4_loopback_and_override_is_preserved(self) -> None:
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(desktop_tool._relay_url(), "http://127.0.0.1:8767")
with patch.dict(os.environ, {"DESKTOP_RELAY_URL": "http://[::1]:9876"}, clear=True):
self.assertEqual(desktop_tool._relay_url(), "http://[::1]:9876")
def test_one_health_snapshot_serves_tools_and_desktop_health(self) -> None:
response = _Response(
_health(
clients=[["desktop_read_file", "desktop_health"]],
advertised_tools=["desktop_read_file", "desktop_health"],
)
)
with patch.object(desktop_tool.requests, "get", return_value=response) as get:
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertFalse(desktop_tool._check_tool("desktop_terminal"))
self.assertTrue(desktop_tool._check_relay())
get.assert_called_once()
self.assertTrue(get.call_args.args[0].endswith("/desktop/health"))
def test_multi_client_disjoint_tools_and_legacy_client_match_handler_semantics(self) -> None:
response = _Response(
_health(
clients=[["desktop_read_file"], ["desktop_terminal"], []],
advertised_tools=["desktop_terminal"],
)
)
with patch.object(desktop_tool.requests, "get", return_value=response):
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertTrue(desktop_tool._check_tool("desktop_terminal"))
self.assertTrue(desktop_tool._check_tool("desktop_checksum"))
self.assertFalse(desktop_tool._check_tool("desktop_computer_status"))
def test_old_health_response_without_clients_uses_legacy_rules(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(advertised_tools=["desktop_terminal"])),
):
self.assertTrue(desktop_tool._check_tool("desktop_terminal"))
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
desktop_tool._clear_availability_cache()
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(advertised_tools=[])),
):
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertFalse(desktop_tool._check_tool("desktop_computer_status"))
def test_reachable_no_client_keeps_health_available_but_tools_unavailable(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(connected=False, clients=[])),
):
self.assertTrue(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
def test_unreachable_relay_disables_health_and_tools_from_one_cached_miss(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
side_effect=requests.ConnectionError("offline"),
) as get:
self.assertFalse(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
get.assert_called_once()
def test_malformed_availability_fields_fail_closed(self) -> None:
malformed = [
{},
{"connected": 1, "advertised_tools": [], "clients": []},
{"connected": False, "advertised_tools": "desktop_read_file", "clients": []},
{"connected": True, "advertised_tools": [], "clients": "invalid"},
{"connected": True, "advertised_tools": [], "clients": []},
{"connected": False, "advertised_tools": ["desktop_read_file"], "clients": []},
{"connected": True, "advertised_tools": [], "clients": [{}]},
{"connected": True, "advertised_tools": [], "clients": [["desktop_read_file"]]},
{"connected": True, "advertised_tools": [], "clients": [{"advertised_tools": [1]}]},
]
for body in malformed:
with self.subTest(body=body):
desktop_tool._clear_availability_cache()
with patch.object(desktop_tool.requests, "get", return_value=_Response(body)):
self.assertFalse(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
def test_slow_probe_receives_full_ttl_after_it_completes(self) -> None:
clock = [0.0]
def get(*args, **kwargs):
clock[0] = 10.0
return _Response(_health(connected=False, clients=[]))
with (
patch.object(desktop_tool.time, "monotonic", side_effect=lambda: clock[0]),
patch.object(desktop_tool.requests, "get", side_effect=get) as request,
):
self.assertTrue(desktop_tool._check_relay())
clock[0] = 12.9
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(request.call_count, 1)
clock[0] = 13.0
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(request.call_count, 2)
def test_endpoint_and_token_changes_do_not_reuse_a_snapshot(self) -> None:
response = _Response(_health(connected=False, clients=[]))
with patch.object(desktop_tool.requests, "get", return_value=response) as get:
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:8767", "DESKTOP_RELAY_TOKEN": "one"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertTrue(desktop_tool._check_relay())
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:8767", "DESKTOP_RELAY_TOKEN": "two"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:9999", "DESKTOP_RELAY_TOKEN": "two"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(get.call_count, 3)
def test_endpoint_history_is_bounded_and_expired_entries_are_pruned(self) -> None:
clock = [0.0]
response = _Response(_health(connected=False, clients=[]))
with (
patch.object(desktop_tool.time, "monotonic", side_effect=lambda: clock[0]),
patch.object(desktop_tool.requests, "get", return_value=response),
):
for port in range(desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES + 5):
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": f"http://127.0.0.1:{9000 + port}"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(
len(desktop_tool._availability_cache),
desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES,
)
self.assertEqual(
len(desktop_tool._availability_probe_generation),
desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES,
)
clock[0] = desktop_tool._AVAILABILITY_CACHE_TTL_SECONDS
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:9999"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(len(desktop_tool._availability_cache), 1)
self.assertEqual(len(desktop_tool._availability_probe_generation), 1)
def test_older_concurrent_probe_cannot_overwrite_newer_cache_entry(self) -> None:
first_started = threading.Event()
release_first = threading.Event()
call_count = 0
call_count_lock = threading.Lock()
def get(*args, **kwargs):
nonlocal call_count
with call_count_lock:
call_count += 1
call_number = call_count
if call_number == 1:
first_started.set()
release_first.wait(timeout=2)
return _Response(
_health(clients=[["desktop_read_file"]], advertised_tools=["desktop_read_file"])
)
release_first.set()
return _Response(_health(connected=False, clients=[]))
results: list[bool] = []
with patch.object(desktop_tool.requests, "get", side_effect=get):
first = threading.Thread(
target=lambda: results.append(desktop_tool._check_tool("desktop_read_file"))
)
first.start()
self.assertTrue(first_started.wait(timeout=2))
second = threading.Thread(
target=lambda: results.append(desktop_tool._check_tool("desktop_read_file"))
)
second.start()
first.join(timeout=2)
second.join(timeout=2)
self.assertFalse(first.is_alive())
self.assertFalse(second.is_alive())
self.assertEqual(call_count, 2) # Duplicate concurrent probes are allowed.
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
self.assertCountEqual(results, [True, False])
if __name__ == "__main__":
unittest.main()
+12 -4
View File
@@ -172,7 +172,7 @@ class GatingTests(_EnvIsolated):
class RelayUrlTests(_EnvIsolated):
def test_default(self) -> None:
self.assertEqual(pp._relay_base_url(), "http://localhost:8767")
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8767")
def test_explicit_phone_relay_url_wins(self) -> None:
os.environ["PHONE_RELAY_URL"] = "https://relay.example:9000/"
@@ -183,12 +183,20 @@ class RelayUrlTests(_EnvIsolated):
os.environ["ANDROID_BRIDGE_URL"] = "http://192.168.1.5:8767/"
self.assertEqual(pp._relay_base_url(), "http://192.168.1.5:8767")
def test_preserves_explicit_loopback_overrides(self) -> None:
for key in ("PHONE_RELAY_URL", "ANDROID_BRIDGE_URL"):
for override in ("http://localhost:8767/", "http://[::1]:8767/"):
with self.subTest(key=key, override=override):
os.environ[key] = override
self.assertEqual(pp._relay_base_url(), override.rstrip("/"))
os.environ.pop(key)
def test_port_override(self) -> None:
os.environ["ANDROID_RELAY_PORT"] = "8888"
self.assertEqual(pp._relay_base_url(), "http://localhost:8888")
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8888")
os.environ.pop("ANDROID_RELAY_PORT")
os.environ["RELAY_PORT"] = "7777"
self.assertEqual(pp._relay_base_url(), "http://localhost:7777")
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:7777")
def test_token_header_only_when_set(self) -> None:
url, headers = pp._relay_url_and_headers()
@@ -254,7 +262,7 @@ class EnvEnablementTests(_EnvIsolated):
assert seed is not None
self.assertTrue(seed["enabled"])
self.assertEqual(seed["home_channel"], {"chat_id": "myphone", "name": "Phone"})
self.assertEqual(seed["relay_url"], "http://localhost:8767")
self.assertEqual(seed["relay_url"], "http://127.0.0.1:8767")
self.assertFalse(seed["typing_indicator"])
+6 -6
View File
@@ -76,18 +76,18 @@ except ImportError: # pragma: no cover - direct-script fallback
# ── Config ────────────────────────────────────────────────────────────────────
#
# Architecture: Phone connects OUT to Hermes server via WebSocket (NAT-friendly).
# The unified Hermes-Relay server runs on localhost:8767 and multiplexes the
# The unified Hermes-Relay server runs on 127.0.0.1:8767 and multiplexes the
# bridge channel alongside chat, terminal, media, and voice. The legacy
# standalone bridge relay on port 8766 was retired in Phase 3 Wave 1.
#
# Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS bridge channel──> Phone
# Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS bridge channel──> Phone
#
# For local/USB dev, tools can also talk directly to the phone's HTTP server
# by setting ANDROID_BRIDGE_URL to the phone's IP.
def _bridge_url() -> str:
"""URL of the relay (default) or direct phone connection."""
return os.getenv("ANDROID_BRIDGE_URL", "http://localhost:8767")
return os.getenv("ANDROID_BRIDGE_URL", "http://127.0.0.1:8767")
def _hermes_home() -> Path:
"""Return the request-scoped Hermes home when the host exposes one."""
@@ -1446,7 +1446,7 @@ def android_setup(
public_ip = _get_public_ip()
# Save config to ~/.hermes/.env
relay_url = f"http://localhost:{port}"
relay_url = f"http://127.0.0.1:{port}"
try:
from hermes_cli.config import save_env_value
save_env_value("ANDROID_BRIDGE_URL", relay_url)
@@ -1468,7 +1468,7 @@ def android_setup(
relay_running = False
phone_connected = False
try:
health = requests.get(f"http://localhost:{port}/health", timeout=2)
health = requests.get(f"{relay_url}/health", timeout=2)
if health.status_code == 200:
relay_running = True
except Exception:
@@ -1487,7 +1487,7 @@ def android_setup(
"status": "error",
"message": (
"Unified Hermes-Relay is not running on "
f"localhost:{port}. Start it with "
f"127.0.0.1:{port}. Start it with "
"`systemctl --user start hermes-relay` and retry."
),
"server_address": server_address,
+156 -29
View File
@@ -49,17 +49,17 @@ Tools registered (Phase B + remote-PC ergonomics, alpha.7):
Architecture mirrors ``android_tool.py``:
Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS desktop channel──> Desktop CLI
Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS desktop channel──> Desktop CLI
Each handler POSTs to ``/desktop/<tool_name>`` on the relay. The relay
forwards a ``desktop.command`` envelope to the connected desktop client
(see ``plugin/relay/channels/desktop.py``), awaits a ``desktop.response``,
and returns the structured result.
``check_fn`` pings ``/desktop/_ping?tool=<name>`` — 200 if a client is
connected and advertises the tool, 503 otherwise. This is how Hermes
becomes aware: with no client, the tool fails closed and the LLM learns
to stop calling it.
``check_fn`` shares a short-lived ``/desktop/health`` snapshot across the
Desktop toolset. Availability matches the relay's multi-client advertisement
rules, including compatibility for connected legacy clients. With no client,
client-routed tools fail closed and the LLM learns to stop calling them.
``desktop_health`` is the one tool that does NOT round-trip to the client
— the relay already has the client's heartbeat-advertised metadata, so we
@@ -71,7 +71,9 @@ from __future__ import annotations
import json
import os
import time
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any, Optional
import requests
@@ -112,8 +114,8 @@ def _trusted_call_context(kwargs: dict[str, Any]) -> dict[str, str]:
def _relay_url() -> str:
"""URL of the unified relay. Defaults to localhost:8767."""
return os.getenv("DESKTOP_RELAY_URL", "http://localhost:8767")
"""URL of the unified relay. Defaults to its IPv4 loopback listener."""
return os.getenv("DESKTOP_RELAY_URL", "http://127.0.0.1:8767")
def _relay_token() -> Optional[str]:
@@ -191,38 +193,163 @@ def _get(path: str, params: Optional[dict] = None) -> dict:
return data
def _check_tool(tool_name: str) -> bool:
"""Returns True if a desktop client is connected AND advertises ``tool_name``.
_AVAILABILITY_CACHE_TTL_SECONDS = 3.0
_AVAILABILITY_CACHE_MAX_ENTRIES = 32
Hits ``/desktop/_ping?tool=<tool_name>``. 200 = available, 503 = no
client / tool not advertised.
"""
@dataclass(frozen=True)
class _DesktopAvailability:
"""Validated relay health state shared by one serialized registry pass."""
reachable: bool
valid: bool
connected: bool
client_toolsets: tuple[frozenset[str], ...]
_UNREACHABLE_AVAILABILITY = _DesktopAvailability(False, False, False, ())
_availability_cache: dict[
tuple[str, str | None], tuple[float, _DesktopAvailability]
] = {}
_availability_probe_generation: dict[tuple[str, str | None], object] = {}
def _availability_cache_key() -> tuple[str, str | None]:
"""Separate snapshots by endpoint and the credential used to reach it."""
return (_relay_url().rstrip("/"), _relay_token())
def _clear_availability_cache() -> None:
"""Reset process-local availability state for focused tests."""
_availability_cache.clear()
_availability_probe_generation.clear()
def _prune_availability_cache(now: float) -> None:
"""Discard expired endpoint/credential history from long-lived hosts."""
expired = [
key
for key, (expires_at, _) in _availability_cache.items()
if now >= expires_at
]
for key in expired:
_availability_cache.pop(key, None)
_availability_probe_generation.pop(key, None)
def _validated_toolset(value: Any) -> frozenset[str] | None:
if not isinstance(value, list):
return None
if any(not isinstance(name, str) or not name for name in value):
return None
return frozenset(value)
def _parse_availability(data: Any) -> _DesktopAvailability:
"""Validate current and pre-multi-client ``/desktop/health`` responses."""
if not isinstance(data, dict) or type(data.get("connected")) is not bool:
return _DesktopAvailability(True, False, False, ())
connected = data["connected"]
advertised = _validated_toolset(data.get("advertised_tools"))
if advertised is None:
return _DesktopAvailability(True, False, False, ())
if "clients" not in data:
# Older relays exposed only the latest/sole client's advertised tools.
# An empty set while connected retains the relay's legacy-client
# optimism for every tool except the experimental computer-use family.
if not connected and advertised:
return _DesktopAvailability(True, False, False, ())
return _DesktopAvailability(True, True, connected, (advertised,) if connected else ())
clients = data["clients"]
if not isinstance(clients, list):
return _DesktopAvailability(True, False, False, ())
toolsets: list[frozenset[str]] = []
for client in clients:
if not isinstance(client, dict):
return _DesktopAvailability(True, False, False, ())
tools = _validated_toolset(client.get("advertised_tools"))
if tools is None:
return _DesktopAvailability(True, False, False, ())
toolsets.append(tools)
# The current relay always emits one clients[] row per connected target.
# Contradictory shapes are unsafe to interpret as tool availability.
if connected != bool(toolsets) or (not connected and advertised):
return _DesktopAvailability(True, False, False, ())
return _DesktopAvailability(True, True, connected, tuple(toolsets))
def _probe_availability() -> _DesktopAvailability:
try:
r = requests.get(
f"{_relay_url()}/desktop/_ping",
params={"tool": tool_name},
response = requests.get(
f"{_relay_url().rstrip('/')}/desktop/health",
headers=_auth_headers(),
timeout=2,
)
return r.status_code == 200
except Exception:
return _UNREACHABLE_AVAILABILITY
if response.status_code != 200:
return _DesktopAvailability(True, False, False, ())
try:
return _parse_availability(response.json())
except Exception:
return _DesktopAvailability(True, False, False, ())
def _availability() -> _DesktopAvailability:
"""Return one cached health snapshot for all Desktop availability checks.
Hermes invokes shared-tool availability checks serially during a registry
pass, so one health request serves the whole Desktop toolset. No threading
primitive is introduced into the plugin: simultaneous callers may perform
duplicate probes, but generation ordering prevents an older probe from
overwriting the cache entry from a newer one.
"""
key = _availability_cache_key()
now = time.monotonic()
_prune_availability_cache(now)
cached = _availability_cache.get(key)
if cached is not None and now < cached[0]:
return cached[1]
generation = object()
_availability_probe_generation[key] = generation
snapshot = _probe_availability()
expires_at = time.monotonic() + _AVAILABILITY_CACHE_TTL_SECONDS
if _availability_probe_generation.get(key) is generation:
if (
key not in _availability_cache
and len(_availability_cache) >= _AVAILABILITY_CACHE_MAX_ENTRIES
):
evicted = min(
_availability_cache,
key=lambda cached_key: _availability_cache[cached_key][0],
)
_availability_cache.pop(evicted, None)
_availability_probe_generation.pop(evicted, None)
_availability_cache[key] = (expires_at, snapshot)
return snapshot
def _check_tool(tool_name: str) -> bool:
"""Match DesktopHandler.has_client_for across every connected client."""
snapshot = _availability()
if not snapshot.reachable or not snapshot.valid or not snapshot.connected:
return False
return any(tool_name in tools for tools in snapshot.client_toolsets) or (
not tool_name.startswith("desktop_computer_")
and any(not tools for tools in snapshot.client_toolsets)
)
def _check_relay() -> bool:
"""``check_fn`` for ``desktop_health`` — the relay must be reachable, but
a client need not be connected. The whole point of ``desktop_health`` is
to tell the agent whether a client IS connected, so it must remain callable
when one is not."""
try:
r = requests.get(
f"{_relay_url()}/desktop/health",
headers=_auth_headers(),
timeout=2,
)
return r.status_code == 200
except Exception:
return False
"""Keep ``desktop_health`` callable on a valid relay with no client."""
snapshot = _availability()
return snapshot.reachable and snapshot.valid
# ── Tool implementations ───────────────────────────────────────────────────────