Compare commits

..
Author SHA1 Message Date
Bailey Dixon 1f49f08dbe fix(android): load Chat models when picker first opens 2026-09-23 15:42:43 -04:00
Bailey Dixon e96aa435f2 Merge pull request #625 from Codename-11/integration/agp-941
chore(deps): update Android Gradle plugins to 9.4.1
2026-09-23 14:24:01 -04:00
Bailey Dixon 592affca40 Merge remote-tracking branch 'origin/pr-614-dependabot' into integration/agp-941
# Conflicts:
#	build.gradle.kts
2026-09-23 14:11:02 -04:00
Bailey Dixon 7e5123b22f Merge remote-tracking branch 'origin/pr-612-dependabot' into integration/agp-941 2026-09-23 14:10:28 -04:00
Bailey Dixon ad3786fb0b Merge pull request #624 from Codename-11/integration/secure-link-contract
feat: guide Secure Link setup and preserve paired routes
2026-09-23 13:57:11 -04:00
Bailey Dixon 8bd67e3363 fix: render Desktop recovery instructions as static children 2026-09-23 13:21:50 -04:00
Bailey Dixon 17cf12fff9 fix: retire Desktop setup results when the listener changes 2026-09-23 13:18:09 -04:00
Bailey Dixon b55e798c3b fix: allow bounded Secure Link preflight checks to finish 2026-09-23 13:12:33 -04:00
Bailey Dixon a128e910f1 feat: guide Secure Link setup across Relay surfaces 2026-09-23 13:10:25 -04:00
Bailey Dixon 14500096c6 fix: simplify Secure Link routes and fit pairing QR payloads 2026-09-23 11:17:12 -04:00
Bailey Dixon 1ea84630d2 chore: refresh Secure Link integration from dev 2026-09-23 10:03:24 -04:00
Bailey Dixon d98e0b113b Merge pull request #620 from ophirhan/fix/appearance-light-mode-cold-start
fix(android): lock night mode to Appearance on cold start
2026-09-23 09:57:42 -04:00
Bailey Dixon ea2110fa14 chore: refresh Secure Link integration from dev 2026-09-23 09:49:27 -04:00
Bailey Dixon 43357a387d fix: enforce the combined Secure Link transport contract 2026-09-23 09:48:56 -04:00
Bailey Dixon 336475e451 Merge remote-tracking branch 'origin/dev' into codex/fix-appearance-cold-start
# Conflicts:
#	CHANGELOG.md
2026-09-23 09:40:55 -04:00
Bailey Dixon 13492610a8 Merge pull request #622 from Codename-11/fix/android-gateway-onboarding
fix(android): clarify Gateway onboarding and HTTP consent
2026-09-23 09:39:06 -04:00
Bailey Dixon 4e75564d33 fix: integrate Android Secure Link transport 2026-09-23 09:29:34 -04:00
Bailey Dixon 50adab99fa fix: integrate Secure Link proxy contract 2026-09-23 09:29:16 -04:00
Bailey Dixon dc8e076836 fix(android): hydrate appearance before first frame 2026-09-23 09:26:30 -04:00
ophirhan b296b56bce fix(android): remove forceDarkAllowed from base values theme
Companion to values-v29 override; clears lint NewApi on minSdk 26.
2026-09-22 22:39:31 +03:00
ophirhan a74ad0738f fix(android): gate forceDarkAllowed behind values-v29
Lint NewApi: android:forceDarkAllowed needs API 29; minSdk is 26.
Keep base Theme.HermesRelay in values/ and disable OEM force-dark only
on API 29+.
2026-09-22 22:39:02 +03:00
ophirhan 6bb186ee2b docs(android): changelog Appearance cold-start night mode lock 2026-09-22 22:14:32 +03:00
ophirhan db4a6f37c7 docs(android): changelog Secure Link pin path; scrub test fixture hosts
Use existing 192.168.1.x examples in presentation tests (no personal LAN).
2026-09-22 22:04:17 +03:00
ophirhan 0f19deae7f fix(android): pin Secure Link only in TrustManager, not CertificatePinner
OkHttp CertificatePinner can fail with an empty peer chain after the custom
TrustManager already accepted the paired SPKI (OEM stacks). That surfaces as
Certificate pinning failure / TLS failed — server may be http:// while the
Mac live pin still matches.
2026-09-22 22:00:52 +03:00
ophirhan fa2d17338d fix(android): Secure Link pin path for HTTP probes, routes UI, gateway WS
Carry pairing SPKI pin through Relay HTTP/voice probes, LAN endpoint
resolution, Secure Link route labels, and GatewayChatClient upgrades so
self-signed Secure Link stays healthy beyond the WSS path alone.
2026-09-22 22:00:45 +03:00
ophirhan bc2f2b0010 fix(android): lock night mode to Appearance on cold start
DayNight followed system/OEM force-dark while DataStore still said Light,
so the UI opened dark until the mode control was toggled. Apply
AppCompatDelegate from the saved preference before first frame and disable
force-dark on the activity theme.
2026-09-22 16:01:35 +03:00
ophirhan dcc8d54916 fix(plugin): make Secure Link proxy usable for Gateway chat and login
Preserve WebSocket query strings (auth tickets) when proxying dashboard
and Gateway sockets, disable nested permessage-deflate on the upstream
leg, scope dashboard login HTML/JSON and redirects under /dashboard
without double-prefixing, register bare /api and /dashboard routes, and
include version on /relay/health for route probes.
2026-09-22 14:40:45 +03:00
dependabot[bot] 47e27f6ac9 chore(deps): bump com.android.application from 9.4.0 to 9.4.1
Bumps com.android.application from 9.4.0 to 9.4.1.

---
updated-dependencies:
- dependency-name: com.android.application
  dependency-version: 9.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 11:56:56 +00:00
dependabot[bot] 5e53d5cfd1 chore(deps): bump com.android.library from 9.4.0 to 9.4.1
Bumps com.android.library from 9.4.0 to 9.4.1.

---
updated-dependencies:
- dependency-name: com.android.library
  dependency-version: 9.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 11:56:23 +00:00
82 changed files with 2759 additions and 184 deletions
+7
View File
@@ -183,6 +183,13 @@ jobs:
./gradlew :app:testSideloadDebugUnitTest \
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.network.shared.PluginProxyTransportTest \
--tests '*GatewayChatClientTest*retarget*' \
--tests '*RelayVoiceClientRoutingTest.proxyProviderOwnsBothVoiceSessionAndWebSocketRequests' \
--tests com.hermesandroid.relay.network.relay.RelayHttpClientDiagnosticsTest \
--tests com.hermesandroid.relay.ui.components.GatewayRoutesAccessPresentationTest \
--tests com.hermesandroid.relay.ui.components.EndpointsCardCompactLayoutTest \
--tests com.hermesandroid.relay.ui.screens.ConnectionDetailPresentationTest \
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
--tests com.hermesandroid.relay.util.ServerAddressTest \
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
+4
View File
@@ -103,6 +103,10 @@ jobs:
python -m pytest \
plugin/tests/test_manifest_compatibility.py \
plugin/tests/test_relay_security.py \
plugin/tests/test_secure_proxy.py \
plugin/tests/test_secure_proxy_contract.py \
plugin/tests/test_secure_link_setup.py \
plugin/tests/test_secure_proxy_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_native_layout_imports.py \
+10
View File
@@ -6,8 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- Guided Secure Link setup in Dashboard and the Desktop Relay pane, with shared read-only host CLI checks, restart instructions, and signed pairing handoff.
### Fixed
- Android Chat can open the model picker before the first turn, loads Gateway models when opened, and distinguishes loading, unavailable, and empty catalogs.
- Secure Link configuration failures leave ordinary Relay available; route details and pairing previews resolve the advertised service namespaces.
- Dashboard pairing QR codes support larger certificate-bearing Secure Link invites.
- Secure Link preserves Gateway ticket authentication and Dashboard login paths, bounds rewritten responses, and serves compatible health information without additional loopback probes.
- Android Secure Link enforces the paired certificate pin for HTTP, Gateway, and voice traffic, retains the correct TLS policy during Gateway route changes, and displays the active HTTPS Dashboard route.
- Android cold start restores the saved Appearance palette and platform light/dark mode before the first app frame.
- Android Gateway onboarding verifies Dashboard access without overstating Chat or voice readiness, explains common authentication setup failures, and requires exact-address consent before using HTTP. Custom Dashboard ports are accepted and shown throughout setup and route editing. (#604)
- Android safely settles Gateway foreground-service starts before stopping local retention, preventing the startup/shutdown race reported in #603. Turning off always-on connectivity preserves active turns.
- Android Standard Voice speaks live background completions in its active conversation after the original reply finishes. Stop and conversation changes discard pending speech. (#545)
@@ -0,0 +1,141 @@
package com.hermesandroid.relay.ui.theme
import android.app.UiModeManager
import android.content.Context
import android.os.SystemClock
import androidx.appcompat.app.AppCompatDelegate
import androidx.datastore.preferences.core.edit
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.hermesandroid.relay.HermesRelayApp
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.CustomThemePreset
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.runtime.HermesRuntimeInitializationState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Real Activity/DataStore/Compose ownership, with the device set to dark mode. */
@RunWith(AndroidJUnit4::class)
class AppearanceColdStartInstrumentedTest {
@Test
fun savedAppearanceOwnsColdStartAndLaterModeChanges() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val app = ApplicationProvider.getApplicationContext<HermesRelayApp>()
val previousPreferences = runBlocking { app.relayDataStore.data.first() }
val originalNightMode =
(app.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode
assumeTrue(
originalNightMode == UiModeManager.MODE_NIGHT_AUTO ||
originalNightMode == UiModeManager.MODE_NIGHT_NO ||
originalNightMode == UiModeManager.MODE_NIGHT_YES,
)
val custom = CustomThemePreset(
id = "day",
name = "Day",
mode = CustomThemePreset.MODE_LIGHT,
backgroundHex = "#F5F5F5",
surfaceHex = "#FFFFFF",
accentHex = "#0E18D6",
textHex = "#111111",
)
instrumentation.uiAutomation.executeShellCommand("cmd uimode night yes").close()
try {
runBlocking {
app.relayDataStore.edit { preferences ->
preferences[AppearancePreferences.themeKey] = "light"
preferences[AppearancePreferences.appThemeKey] = AppThemes.DEFAULT_ID
}
}
instrumentation.runOnMainSync {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
ActivityScenario.launch(MainActivity::class.java).use {
runBlocking {
withTimeout(30_000) {
app.runtime.connectionViewModel.isReady.first { it }
app.runtime.initializationState.first {
it == HermesRuntimeInitializationState.Ready
}
}
}
awaitTheme(isDark = false, nightMode = AppCompatDelegate.MODE_NIGHT_NO)
runBlocking {
app.relayDataStore.edit {
it[AppearancePreferences.themeKey] = "dark"
}
}
awaitTheme(isDark = true, nightMode = AppCompatDelegate.MODE_NIGHT_YES)
runBlocking {
app.relayDataStore.edit {
it[AppearancePreferences.themeKey] = "auto"
}
}
awaitTheme(isDark = true, nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
runBlocking {
app.relayDataStore.edit {
it[AppearancePreferences.customThemesKey] =
AppearancePreferences.encodeCustomThemes(listOf(custom))
it[AppearancePreferences.appThemeKey] = custom.appThemeId
}
}
awaitTheme(isDark = false, nightMode = AppCompatDelegate.MODE_NIGHT_NO)
}
} finally {
runBlocking {
app.relayDataStore.edit { preferences ->
previousPreferences[AppearancePreferences.themeKey]?.let {
preferences[AppearancePreferences.themeKey] = it
} ?: preferences.remove(AppearancePreferences.themeKey)
previousPreferences[AppearancePreferences.appThemeKey]?.let {
preferences[AppearancePreferences.appThemeKey] = it
} ?: preferences.remove(AppearancePreferences.appThemeKey)
previousPreferences[AppearancePreferences.customThemesKey]?.let {
preferences[AppearancePreferences.customThemesKey] = it
} ?: preferences.remove(AppearancePreferences.customThemesKey)
}
}
val restoreMode = when (originalNightMode) {
UiModeManager.MODE_NIGHT_YES -> "yes"
UiModeManager.MODE_NIGHT_NO -> "no"
else -> "auto"
}
instrumentation.uiAutomation.executeShellCommand("cmd uimode night $restoreMode").close()
}
}
private fun awaitTheme(isDark: Boolean, nightMode: Int) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val deadline = SystemClock.uptimeMillis() + 15_000
while (SystemClock.uptimeMillis() < deadline) {
instrumentation.waitForIdleSync()
if (RelayRefresh.activePalette.isDark == isDark &&
AppCompatDelegate.getDefaultNightMode() == nightMode
) {
assertEquals(nightMode, AppCompatDelegate.getDefaultNightMode())
if (isDark) assertTrue(RelayRefresh.activePalette.isDark)
else assertFalse(RelayRefresh.activePalette.isDark)
return
}
SystemClock.sleep(25)
}
fail(
"Appearance did not settle: paletteDark=${RelayRefresh.activePalette.isDark}, " +
"nightMode=${AppCompatDelegate.getDefaultNightMode()}",
)
}
}
@@ -13,10 +13,15 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.power.WakeLockManager
import com.hermesandroid.relay.runtime.HermesProcessRuntime
import com.hermesandroid.relay.ui.theme.AppearanceNightMode
import com.hermesandroid.relay.util.AppForegroundTracker
import com.hermesandroid.relay.util.CrashReporter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
@@ -57,6 +62,10 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
// Install the crash handler FIRST so any failure in the rest of app
// init (or anywhere later) is captured and surfaced on next launch.
CrashReporter.install(this)
// Apply saved Light/Dark/Auto before the first Activity frame so DayNight
// does not briefly follow the system when Appearance is explicitly Light.
// Bounded + best-effort: HermesRelayTheme SideEffect is the durable path.
applyPersistedAppearanceNightMode()
AppAnalytics.initialize(this)
// A8 — wire the bridge-gesture wake-lock wrapper so
// ActionExecutor.tap/tapText/typeText/swipe/scroll can hold
@@ -76,6 +85,19 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
AppForegroundTracker.initialize()
}
private fun applyPersistedAppearanceNightMode() {
try {
runBlocking {
val preferences = withTimeoutOrNull(400L) {
relayDataStore.data.first()
} ?: return@runBlocking
AppearanceNightMode.applyFromPreferences(preferences)
}
} catch (_: Throwable) {
// Non-fatal — theme root reapplies once DataStore is ready.
}
}
private fun isMainApplicationProcess(): Boolean {
val processName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
getProcessName()
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.ui.theme.AppFont
@@ -35,23 +36,25 @@ internal object AppearancePreferences {
private val serializer = ListSerializer(CustomThemePreset.serializer())
fun state(context: Context): Flow<PersistedAppearance> = context.applicationContext.relayDataStore.data
.map { preferences ->
val customThemes = decodeCustomThemes(preferences[customThemesKey])
val requestedThemeId = preferences[appThemeKey]
val customTheme = CustomThemePreset.idFromAppTheme(requestedThemeId)
?.let { id -> customThemes.firstOrNull { it.id == id } }
PersistedAppearance(
themePreference = preferences[themeKey]
?.takeIf { it == "auto" || it == "light" || it == "dark" }
?: "auto",
appThemeId = customTheme?.appThemeId ?: AppThemes.byId(requestedThemeId).id,
accentHex = normalizeAccentHex(preferences[accentKey]),
shapeId = AppearanceShape.fromId(preferences[shapeKey]).id,
appFontId = AppFont.byId(preferences[appFontKey]).id,
fontScale = (preferences[fontScaleKey] ?: 1.0f).coerceIn(0.85f, 1.3f),
customTheme = customTheme,
)
}
.map(::decode)
fun decode(preferences: Preferences): PersistedAppearance {
val customThemes = decodeCustomThemes(preferences[customThemesKey])
val requestedThemeId = preferences[appThemeKey]
val customTheme = CustomThemePreset.idFromAppTheme(requestedThemeId)
?.let { id -> customThemes.firstOrNull { it.id == id } }
return PersistedAppearance(
themePreference = preferences[themeKey]
?.takeIf { it == "auto" || it == "light" || it == "dark" }
?: "auto",
appThemeId = customTheme?.appThemeId ?: AppThemes.byId(requestedThemeId).id,
accentHex = normalizeAccentHex(preferences[accentKey]),
shapeId = AppearanceShape.fromId(preferences[shapeKey]).id,
appFontId = AppFont.byId(preferences[appFontKey]).id,
fontScale = (preferences[fontScaleKey] ?: 1.0f).coerceIn(0.85f, 1.3f),
customTheme = customTheme,
)
}
fun shape(context: Context): Flow<String> = state(context).map { it.shapeId }
@@ -197,15 +197,34 @@ fun EndpointCandidate.isDashboardOnlyRoute(): Boolean =
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
fun EndpointCandidate.primaryRouteUrl(): String? =
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: proxyDashboardBaseUrlOrNull()
?: api?.url
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
/** Dashboard/Gateway identity only; Relay and broker transports are extensions. */
/**
* Dashboard/Gateway identity only; Relay and broker transports are extensions.
*
* Hermes Secure Link stores the dashboard surface under [ProxyEndpoint.surfaces]
* (`…/dashboard`), not [DashboardEndpoint.url]. Without that hop, Routes/Access
* fall back to the saved plain `:9119` URL while Overview already rides the
* live Secure Link origin.
*/
fun EndpointCandidate.gatewayRouteUrl(): String? =
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: proxyDashboardBaseUrlOrNull()
?: api?.url?.let(Connection::deriveDefaultDashboardUrl)
/** Secure Link dashboard base when the proxy advertises a dashboard surface. */
internal fun EndpointCandidate.proxyDashboardBaseUrlOrNull(): String? {
val proxy = proxy ?: return null
if (!proxy.isValidPinnedProxy()) return null
val surfaces = proxy.surfaces.map { it.trim().lowercase() }.toSet()
if ("dashboard" !in surfaces) return null
val base = proxy.url.trim().trimEnd('/').takeIf { it.isNotBlank() } ?: return null
return "$base/dashboard"
}
/** Stable host/port identity without assuming that an API surface exists. */
fun EndpointCandidate.routeAuthority(): String? {
val rawUrl = primaryRouteUrl() ?: return null
@@ -75,16 +75,25 @@ class RelayHttpClient(
private val context: Context? = null,
/** Dashboard-authenticated client for same-origin plugin ingress calls. */
private val dashboardHttpClientProvider: ((String) -> OkHttpClient?)? = null,
/**
* Pinned-TLS client for Hermes Secure Link (`plugin_proxy`) relay URLs.
* Without this, HTTPS probes against the self-signed Secure Link cert fail
* with "Trust anchor for certification path not found" while the WSS path
* (which already uses buildPluginProxyClient) stays healthy — the UI then
* reports dashboard/relay surfaces offline despite an Active connection.
*/
private val pluginProxyHttpClientProvider: ((String) -> OkHttpClient?)? = null,
) {
private fun relayHttpBaseOrNull(url: String): String? =
RelayEndpointContract.parseOrNull(url)?.httpBaseUrl
private fun callClient(relayUrl: String): OkHttpClient =
if (isDashboardRelayIngressUrl(relayUrl)) {
dashboardHttpClientProvider?.invoke(relayUrl) ?: okHttpClient
} else {
okHttpClient
when {
isDashboardRelayIngressUrl(relayUrl) ->
dashboardHttpClientProvider?.invoke(relayUrl) ?: okHttpClient
else ->
pluginProxyHttpClientProvider?.invoke(relayUrl) ?: okHttpClient
}
companion object {
@@ -1360,7 +1369,16 @@ class RelayHttpClient(
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
)
}
val version = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
val surface = (parsed["surface"] as? kotlinx.serialization.json.JsonPrimitive)?.content
val versionRaw = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
// Secure Link /relay/health historically returned status=ok without
// version (surface=hermes_secure_proxy). Treat that as healthy so
// route probes don't spam "Missing version field".
val version = when {
!versionRaw.isNullOrBlank() -> versionRaw
surface.equals("hermes_secure_proxy", ignoreCase = true) -> "secure-link"
else -> null
}
if (version.isNullOrBlank()) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
@@ -103,6 +103,8 @@ class RelayVoiceClient(
private val voiceOutputFirstAudioTimeoutMs: Long = VOICE_OUTPUT_FIRST_AUDIO_TIMEOUT_MS,
/** Dashboard-authenticated transport for same-origin plugin ingress. */
private val dashboardHttpClientProvider: ((String) -> OkHttpClient?)? = null,
/** Pinned-TLS client for Hermes Secure Link relay URLs (self-signed leaf). */
private val pluginProxyHttpClientProvider: ((String) -> OkHttpClient?)? = null,
/** Fresh Dashboard ticket request for every ingress voice socket dial. */
private val dashboardIngressWebSocketRequestProvider:
(suspend (String) -> Request?)? = null,
@@ -114,11 +116,7 @@ class RelayVoiceClient(
private val okHttpClient: OkHttpClient
get() {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
return if (isDashboardRelayIngressUrl(relayUrl)) {
dashboardHttpClientProvider?.invoke(relayUrl) ?: directOkHttpClient
} else {
directOkHttpClient
}
return resolveClient(relayUrl)
}
companion object {
@@ -163,13 +161,16 @@ class RelayVoiceClient(
}
}
private fun callClient(url: String): OkHttpClient =
if (isDashboardRelayIngressUrl(url)) {
dashboardHttpClientProvider?.invoke(url) ?: directOkHttpClient
} else {
directOkHttpClient
private fun resolveClient(url: String): OkHttpClient =
when {
isDashboardRelayIngressUrl(url) ->
dashboardHttpClientProvider?.invoke(url) ?: directOkHttpClient
else ->
pluginProxyHttpClientProvider?.invoke(url) ?: directOkHttpClient
}
private fun callClient(url: String): OkHttpClient = resolveClient(url)
private fun sessionClient(): OkHttpClient =
okHttpClient.newBuilder()
.callTimeout(SESSION_CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -124,8 +124,15 @@ class EndpointResolver(
* expected path for plain JVM tests.
*/
private val context: Context? = null,
/** Route-aware client for pinned plugin proxy probes. */
private val clientForCandidate: ((EndpointCandidate) -> OkHttpClient?)? = null,
/**
* Route-aware client for pinned plugin proxy probes.
* Second arg is the concrete probe request URL when known — callers must
* pin only when *this* request targets the Secure Link authority. Using a
* pin client for every surface on a LAN candidate that merely *stores* a
* Secure Link relay URL breaks plain :9119/:8642 probes (authority guard
* throws IOException → "Unreachable - IOException").
*/
private val clientForCandidate: ((EndpointCandidate, probeRequestUrl: String?) -> OkHttpClient?)? = null,
) {
/**
@@ -556,7 +563,9 @@ class EndpointResolver(
)
}
}
val fastClient = (clientForCandidate?.invoke(candidate) ?: httpClient).newBuilder()
val fastClient = (
clientForCandidate?.invoke(candidate, target.requestUrl) ?: httpClient
).newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
@@ -3,7 +3,6 @@ package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import com.hermesandroid.relay.data.isValidPinnedProxy
import okhttp3.CertificatePinner
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.net.URI
@@ -66,9 +65,10 @@ fun EndpointCandidate.pluginProxyRoutesOrNull(): PluginProxyRoutes? =
private fun formatHost(host: String): String = if (':' in host) "[$host]" else host
/**
* Build a client that trusts the system normally, plus exactly the
* pairing-advertised SPKI for this proxy. The authority guard keeps a pin
* scoped to host *and port*; OkHttp's CertificatePinner alone is host-only.
* Require the paired leaf SPKI for both system-trusted and self-signed chains.
* Validate it in the trust manager, before OkHttp's chain cleaning, so a
* self-signed paired leaf does not depend on a platform-supplied cleaned chain.
* The authority guard applies to HTTP calls and WebSocket upgrades alike.
*/
fun buildPluginProxyClient(
baseBuilder: OkHttpClient.Builder,
@@ -88,12 +88,12 @@ fun buildPluginProxyClient(
if (rawSocketFactory != null) baseBuilder.socketFactory(rawSocketFactory)
return baseBuilder
.sslSocketFactory(sslContext.socketFactory, pinnedTrust)
.certificatePinner(
CertificatePinner.Builder().add(expectedHost, routes.pinSha256).build(),
)
.addNetworkInterceptor(Interceptor { chain ->
.followRedirects(false)
.followSslRedirects(false)
.addInterceptor(Interceptor { chain ->
val requestUrl = chain.request().url
if (!requestUrl.host.equals(expectedHost, ignoreCase = true) ||
if (!requestUrl.isHttps ||
!requestUrl.host.equals(expectedHost, ignoreCase = true) ||
requestUrl.port != expectedPort
) {
throw java.io.IOException("Pinned proxy redirect left its paired authority")
@@ -122,7 +122,7 @@ private fun systemTrustManager(): X509TrustManager {
return factory.trustManagers.filterIsInstance<X509TrustManager>().single()
}
private class PinnedOrSystemTrustManager(
internal class PinnedOrSystemTrustManager(
private val system: X509TrustManager,
private val expectedPin: String,
) : X509TrustManager {
@@ -133,10 +133,8 @@ private class PinnedOrSystemTrustManager(
val certificates = chain?.takeIf { it.isNotEmpty() }
?: throw CertificateException("Proxy supplied no certificate chain")
val systemAccepted = runCatching { system.checkServerTrusted(chain, authType) }.isSuccess
if (systemAccepted) return
val leaf = certificates.first()
leaf.checkValidity()
if (!systemAccepted) leaf.checkValidity()
val actual = "sha256/" + java.util.Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(leaf.publicKey.encoded),
)
@@ -363,7 +363,7 @@ data class DashboardFetchedFile(
*/
class DashboardApiClient(
baseUrl: String,
private val okHttpClient: OkHttpClient = defaultClient(),
internal val okHttpClient: OkHttpClient = defaultClient(),
private val ownsHttpClient: Boolean = true,
private val json: Json = Json {
ignoreUnknownKeys = true
@@ -277,7 +277,7 @@ class GatewayChatClient(
private val json = Json { ignoreUnknownKeys = true }
private val client: OkHttpClient = (okHttpClient ?: OkHttpClient())
private fun socketClient(base: OkHttpClient): OkHttpClient = base
.newBuilder()
// The 10s default connectTimeout is LAN-tuned; a remote dashboard
// reached over Tailscale (DERP cold start) can take longer to complete
@@ -294,8 +294,19 @@ class GatewayChatClient(
* being torn down — the in-flight turn's session is server-side and the
* same shared gateway sits behind both routes.
*/
private data class RouteTransport(
val dashboard: DashboardApiClient,
val socket: OkHttpClient,
)
@Volatile
private var dashboardClient: DashboardApiClient = initialDashboardClient
private var routeTransport = RouteTransport(
initialDashboardClient,
socketClient(okHttpClient ?: initialDashboardClient.okHttpClient),
)
private val dashboardClient: DashboardApiClient
get() = routeTransport.dashboard
private val _connectionState = MutableStateFlow(GatewayConnectionState.Idle)
val connectionState: StateFlow<GatewayConnectionState> = _connectionState.asStateFlow()
@@ -1001,7 +1012,7 @@ class GatewayChatClient(
fun retarget(newDashboardClient: DashboardApiClient) {
if (dashboardClient === newDashboardClient) return
Log.i(TAG, "Gateway retargeting to a new route (turn active=${hasActiveTurn()})")
dashboardClient = newDashboardClient
routeTransport = RouteTransport(newDashboardClient, socketClient(newDashboardClient.okHttpClient))
if (hasActiveTurn()) {
retargetedThisTurn = activeTurn?.ended == false
webSocket?.cancel()
@@ -3080,12 +3091,14 @@ class GatewayChatClient(
}
private suspend fun connectOnce() {
// Ticket, URL and TLS/auth policy must belong to one route snapshot.
val transport = routeTransport
val connectStart = System.nanoTime()
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.MintingTicket
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
val ticket = transport.dashboard.requestWsTicket().getOrElse { e ->
val statusCode = (e as? DashboardHttpException)?.statusCode
val authFailure = statusCode in setOf(401, 403)
val rateLimited = statusCode == 429
@@ -3108,8 +3121,15 @@ class GatewayChatClient(
)
}
val ticketMs = (System.nanoTime() - connectStart) / 1_000_000
if (transport !== routeTransport) {
throw GatewayConnectAttemptException(
"Gateway route changed while minting a ticket",
GatewayConnectFailureStage.Ticket,
retryable = true,
)
}
val socketProfile = currentSessionProfile()
val url = dashboardClient.gatewayWebSocketUrl(
val url = transport.dashboard.gatewayWebSocketUrl(
ticket = ticket.ticket,
profile = socketProfile,
)
@@ -3122,7 +3142,7 @@ class GatewayChatClient(
_connectionState.value = GatewayConnectionState.Connecting
val ready = CompletableDeferred<Unit>()
readySignal = ready
val socket = client.newWebSocket(
val socket = transport.socket.newWebSocket(
Request.Builder().url(url).build(),
createListener(ready),
)
@@ -106,6 +106,9 @@ internal class HermesRuntimeBinder(
},
apiBearerTokenProvider = connection::getApiKey,
dashboardHttpClientProvider = connection::dashboardHttpClientForRelayIngress,
pluginProxyHttpClientProvider = { url ->
connection.pluginProxyClientForUrl(url, includeRelaySessionHeader = false)
},
dashboardIngressWebSocketRequestProvider = connection::dashboardRelayRequestForIngress,
)
val standardVoiceClient = StandardHermesVoiceClient(
@@ -1095,14 +1095,16 @@ fun RelayApp() {
}
}
// Observe theme preference
val themePreference by connectionViewModel.theme.collectAsState()
val appThemeId by connectionViewModel.appTheme.collectAsState()
val fontScale by connectionViewModel.fontScale.collectAsState()
val appFontId by connectionViewModel.appFont.collectAsState()
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
// The same decoded emission that releases splash readiness owns the first
// real frame; individual settings flows can hydrate independently later.
val appearance by connectionViewModel.appearance.collectAsState()
val themePreference = appearance.themePreference
val appThemeId = appearance.appThemeId
val fontScale = appearance.fontScale
val appFontId = appearance.appFontId
val appearanceAccent = appearance.accentHex
val appearanceShape = appearance.shapeId
val activeCustomTheme = appearance.customTheme
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
@@ -1198,12 +1198,18 @@ fun ActiveCardSecurityPosture(
val authState by connectionViewModel.authState.collectAsState()
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
// Live dashboard path (Secure Link / preferred route), not only the saved
// plain :9119 configuredDashboardUrl that pairing still stores alongside.
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val dashboardDisplayUrl = effectiveDashboardUrl.trim().trimEnd('/').ifBlank {
activeConnection?.resolvedDashboardUrl.orEmpty()
}
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired = dashboardStatus?.authRequired == true &&
dashboardStatus.authenticated != true
val dashboardValue = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() ->
dashboardDisplayUrl.isBlank() ->
stringResource(R.string.active_section_not_configured)
dashboardStatus == null -> stringResource(R.string.active_section_not_checked)
!dashboardStatus.reachable -> stringResource(R.string.active_section_unreachable)
@@ -1255,7 +1261,7 @@ fun ActiveCardSecurityPosture(
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = activeConnection?.resolvedDashboardUrl.orEmpty().ifBlank {
text = dashboardDisplayUrl.ifBlank {
stringResource(R.string.active_section_not_configured)
},
style = MaterialTheme.typography.bodySmall,
@@ -71,6 +71,7 @@ import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.network.shared.EndpointSurface
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.network.shared.pluginProxyRoutesOrNull
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
import java.net.URI
@@ -429,11 +430,6 @@ private fun EndpointRow(
color = MaterialTheme.colorScheme.tertiary,
)
}
Text(
text = stringResource(R.string.secure_link_auth_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -582,10 +578,10 @@ private fun RouteSurfaceMap(
outcomeFor: (EndpointSurface) -> RouteProbeOutcome? = { null },
modifier: Modifier = Modifier,
) {
val dashboardUrl = candidate.dashboard?.url
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
val apiUrl = candidate.api?.url
val relayUrl = candidate.relay?.url
val proxy = candidate.pluginProxyRoutesOrNull()
val dashboardUrl = candidate.gatewayRouteUrl()
val apiUrl = candidate.api?.url ?: proxy?.apiBaseUrl
val relayUrl = candidate.relay?.url ?: proxy?.relayWebSocketUrl
val dashboardOutcome = outcomeFor(EndpointSurface.Dashboard)
val apiOutcome = outcomeFor(EndpointSurface.Api)
val relayOutcome = outcomeFor(EndpointSurface.Relay)
@@ -60,7 +60,9 @@ import com.hermesandroid.relay.ui.theme.appearanceTopRoundedCornerShape
@Composable
fun ModelPickerSheet(
options: List<ChatInputPickerOption>,
loading: Boolean = false,
refreshing: Boolean = false,
error: String? = null,
onRefresh: (() -> Unit)? = null,
onSelect: (ChatInputPickerOption) -> Unit,
onDismiss: () -> Unit,
@@ -210,11 +212,28 @@ fun ModelPickerSheet(
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.model_picker_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
modelOptions.isEmpty() && loading -> Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(12.dp))
Text(stringResource(R.string.dashboard_loading_provider_catalog))
}
modelOptions.isEmpty() && error != null -> Text(
text = stringResource(R.string.dashboard_model_options_load_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
else -> Text(
text = stringResource(
if (modelOptions.isEmpty()) R.string.model_picker_no_models
else R.string.model_picker_empty,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -1119,7 +1119,9 @@ fun ChatScreen(
val serverModelName by chatViewModel.serverModelName.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val modelOptionsLoading by chatViewModel.modelOptionsLoading.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val modelOptionsError by chatViewModel.modelOptionsError.collectAsState()
val modelSelectionConfirmation by chatViewModel.modelSelectionConfirmation.collectAsState()
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
@@ -1454,6 +1456,11 @@ fun ChatScreen(
composerDraftKey.sessionId,
) { mutableStateOf<Int?>(null) }
var showModelSheet by remember { mutableStateOf(false) }
LaunchedEffect(showModelSheet, isGatewayTransport, currentSessionId, selectedProfile?.name, activeConnection?.id) {
if (showModelSheet && isGatewayTransport) {
chatViewModel.refreshModelOptions(catalogOnly = true)
}
}
var showEffortSheet by remember { mutableStateOf(false) }
var showAgentInfo by remember { mutableStateOf(false) }
var showProfileShelf by remember { mutableStateOf(false) }
@@ -4338,8 +4345,9 @@ fun ChatScreen(
fallbackModelDetail,
serverDefaultModelDetail,
hasModelChoices,
isGatewayTransport,
) {
if (!hasModelChoices && fallbackModelDetail.isNullOrBlank()) {
if (!isGatewayTransport && !hasModelChoices && fallbackModelDetail.isNullOrBlank()) {
emptyList()
} else {
buildList {
@@ -4412,7 +4420,7 @@ fun ChatScreen(
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
contentDescription = stringResource(R.string.cd_select_model),
options = it,
enabled = chatReady && !isStreaming && it.size > 1,
enabled = chatReady && !isStreaming && (isGatewayTransport || it.size > 1),
)
}
val normalizedEffort = normalizeReasoningEffortForInput(selectedReasoningEffort)
@@ -4778,7 +4786,9 @@ fun ChatScreen(
if (showModelSheet) {
ModelPickerSheet(
options = modelPickerOptions,
loading = modelOptionsLoading,
refreshing = modelOptionsRefreshing,
error = modelOptionsError,
onRefresh = {
chatViewModel.refreshModelOptions(refresh = true, catalogOnly = true)
},
@@ -0,0 +1,74 @@
package com.hermesandroid.relay.ui.theme
import androidx.appcompat.app.AppCompatDelegate
import androidx.datastore.preferences.core.Preferences
import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.CustomThemePreset
import com.hermesandroid.relay.data.PersistedAppearance
/**
* Maps the persisted appearance preference onto AppCompat's night mode.
*
* Compose paints its own palette, while the activity and platform surfaces use
* Theme.AppCompat.DayNight. Both resolve from the same saved appearance.
*/
internal object AppearanceNightMode {
private val VALID_PREFERENCES = setOf("auto", "light", "dark")
fun normalizePreference(raw: String?): String =
raw?.takeIf { it in VALID_PREFERENCES } ?: "auto"
fun nightModeFor(
themePreference: String,
themeMode: ThemeMode,
customTheme: CustomThemePreset? = null,
): Int {
customTheme?.let { preset ->
return if (preset.isDark) {
AppCompatDelegate.MODE_NIGHT_YES
} else {
AppCompatDelegate.MODE_NIGHT_NO
}
}
return when (themeMode) {
ThemeMode.DARK_ONLY -> AppCompatDelegate.MODE_NIGHT_YES
ThemeMode.LIGHT_ONLY -> AppCompatDelegate.MODE_NIGHT_NO
ThemeMode.BOTH -> when (normalizePreference(themePreference)) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}
fun nightModeFor(appearance: PersistedAppearance): Int {
val appTheme = appearance.customTheme?.toAppTheme() ?: AppThemes.byId(appearance.appThemeId)
return nightModeFor(
themePreference = appearance.customTheme?.mode ?: appearance.themePreference,
themeMode = appTheme.mode,
customTheme = appearance.customTheme,
)
}
fun nightModeFor(preferences: Preferences): Int =
nightModeFor(AppearancePreferences.decode(preferences))
/** Apply only when the mode actually changes — avoids redundant uiMode churn. */
fun apply(nightMode: Int) {
try {
if (AppCompatDelegate.getDefaultNightMode() != nightMode) {
AppCompatDelegate.setDefaultNightMode(nightMode)
}
} catch (_: Throwable) {
// Robolectric / headless hosts may not support night-mode switches.
}
}
fun applyFromPreferences(preferences: Preferences) {
apply(nightModeFor(preferences))
}
fun applyFromAppearance(appearance: PersistedAppearance) {
apply(nightModeFor(appearance))
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -10,7 +11,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Density
import androidx.core.view.WindowCompat
import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.CustomThemePreset
import com.hermesandroid.relay.data.PersistedAppearance
@@ -63,9 +66,22 @@ fun HermesRelayTheme(
// call sites observe the active palette. SideEffect runs post-composition,
// avoiding a state-write-during-composition; the default theme matches the
// façade's initial value, so the common path has no first-frame flash.
//
// Match system-bar icon contrast to the palette in this window. AppCompat
// night mode follows persisted Appearance independently of composable
// previews and temporary supervised/loading palettes.
val view = LocalView.current
SideEffect {
RelayRefresh.activePalette = palette
RelayRefresh.activeShapeScale = shapeScale
if (!view.isInEditMode) {
val activity = view.context as? Activity
if (activity != null) {
val controller = WindowCompat.getInsetsController(activity.window, view)
controller.isAppearanceLightStatusBars = !useDarkTheme
controller.isAppearanceLightNavigationBars = !useDarkTheme
}
}
}
CompositionLocalProvider(
@@ -1127,6 +1127,9 @@ class ChatViewModel : ViewModel() {
modelSelectionRevision.incrementAndGet()
_modelSelectionConfirmation.value = null
modelOptionsGeneration.incrementAndGet()
_modelOptionsLoading.value = false
_modelOptionsRefreshing.value = false
_modelOptionsError.value = null
val cached = modelOptionsByProfile[profileKey]
_modelProviders.value = cached?.providers.orEmpty()
relayCapabilityGeneration.incrementAndGet()
@@ -1144,6 +1147,10 @@ class ChatViewModel : ViewModel() {
/** True only during an explicit user-requested dynamic model catalog refresh. */
private val _modelOptionsRefreshing = MutableStateFlow(false)
val modelOptionsRefreshing: StateFlow<Boolean> = _modelOptionsRefreshing.asStateFlow()
private val _modelOptionsLoading = MutableStateFlow(false)
val modelOptionsLoading: StateFlow<Boolean> = _modelOptionsLoading.asStateFlow()
private val _modelOptionsError = MutableStateFlow<String?>(null)
val modelOptionsError: StateFlow<String?> = _modelOptionsError.asStateFlow()
/** Current gateway model from `model.options`, used when no Android override is active. */
private val _gatewayCurrentModel = MutableStateFlow("")
@@ -1231,16 +1238,20 @@ class ChatViewModel : ViewModel() {
val gateway = gatewayClient ?: run {
android.util.Log.i("ChatViewModel", "refreshModelOptions: no gateway client")
if (refresh) _modelOptionsRefreshing.value = false
_modelOptionsLoading.value = false
_modelOptionsError.value = "Gateway unavailable."
return
}
if (refresh && _modelOptionsRefreshing.value) return
if (_modelOptionsRefreshing.value) return
if (refresh) _modelOptionsRefreshing.value = true
_modelOptionsLoading.value = true
_modelOptionsError.value = null
val generation = modelOptionsGeneration.incrementAndGet()
val profileKey = modelOptionsProfileKey()
viewModelScope.launch {
gateway.modelOptions(refresh = refresh).fold(
onSuccess = {
if (!isCurrentModelOptionsResponse(
if (gatewayClient !== gateway || !isCurrentModelOptionsResponse(
generation,
modelOptionsGeneration.get(),
profileKey,
@@ -1273,12 +1284,22 @@ class ChatViewModel : ViewModel() {
},
onFailure = {
android.util.Log.w("ChatViewModel", "model.options failed: ${it.message}")
if (refresh) {
_transientNotice.tryEmit("Couldn't refresh models: ${it.message ?: "unknown error"}")
if (gatewayClient === gateway && isCurrentModelOptionsResponse(
generation, modelOptionsGeneration.get(),
profileKey, modelOptionsProfileKey(),
)
) {
_modelOptionsError.value = it.message ?: "Model catalog unavailable."
if (refresh) {
_transientNotice.tryEmit("Couldn't refresh models: ${it.message ?: "unknown error"}")
}
}
},
)
if (refresh) _modelOptionsRefreshing.value = false
if (gatewayClient === gateway && generation == modelOptionsGeneration.get()) {
_modelOptionsLoading.value = false
if (refresh) _modelOptionsRefreshing.value = false
}
}
}
@@ -1513,6 +1534,9 @@ class ChatViewModel : ViewModel() {
) {
val client = apiClient ?: return
val generation = modelOptionsGeneration.incrementAndGet()
_modelOptionsLoading.value = false
_modelOptionsRefreshing.value = false
_modelOptionsError.value = null
val profileKey = modelOptionsProfileKey()
viewModelScope.launch {
val providerResult = client.getProviderModelOptions()
@@ -2122,6 +2146,9 @@ class ChatViewModel : ViewModel() {
// what the agent actually runs. The next session.create then binds the
// profile's own model.
modelOptionsGeneration.incrementAndGet()
_modelOptionsLoading.value = false
_modelOptionsRefreshing.value = false
_modelOptionsError.value = null
_modelProviders.value = emptyList()
_apiModelOptions.value = emptyList()
_availableModels.value = emptyList()
@@ -2732,6 +2759,10 @@ class ChatViewModel : ViewModel() {
val previousClient = gatewayClient
val changed = previousClient !== client
if (changed) {
modelOptionsGeneration.incrementAndGet()
_modelOptionsLoading.value = false
_modelOptionsRefreshing.value = false
_modelOptionsError.value = null
clearProjectedBackgroundProcesses()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
@@ -5420,6 +5451,9 @@ class ChatViewModel : ViewModel() {
/** Clear server-owned catalogs before a different connection starts loading. */
fun resetConnectionCatalogs() {
modelOptionsGeneration.incrementAndGet()
_modelOptionsLoading.value = false
_modelOptionsRefreshing.value = false
_modelOptionsError.value = null
modelOptionsByProfile.clear()
apiSessionModelLocks.clear()
_availableSkills.value = emptyList()
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.ui.theme.AppFont
import com.hermesandroid.relay.ui.theme.AppThemes
import com.hermesandroid.relay.ui.theme.AppearanceNightMode
import com.hermesandroid.relay.ui.theme.normalizeAccentHex
import com.hermesandroid.relay.ui.theme.AppearanceShape
import com.hermesandroid.relay.ui.components.avatar.PetImporter
@@ -31,6 +32,7 @@ import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.auth.PairedSession
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.PersistedAppearance
import com.hermesandroid.relay.data.CustomThemePreset
import com.hermesandroid.relay.data.DataManager
import com.hermesandroid.relay.data.DemoContent
@@ -1185,9 +1187,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private val endpointResolver = EndpointResolver(
httpClient = endpointProbeClient,
clientForCandidate = { candidate ->
candidate.pluginProxyRoutesOrNull()?.let { proxy ->
val tokenProvider = { (authManager.authState.value as? AuthState.Paired)?.token }
clientForCandidate = { candidate, probeRequestUrl ->
val tokenProvider = { (authManager.authState.value as? AuthState.Paired)?.token }
candidate.pluginProxyRoutesOrNull()?.takeIf { proxy ->
proxy.authority.equals(
probeRequestUrl?.let(com.hermesandroid.relay.auth.CertPinStore::hostPortFromUrl),
ignoreCase = true,
)
}?.let { proxy ->
if (candidate.hermesReachRouteOrNull() != null) {
buildHermesReachClient(
baseBuilder = endpointProbeClient.newBuilder(),
@@ -1202,6 +1209,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
sessionTokenProvider = tokenProvider,
)
}
} ?: run {
// LAN sometimes stores Secure Link relay URL (wss://…:9443/…).
// Pin ONLY when this probe actually hits that authority —
// not for plain dashboard :9119 / API :8642 on the same
// candidate (pin client's authority guard → IOException).
val requestUrl = probeRequestUrl?.trim().orEmpty()
if (requestUrl.isBlank()) return@run null
val targetAuthority = com.hermesandroid.relay.auth.CertPinStore
.hostPortFromUrl(requestUrl)
?: return@run null
activeConnection.value?.routeCandidates.orEmpty()
.mapNotNull { it.pluginProxyRoutesOrNull() }
.firstOrNull { routes ->
routes.authority.equals(targetAuthority, ignoreCase = true)
}
?.let { routes ->
buildPluginProxyClient(
baseBuilder = endpointProbeClient.newBuilder(),
routes = routes,
sessionTokenProvider = tokenProvider,
)
}
}
},
context = application,
@@ -1292,6 +1321,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
(authManager.authState.value as? AuthState.Paired)?.token
},
dashboardHttpClientProvider = ::dashboardHttpClientForRelayIngress,
// Secure Link relay HTTP must use the same pin TrustManager as WSS;
// default OkHttp only has the system CA store and rejects the leaf.
pluginProxyHttpClientProvider = { url ->
pluginProxyClientForUrl(
url = url,
baseClient = relayOkHttp,
includeRelaySessionHeader = false,
)
},
)
// Pairing-management collaborator — owns the paired-devices list
@@ -1561,7 +1599,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
?: connectionManager.activeRelayEndpoint.value?.relay?.url
?: autoRelayUrlSnapshot()
private fun pluginProxyClientForUrl(
internal fun pluginProxyClientForUrl(
url: String,
baseClient: OkHttpClient? = null,
includeRelaySessionHeader: Boolean = true,
@@ -2164,10 +2202,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
@Deprecated("Use relayConnectionState", replaceWith = ReplaceWith("relayConnectionState"))
val connectionState: StateFlow<ConnectionState> = relayConnectionState
// One snapshot from the DataStore emission that also releases splash
// readiness. The app root must not compose a first frame from separately
// hydrated theme, preset, font, and shape StateFlows.
private val _appearance = MutableStateFlow(PersistedAppearance())
internal val appearance: StateFlow<PersistedAppearance> = _appearance.asStateFlow()
// Theme preference — light/dark/auto mode axis.
val theme: StateFlow<String> = application.relayDataStore.data
.map { preferences ->
preferences[AppearancePreferences.themeKey] ?: "auto"
AppearanceNightMode.normalizePreference(preferences[AppearancePreferences.themeKey])
}
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
@@ -5118,6 +5162,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
var prevApiKey: String? = null
application.relayDataStore.data.collect { preferences ->
val persistedAppearance = AppearancePreferences.decode(preferences)
_appearance.value = persistedAppearance
AppearanceNightMode.applyFromAppearance(persistedAppearance)
// Restore insecure mode
val insecure = preferences[KEY_INSECURE_MODE] ?: false
connectionManager.setInsecureMode(insecure)
@@ -7316,6 +7364,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
},
apiBearerTokenProvider = { authManager.getApiKey() },
dashboardHttpClientProvider = ::dashboardHttpClientForRelayIngress,
pluginProxyHttpClientProvider = { url ->
pluginProxyClientForUrl(
url = url,
baseClient = relayOkHttp,
includeRelaySessionHeader = false,
)
},
dashboardIngressWebSocketRequestProvider = ::dashboardRelayRequestForIngress,
).getVoiceConfig()
} else {
@@ -8397,9 +8452,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// --- Shared methods ---
fun setTheme(theme: String) {
val normalized = AppearanceNightMode.normalizePreference(theme)
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[AppearancePreferences.themeKey] = theme
preferences[AppearancePreferences.themeKey] = normalized
}
}
}
@@ -481,8 +481,17 @@ class UpstreamTransportController(
}
gatewayClientCache?.third?.shutdown()
lateinit var client: GatewayChatClient
// Dashboard REST already carries the pairing SPKI pin for Secure Link.
// The gateway WS upgrade must use the SAME client — a bare OkHttpClient
// rejects the self-signed Secure Link cert ("Trust anchor … not found")
// and leaves Chat stuck on "Checking gateway…".
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
val gatewayHttpClient = dashboardRestHttpClients[
connectionId to dashboardUrl.trim().trimEnd('/'),
]
client = GatewayChatClient(
initialDashboardClient = dashboardClientFor(connectionId, dashboardUrl),
initialDashboardClient = dashboardClient,
okHttpClient = gatewayHttpClient,
onGatewayUnsupported = {
updateGatewayAvailabilityIfCurrent(
connectionId,
@@ -554,12 +563,16 @@ class UpstreamTransportController(
if (cached.activeRequests == 0 && cached.retained == 0) shutdownRouteEntry(cached)
}
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
val gatewayHttpClient = dashboardRestHttpClients[
connectionId to dashboardUrl.trim().trimEnd('/'),
]
entry = RouteGatewayEntry(
dashboardUrl = dashboardUrl,
dashboardClient = dashboardClient,
client = GatewayChatClient(
initialDashboardClient = dashboardClient,
fixedSessionProfile = profile,
okHttpClient = gatewayHttpClient,
).also { it.setKeepAliveInBackground(gatewayKeepAliveProvider()) },
)
if (retain) entry.retained = 1 else entry.activeRequests = 1
@@ -2671,6 +2671,7 @@
<string name="model_picker_title">Modelo</string>
<string name="model_picker_search">Pesquisar modelos ou provedores…</string>
<string name="model_picker_empty">Nenhum modelo corresponde à pesquisa</string>
<string name="model_picker_no_models">Nenhum modelo disponível. Tente atualizar.</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">O que o agente vê</string>
<string name="context_sheet_transparency">O contexto adicional exato adicionado ao início do próximo turno, para transparência.</string>
@@ -2782,6 +2782,7 @@
<string name="model_picker_title">模型</string>
<string name="model_picker_search">搜索模型或提供商…</string>
<string name="model_picker_empty">没有匹配您搜索的模型</string>
<string name="model_picker_no_models">没有可用的模型。请尝试刷新。</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">代理看到的内容</string>
+1
View File
@@ -2788,6 +2788,7 @@
<string name="model_picker_title">Modell</string>
<string name="model_picker_search">Modelle oder Anbieter suchen&#8230;</string>
<string name="model_picker_empty">Keine Modelle entsprechen deiner Suche</string>
<string name="model_picker_no_models">Keine Modelle verfügbar. Versuche es mit Aktualisieren.</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">Was der Agent sieht</string>
+1
View File
@@ -2551,6 +2551,7 @@
<string name="model_picker_title">Modelo</string>
<string name="model_picker_search">Buscar modelos o proveedores…</string>
<string name="model_picker_empty">Ningún modelo coincide con tu búsqueda</string>
<string name="model_picker_no_models">No hay modelos disponibles. Prueba a actualizar.</string>
<string name="context_sheet_agent_sees">Lo que ve el agente</string>
<string name="context_sheet_transparency">El contexto adicional exacto antepuesto a tu próximo turno, para mayor transparencia.</string>
<string name="context_sheet_persona">Persona/perfil</string>
+1
View File
@@ -2796,6 +2796,7 @@
<string name="model_picker_title">モデル</string>
<string name="model_picker_search">モデルまたはプロバイダーを検索&#8230;</string>
<string name="model_picker_empty">検索に一致するモデルはありません</string>
<string name="model_picker_no_models">利用可能なモデルがありません。更新してください。</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">エージェントが見ているもの</string>
+1
View File
@@ -2776,6 +2776,7 @@
<string name="model_picker_title">Модель</string>
<string name="model_picker_search">Поиск моделей или поставщиков\&amp;#8230;</string>
<string name="model_picker_empty">Нет моделей, соответствующих вашему запросу</string>
<string name="model_picker_no_models">Нет доступных моделей. Попробуйте обновить список.</string>
<string name="context_sheet_agent_sees">Что видит агент</string>
<string name="context_sheet_transparency">Точный дополнительный контекст, добавляемый к вашему следующему ходу, для прозрачности.</string>
<string name="context_sheet_persona">Персона / профиль</string>
+1
View File
@@ -3180,6 +3180,7 @@
<string name="model_picker_title">Model</string>
<string name="model_picker_search">Search models or providers&#8230;</string>
<string name="model_picker_empty">No models match your search</string>
<string name="model_picker_no_models">No models available. Try Refresh.</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">What the agent sees</string>
@@ -78,6 +78,35 @@ class RelayHttpClientDiagnosticsTest {
}
}
@Test
fun secureLinkHealthWithoutVersionStillSucceeds() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""{"status":"ok","surface":"hermes_secure_proxy","security":"pinned_tls"}""",
),
)
server.start()
try {
val configuredRelay = "ws://${server.hostName}:${server.port}/relay/ws"
val client = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { configuredRelay },
sessionTokenProvider = { null },
)
val result = client.probeHealth(configuredRelay)
assertTrue(result.isSuccess)
assertEquals("secure-link", result.getOrNull()?.version)
assertTrue(
DiagnosticsLog.recent(setOf(DiagnosticCategory.Relay))
.none { it.detail == "Missing version field" },
)
} finally {
server.shutdown()
}
}
@Test
fun dashboardIngressUsesOuterAuthClientAndSeparateRelayHeader() = runTest {
val server = MockWebServer()
@@ -73,6 +73,29 @@ class RelayVoiceClientRoutingTest {
runCatching { tailscaleServer.shutdown() }
}
@Test
fun proxyProviderOwnsBothVoiceSessionAndWebSocketRequests() = runTest {
val selected = Collections.synchronizedList(mutableListOf<String>())
val requests = Collections.synchronizedList(mutableListOf<String>())
val proxyClient = httpClient.newBuilder().addInterceptor { chain ->
requests.add(chain.request().url.encodedPath)
chain.proceed(chain.request())
}.build()
val client = RelayVoiceClient(
context = context,
okHttpClient = httpClient.newBuilder().addInterceptor {
throw IOException("generic client must not handle this route")
}.build(),
relayUrlProvider = { relayUrl(lanServer) },
sessionTokenProvider = { "session-token" },
pluginProxyHttpClientProvider = { url -> selected.add(url); proxyClient },
)
val result = client.runVoiceOutput("Pinned route") {}
assertTrue(result.exceptionOrNull()?.message, result.isSuccess)
assertEquals(listOf("/voice/output/session", "/voice/output/session-test"), requests)
assertTrue(selected.any { it.endsWith("/voice/output/session-test") })
}
@Test
fun realtimeAgentAndVoiceOutputFollowSameEffectiveRelayUrlProvider() = runTest {
var activeRelayUrl = relayUrl(lanServer)
@@ -1,6 +1,23 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.ProxyEndpoint
import io.mockk.every
import io.mockk.mockk
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import java.security.MessageDigest
import java.security.PublicKey
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Base64
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.net.ssl.X509TrustManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -9,6 +26,68 @@ import org.junit.Test
class PluginProxyTransportTest {
private val pin = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
@Test
fun `system trust never bypasses the paired leaf pin`() {
val key = mockk<PublicKey>()
every { key.encoded } returns byteArrayOf(1, 2, 3)
val leaf = mockk<X509Certificate>(relaxed = true)
every { leaf.publicKey } returns key
val matchingPin = "sha256/" + Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(key.encoded),
)
for (systemAccepted in listOf(true, false)) {
val system = mockk<X509TrustManager>(relaxed = true)
if (!systemAccepted) {
every { system.checkServerTrusted(any(), any()) } throws CertificateException("untrusted")
}
PinnedOrSystemTrustManager(system, matchingPin).checkServerTrusted(arrayOf(leaf), "RSA")
assertThrows(CertificateException::class.java) {
PinnedOrSystemTrustManager(system, pin).checkServerTrusted(arrayOf(leaf), "RSA")
}
assertThrows(CertificateException::class.java) {
PinnedOrSystemTrustManager(system, matchingPin).checkServerTrusted(emptyArray(), "RSA")
}
}
val rejectingSystem = mockk<X509TrustManager>()
every { rejectingSystem.checkServerTrusted(any(), any()) } throws CertificateException("untrusted")
every { leaf.checkValidity() } throws CertificateException("expired")
assertThrows(CertificateException::class.java) {
PinnedOrSystemTrustManager(rejectingSystem, matchingPin).checkServerTrusted(arrayOf(leaf), "RSA")
}
}
@Test
fun `authority guard rejects HTTP and WebSocket before credentials or network`() {
var credentialsRead = false
val client = buildPluginProxyClient(
OkHttpClient.Builder(),
ProxyEndpoint("https://paired.invalid:9443", pinSha256 = pin).toPluginProxyRoutesOrNull()!!,
sessionTokenProvider = { credentialsRead = true; "session-token" },
)
assertFalse(client.followRedirects)
assertFalse(client.followSslRedirects)
for (url in listOf("http://paired.invalid:9443/relay", "https://other.invalid:9443/relay", "https://paired.invalid:9444/relay")) {
val failure = assertThrows(java.io.IOException::class.java) {
client.newCall(Request.Builder().url(url).build()).execute().close()
}
assertTrue(failure.message.orEmpty().contains("paired authority"))
}
val failed = CountDownLatch(1)
var socketFailure: Throwable? = null
client.newWebSocket(Request.Builder().url("wss://paired.invalid:9444/relay/ws").build(),
object : WebSocketListener() {
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
socketFailure = t
failed.countDown()
}
},
)
assertTrue(failed.await(3, TimeUnit.SECONDS))
assertTrue(socketFailure?.message.orEmpty().contains("paired authority"))
assertFalse(credentialsRead)
client.dispatcher.executorService.shutdown()
}
@Test
fun `derives all proxy surfaces from one authority`() {
val routes = ProxyEndpoint(
@@ -20,6 +20,7 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import okhttp3.WebSocketListener
@@ -2087,6 +2088,85 @@ class GatewayChatClientTest {
assertEquals(GatewayReconnectDisposition.Terminal, client.reconnectDisposition.value)
}
@Test
fun `active route retarget replaces socket policy and preserves the live turn`() {
val replacement = GatewayClientHarness()
fun dashboardFor(target: GatewayClientHarness): DashboardApiClient = DashboardApiClient(
baseUrl = target.server.url("/").toString(),
okHttpClient = OkHttpClient.Builder().addInterceptor { chain ->
if (chain.request().url.port != target.server.port) {
throw java.io.IOException("transport belongs to another paired authority")
}
chain.proceed(chain.request())
}.build(),
)
client.shutdown()
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
client = GatewayChatClient(
initialDashboardClient = dashboardFor(harness),
scope = scope,
callbackDispatcher = { it() },
midTurnRejoinWindowMs = 3_000L,
)
try {
val recorder = Recorder()
client.sendTurn(null, "follow the route", null, recorder.callbacks) {
recorder.preflightFailures += it
}
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
client.retarget(dashboardFor(replacement))
val moved = replacement.awaitServerSocket()
val activation = replacement.awaitRpc("session.activate")
assertEquals("live-1", activation["session_id"]?.jsonPrimitive?.content)
moved.send(replacement.eventFrame("message.complete", buildJsonObject {
put("text", "Finished on the new route")
}, "live-1"))
assertTrue(recorder.completeLatch.await(5, TimeUnit.SECONDS))
assertTrue(recorder.errors.isEmpty())
assertTrue(recorder.preflightFailures.isEmpty())
assertFalse(replacement.rpcLog.any { it.first == "prompt.submit" })
} finally {
client.shutdown()
replacement.shutdown()
}
}
@Test
fun `retarget during ticket mint discards old ticket before socket upgrade`() = runBlocking {
val replacement = GatewayClientHarness()
val mintStarted = CountDownLatch(1)
val releaseMint = CountDownLatch(1)
val oldTransport = OkHttpClient.Builder().addInterceptor { chain ->
val response = chain.proceed(chain.request())
if (chain.request().url.encodedPath.endsWith("/ws-ticket")) {
mintStarted.countDown()
check(releaseMint.await(5, TimeUnit.SECONDS))
}
response
}.build()
client.shutdown()
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
client = GatewayChatClient(
initialDashboardClient = DashboardApiClient(harness.server.url("/").toString(), oldTransport),
scope = scope,
callbackDispatcher = { it() },
)
try {
val pending = async(Dispatchers.IO) { client.prewarmAwait("stored-session") }
assertTrue(mintStarted.await(3, TimeUnit.SECONDS))
client.retarget(DashboardApiClient(replacement.server.url("/").toString()))
releaseMint.countDown()
assertTrue(pending.await())
assertEquals(1, replacement.ticketMints.get())
assertTrue("old ticket must never dial a socket", harness.serverSockets.isEmpty())
} finally {
releaseMint.countDown()
client.shutdown()
replacement.shutdown()
}
}
@Test
fun `each connect attempt mints a fresh ticket`() {
val r1 = Recorder()
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
@@ -94,6 +95,37 @@ class AppearanceShapeScreenshotTest {
compose.onRoot().captureRoboImage("build/ui-evidence/appearance-shape-balanced-sheet-dark.png")
}
@Test @Config(sdk = [34]) fun coldModelPickerLoadingSurface() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
ModelPickerSheet(
options = listOf(ChatInputPickerOption("Server default", null)),
loading = true,
onRefresh = {},
onSelect = {},
onDismiss = {},
)
}
}
compose.onNodeWithText("Loading provider catalog…").assertExists()
compose.onRoot().captureRoboImage("build/ui-evidence/model-picker-cold-loading.png")
}
@Test @Config(sdk = [34]) fun coldModelPickerEmptySurface() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
ModelPickerSheet(
options = listOf(ChatInputPickerOption("Server default", null)),
onRefresh = {},
onSelect = {},
onDismiss = {},
)
}
}
compose.onNodeWithText("No models available. Try Refresh.").assertExists()
compose.onRoot().captureRoboImage("build/ui-evidence/model-picker-cold-empty.png")
}
private fun captureMode(shapeId: String, themeId: String, themePreference: String, fontScale: Float) {
compose.setContent {
HermesRelayTheme(
@@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -17,11 +18,35 @@ import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w320dp-h720dp-xxhdpi")
@Config(sdk = [35], qualifiers = "w320dp-h720dp-xxhdpi")
class EndpointsCardCompactLayoutTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `Secure Link details show derived namespaces rather than unconfigured placeholders`() {
val route = EndpointCandidate(
role = "plugin_proxy",
proxy = ProxyEndpoint(
url = "https://relay.example:9443",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay", "dashboard"),
),
)
compose.setContent {
MaterialTheme {
EndpointsCard(
endpoints = listOf(route), activeEndpoint = route,
preferredRole = null, manualOverrideRole = null,
onUseNow = {}, onCancelUseNow = {}, onPreferEndpoint = {},
onClearPreferred = {}, onProbeNow = {}, onViewPin = { null },
)
}
}
compose.onNodeWithText("https://relay.example:9443/dashboard").assertExists()
compose.onNodeWithText("wss://relay.example:9443/relay/ws").assertExists()
}
@Test
fun `long route title keeps active state on a separate visible row`() {
val title = "A very long operator-defined reverse proxy route name"
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.network.shared.EndpointSurface
import org.junit.Assert.assertEquals
@@ -56,6 +57,51 @@ class GatewayRoutesAccessPresentationTest {
assertEquals("LAN (HTTP)", presentation.label)
}
@Test
fun `Secure Link candidate presents pinned HTTPS dashboard not plain 9119`() {
val secureLink = EndpointCandidate(
role = "plugin_proxy",
priority = 0,
proxy = ProxyEndpoint(
url = "https://192.168.1.20:9443",
transportHint = "https",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay", "api", "dashboard"),
),
)
val presentation = gatewayRoutePresentation(
activeEndpoint = secureLink,
configuredDashboardUrl = "http://192.168.1.20:9119",
)
assertEquals("https://192.168.1.20:9443/dashboard", presentation.address)
assertEquals("Hermes Secure Link (HTTPS)", presentation.label)
assertFalse(presentation.publicHttpViolation)
assertTrue(presentation.configured)
}
@Test
fun `Secure Link without dashboard surface does not invent a Gateway address`() {
val relayOnlyProxy = EndpointCandidate(
role = "plugin_proxy",
proxy = ProxyEndpoint(
url = "https://192.168.1.20:9443",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay"),
),
)
val presentation = gatewayRoutePresentation(
activeEndpoint = relayOnlyProxy,
configuredDashboardUrl = "http://192.168.1.20:9119",
)
// No dashboard surface → fall back to the saved configured URL.
assertEquals("http://192.168.1.20:9119", presentation.address)
assertEquals("LAN (HTTP)", presentation.label)
}
@Test
fun `relay-only candidate leaves the Gateway route unconfigured`() {
val route = EndpointCandidate(
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import com.hermesandroid.relay.data.RelayEndpoint
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -122,6 +123,25 @@ class ConnectionDetailPresentationTest {
assertEquals("", route.address)
}
@Test
fun `Secure Link owns current route identity instead of plain LAN fallback`() {
val secureLink = EndpointCandidate(
role = "plugin_proxy",
proxy = ProxyEndpoint(
url = "https://192.168.1.20:9443",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay", "api", "dashboard"),
),
)
val route = resolveDetailRoutePresentation(
activeEndpoint = secureLink,
effectiveDashboardUrl = "https://192.168.1.20:9443/dashboard",
)
assertEquals("Hermes Secure Link (HTTPS)", route.label)
assertEquals("https://192.168.1.20:9443/dashboard", route.address)
}
@Test
fun `missing route does not claim plain HTTP`() {
val route = resolveDetailRoutePresentation(
@@ -0,0 +1,100 @@
package com.hermesandroid.relay.ui.theme
import androidx.appcompat.app.AppCompatDelegate
import androidx.datastore.preferences.core.mutablePreferencesOf
import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.CustomThemePreset
import org.junit.Assert.assertEquals
import org.junit.Test
class AppearanceNightModeTest {
@Test
fun dualModeHonorsExplicitLightAndDark() {
assertEquals(
AppCompatDelegate.MODE_NIGHT_NO,
AppearanceNightMode.nightModeFor("light", ThemeMode.BOTH),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_YES,
AppearanceNightMode.nightModeFor("dark", ThemeMode.BOTH),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
AppearanceNightMode.nightModeFor("auto", ThemeMode.BOTH),
)
}
@Test
fun fixedThemesIgnorePreferenceAxis() {
assertEquals(
AppCompatDelegate.MODE_NIGHT_NO,
AppearanceNightMode.nightModeFor("dark", ThemeMode.LIGHT_ONLY),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_YES,
AppearanceNightMode.nightModeFor("light", ThemeMode.DARK_ONLY),
)
}
@Test
fun customThemeModeWinsOverPreference() {
val light = CustomThemePreset(
id = "day",
name = "Day",
mode = CustomThemePreset.MODE_LIGHT,
backgroundHex = "#F5F5F5",
surfaceHex = "#FFFFFF",
accentHex = "#0E18D6",
textHex = "#111111",
)
val dark = light.copy(id = "night", name = "Night", mode = CustomThemePreset.MODE_DARK)
assertEquals(
AppCompatDelegate.MODE_NIGHT_NO,
AppearanceNightMode.nightModeFor("dark", ThemeMode.BOTH, light),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_YES,
AppearanceNightMode.nightModeFor("light", ThemeMode.BOTH, dark),
)
}
@Test
fun normalizePreferenceFallsBackToAuto() {
assertEquals("auto", AppearanceNightMode.normalizePreference(null))
assertEquals("auto", AppearanceNightMode.normalizePreference("sepia"))
assertEquals("light", AppearanceNightMode.normalizePreference("light"))
}
@Test
fun persistedSnapshotDrivesBothPaletteAndPlatformNightMode() {
val saved = mutablePreferencesOf(
AppearancePreferences.themeKey to "light",
AppearancePreferences.appThemeKey to AppThemes.DEFAULT_ID,
)
val appearance = AppearancePreferences.decode(saved)
assertEquals("light", appearance.themePreference)
assertEquals(AppCompatDelegate.MODE_NIGHT_NO, AppearanceNightMode.nightModeFor(appearance))
saved[AppearancePreferences.themeKey] = "auto"
assertEquals(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
AppearanceNightMode.nightModeFor(AppearancePreferences.decode(saved)),
)
val custom = CustomThemePreset(
id = "night",
name = "Night",
mode = CustomThemePreset.MODE_DARK,
backgroundHex = "#0B0B0F",
surfaceHex = "#141421",
accentHex = "#5B6CFF",
textHex = "#F5F6F7",
)
saved[AppearancePreferences.customThemesKey] =
AppearancePreferences.encodeCustomThemes(listOf(custom))
saved[AppearancePreferences.appThemeKey] = custom.appThemeId
val restoredCustom = AppearancePreferences.decode(saved)
assertEquals(custom.id, restoredCustom.customTheme?.id)
assertEquals(AppCompatDelegate.MODE_NIGHT_YES, AppearanceNightMode.nightModeFor(restoredCustom))
}
}
@@ -167,6 +167,79 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("gpt-5.6-sol", viewModel.gatewayCurrentModel.value)
}
@Test
fun coldPickerLoadCanBeForceRefreshedWithoutAChatTurnAndLateResultCannotEraseIt() {
assertTrue(viewModel.modelProviders.value.isEmpty())
gatewayHarness.suppressAckMethods += "model.options"
viewModel.refreshModelOptions(catalogOnly = true)
val coldRequest = gatewayHarness.awaitPendingAck()
assertEquals("model.options", coldRequest.method)
assertTrue(viewModel.modelOptionsLoading.value)
assertFalse(viewModel.modelOptionsRefreshing.value)
assertTrue(gatewayHarness.rpcLog.none { it.first == "prompt.submit" || it.first == "session.create" })
viewModel.refreshModelOptions(refresh = true, catalogOnly = true)
val forcedRequest = gatewayHarness.awaitPendingAck()
assertEquals("model.options", forcedRequest.method)
assertTrue(viewModel.modelOptionsRefreshing.value)
assertEquals(
true,
(gatewayHarness.rpcLog.last { it.first == "model.options" }.second["refresh"] as? JsonPrimitive)?.content == "true",
)
gatewayHarness.releaseAck(forcedRequest, buildJsonObject {
put("providers", buildJsonArray {
add(buildJsonObject {
put("slug", "openai")
put("name", "OpenAI")
put("models", buildJsonArray { add(JsonPrimitive("gpt-5.5")) })
put("authenticated", true)
})
})
put("model", "gpt-5.5")
put("provider", "openai")
})
awaitCondition { viewModel.modelProviders.value.singleOrNull()?.models == listOf("gpt-5.5") }
assertFalse(viewModel.modelOptionsLoading.value)
assertFalse(viewModel.modelOptionsRefreshing.value)
gatewayHarness.releaseAck(coldRequest, buildJsonObject {
put("providers", buildJsonArray {})
})
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertEquals(listOf("gpt-5.5"), viewModel.modelProviders.value.single().models)
viewModel.refreshModelOptions(refresh = true, catalogOnly = true)
val repeatedRefresh = gatewayHarness.awaitPendingAck()
assertEquals("model.options", repeatedRefresh.method)
gatewayHarness.releaseAck(repeatedRefresh, buildJsonObject {
put("providers", buildJsonArray {})
})
awaitCondition { !viewModel.modelOptionsLoading.value }
assertTrue(viewModel.modelProviders.value.isEmpty())
}
@Test
fun retiredConnectionCannotPublishLateColdCatalogOrKeepItsLoadingState() {
gatewayHarness.suppressAckMethods += "model.options"
viewModel.refreshModelOptions(catalogOnly = true)
val staleRequest = gatewayHarness.awaitPendingAck()
assertTrue(viewModel.modelOptionsLoading.value)
viewModel.updateGatewayClient(null)
assertFalse(viewModel.modelOptionsLoading.value)
gatewayHarness.releaseAck(staleRequest, buildJsonObject {
put("providers", buildJsonArray {
add(buildJsonObject {
put("slug", "stale")
put("models", buildJsonArray { add(JsonPrimitive("wrong-model")) })
})
})
})
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertTrue(viewModel.modelProviders.value.isEmpty())
}
@Test
fun attachingReadyGatewayDoesNotHydrateControlStateAheadOfSessions() {
viewModel.updateGatewayClient(null)
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.4.0" apply false
id("com.android.library") version "9.4.0" apply false
id("com.android.application") version "9.4.1" apply false
id("com.android.library") version "9.4.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.20" apply false
}
+8
View File
@@ -16,6 +16,14 @@ subcommands provide scriptable chat, pairing, sessions, daemon management,
grants, diagnostics, and desktop-tool routing. The optional systray is only a
Windows management surface over those same commands and state files.
Secure Link is enabled on the **server**, using Dashboard/Desktop Relay setup or
the host-side `hermes relay secure-link` readiness command. This CLI+UI remains a
client: use `hermes-relay pair --pair-qr '<signed invite>'` to import its authority,
certificate, and pin. A bare address or pairing code cannot establish new Secure
Link trust. The tray and CLI use the same saved host trust; neither enables the
server, rotates its certificate, or treats transport reachability as Dashboard
sign-in. Generate a fresh invite and explicitly re-pair after identity changes.
> **What this is not:** A local Hermes install. Point it at an existing Hermes-Relay server (`ws://host:8767`). For the full TUI with Ink, see the sibling package [`ui-tui`](../../hermes-agent-tui-smoke/ui-tui) in the hermes-agent fork.
## Desktop surfaces
+12
View File
@@ -180,6 +180,18 @@ redacted.
## Current-upstream conformance
The `secure_link_gateway_auth` scenario exercises query and subprotocol ticket
admission through the real Secure Link proxy, single-use rejection, a completed
turn, and live-session activation after reconnect. Run its wire regression with
`python -m unittest plugin.tests.test_secure_proxy_contract`; it also checks
compression boundaries, health coalescing, and bounded Dashboard rewrites.
Pass that scenario's JSON manifest to the conformance checker below to verify
the upstream ticket/public-protocol and live-activation seams. Android's focused
`PluginProxyTransportTest`, `GatewayChatClientTest`, and `RelayVoiceClientRoutingTest`
cover pin rejection, route replacement during an active turn or ticket mint, and
the voice HTTP/WebSocket client selection. These are protocol tests, not physical
device or OEM TLS certification.
Standard Voice receives successful unsolicited assistant answers from live Chat
admission, with a receipt captured before the new assistant placeholder exists.
The receipt belongs to the active voice generation and conversation binding;
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+13
View File
@@ -6,6 +6,19 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Secure Link managed activation
The first guided setup uses shared read-only checks, copyable startup settings,
explicit operator restart, and a re-checked pairing handoff. Add automatic
activation only for explicitly supported service-manager adapters that prove
ownership, preview the exact change and interruption, preserve the prior
configuration, verify the new listener, and roll back a failed activation.
Unknown/embedded managers must retain the guided-command path. Never infer a
service name, change an upstream bind, open firewall ports, rotate keys, or
restart Gateway merely because a user opens setup.
---
## Restore the plugin manifest v2 declaration after the Hermes installer fix ships
Hermes installers in affected stable releases reject `manifest_version: 2`
+1
View File
@@ -370,6 +370,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|-------|--------|---------|
| `/ws`, `/` | GET (upgrade) | Main WebSocket endpoint. Phone connects, sends `system/auth`, then multiplexes `chat`/`terminal`/`bridge` envelopes. |
| `/health` | GET | Returns `{status, version, clients, sessions}` JSON. |
| `/secure-link/preflight` | GET | **Loopback only.** Read-only Secure Link checks for a proposed `host` and `port`, with startup instructions and restart impact. The Dashboard/Desktop setup UI and `hermes relay secure-link` share this report. No configuration or service mutations. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the SessionManager's bounded defaults are used. Client-supplied policy in the WebSocket auth envelope is never authoritative. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
+8
View File
@@ -152,6 +152,14 @@ public `:8767`.
### Hermes Secure Link
Start with the shared read-only setup check: **Relay → Remote Access → Secure
Link** in Dashboard or the Desktop Relay pane, or
`hermes relay secure-link --host <phone-reachable-address> --port 9443` on the
host. It verifies prerequisites, previews environment/startup settings and
restart impact, and requires a re-check of the active listener before offering
the pairing handoff. It does not guess a service manager, write configuration,
open ports, or rotate keys. See the [guided user flow](../user-docs/guide/remote-access.md#optional-hermes-secure-link).
Enable Secure Link when a pairing-pinned unified route is desired. Its
default listener is `https://<host>:9443`; Relay health is
`GET /relay/health`, the authenticated Relay WebSocket is
+42
View File
@@ -15,6 +15,29 @@ must make the listener reachable.
Secure Link is not an arbitrary reverse proxy and does not replace any
service's authentication or authorization.
## Guided setup control surface
`GET /secure-link/preflight` is a loopback-only, read-only Relay operator route.
The authenticated Dashboard/Desktop plugin forwards it through
`GET /remote-access/secure-link/preflight`; `hermes relay secure-link` consumes
the same report from the running Relay. Public `/relay/*` ingress never exposes
this operator endpoint. Client input selects only the proposed listener address
and port; it cannot select a probe upstream or certificate/key file.
The report checks a usable bind address, port availability, existing certificate
identity, fixed loopback upstreams, Dashboard authentication, and restart impact.
It does not mutate configuration, generate/rotate secrets, or manage services.
Instructions preserve the existing service owner and require an explicit operator
restart. Re-checking an active matching origin enables the pairing handoff; a
proposed address alone never enables it. Report readiness is not client sign-in,
network reachability from another device, or Gateway Chat readiness.
Pairing previews derive declared namespaces from validated proxy advertisements
without displaying their certificate/pin or treating ordinary system-TLS probe
failure as proof that the paired route is broken. Signed QR import remains the
client trust ceremony. Unsupported/older Relay setup endpoints fail visibly and
never fall back to browser-side configuration writes.
## Trust boundaries
- The operator-reviewed pairing QR is the first-pair trust ceremony. It must
@@ -58,6 +81,10 @@ service's authentication or authorization.
Relay still requires its normal first-frame pairing/session authentication,
enforces expiry and grants, rate-limits failures, and binds the resulting
connection to that authenticated session.
- Relay-native `/voice/*` HTTP routes and management/session HTTP routes are
not exposed beneath `/relay`. A pinned client alone does not enable them.
Standard voice uses the independently authenticated Dashboard namespace;
native Relay voice requires a separately supported ingress.
- Client-controlled hosts, origins, absolute URLs, proxy headers, redirects,
encoded separators, and path traversal can never select an upstream.
- The external `Host` header is validated against the configured Secure Link
@@ -67,6 +94,17 @@ service's authentication or authorization.
- HTTP request and response bodies are bounded, and upstream connect/read/total
timeouts are finite. Long-lived traffic uses the separately bounded WebSocket
path rather than an unlimited HTTP proxy request.
- Dashboard login HTML and JSON landing paths are rewritten under `/dashboard`.
Rewrites request identity encoding and enforce the response limit while reading,
including chunked responses. An upstream that ignores the identity request and
sends compressed HTML/JSON receives a 502; compressed bytes are never returned
with their encoding header removed.
- Gateway query tickets and ticket subprotocols survive the WebSocket proxy.
Upstream authenticates before the outer upgrade succeeds. Only the selected
public protocol is returned; ticket-bearing protocols are never reflected.
The upstream leg disables compression independently of the downstream leg.
- Public Relay health reads version and counters from the same server instance;
it does not make a second loopback Relay health request.
- Secure Link failing to initialize must not silently advertise a
candidate. It must not make the ordinary Relay unavailable unless the
operator explicitly configured strict startup behavior.
@@ -81,6 +119,10 @@ service's authentication or authorization.
remains enabled; clients never disable certificate validation to learn a pin.
- The pin and each service credential are scoped to the exact host and port.
Redirects or retries outside that authority fail before credentials are sent.
- Android enforces the paired leaf SPKI inside its trust manager even when system
trust succeeds. HTTP and WebSocket requests use the same HTTPS authority guard;
automatic redirects are disabled. Gateway route changes replace the ticket and
socket transport together, discarding a ticket minted for a superseded route.
- A declared Secure Link route fails closed if its pinned client cannot be
built; it must not fall back to a generic TLS or TOFU client.
- UI security labels derive from the validated proxy contract, not from a
+4
View File
@@ -691,6 +691,9 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
**Global unattended-access affordance (v0.4.1).** When master + unattended are both on (sideload only), `UnattendedGlobalBanner` renders as a 28dp amber strip at the top of `RelayApp`'s scaffold on every tab — pulsing dot + "Unattended access ON — agent can wake and drive this device" + chevron → tap navigates to Bridge. Theme-aware colours (amber-on-dark in dark mode, dark-amber-on-pale-amber in light). The banner handles visibility while the user is INSIDE Hermes-Relay; the existing WindowManager `BridgeStatusOverlayChip` handles visibility when the app is BACKGROUNDED. See `docs/decisions.md` §18 for the split rationale.
### Settings Tab
At startup, the first app frame uses one saved Appearance snapshot for its Compose palette, while AppCompat's activity night mode follows that same snapshot. The splash stays up until the snapshot is loaded; Auto follows the system, and fixed or custom presets keep their own light/dark mode.
- **Active agent card (v0.6.0)** — top-of-screen summary card showing the current Connection / Profile / Personality. Tap navigates to Chat and auto-opens the agent sheet via the `openAgentSheet` nav arg, giving Settings-originating users a one-tap path to change agent context without leaving the flow.
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, voice, Pair-readiness, credential-store recovery, history-failure, and rejected-Send evidence without secrets. See `docs/decisions.md` §19.
- **Connection (single-server settings)** — summary-first detail for one Hermes installation. Dashboard/Gateway health drives standard Chat, Manage, Sessions, and Voice readiness. Direct API compatibility and Relay extensions appear as independently optional capabilities. Dashboard/Gateway address and network paths are edited under Routes. Advanced retains only the optional direct API credential, explicit direct Relay endpoint override, and insecure-development controls; missing API or Relay settings never make a healthy Dashboard/Gateway connection look broken. Every Relay QR, enter-code, and show-code method uses the shared connection-scoped Pair flow. Transport security posture and paired-device grants remain visible without leading the normal setup flow with ports or bearer keys.
@@ -738,6 +741,7 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
|-------|--------|---------|
| `/ws`, `/` | GET (upgrade) | WebSocket handler — main multiplexed channel |
| `/health` | GET | Health check — returns `{status, version, clients, sessions}` |
| `/secure-link/preflight` | GET | **Loopback only.** Read-only Secure Link setup checks and startup instructions for a proposed `host` and `port`. No configuration, key, or service mutations. Dashboard/Desktop and the host CLI share this report. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard and CLI/tray pair/repair flows. Optional request field `dashboard_url` is copied into the QR payload for custom dashboard routes. |
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}`. Loopback callers receive the parsed `config.yaml` and absolute path. Remote callers require a relay session bearer and receive only the explicitly public `description` and `model.default` fields with `path: "config.yaml"`; arbitrary provider, platform, integration, and extension sections never cross the remote boundary. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
+1 -1
View File
@@ -1,7 +1,7 @@
[versions]
appVersionName = "1.17.0"
appVersionCode = "57"
agp = "9.4.0"
agp = "9.4.1"
kotlin = "2.4.20"
compose-bom = "2026.09.00"
navigation-compose = "2.10.1"
+59
View File
@@ -24,6 +24,8 @@ import os
import sys
import urllib.error
import urllib.request
import urllib.parse
from argparse import Namespace
# ── hermes pair ───────────────────────────────────────────────────────────────
@@ -154,12 +156,24 @@ def register_relay_cli(subparser) -> None:
"""
sub = subparser.add_subparsers(dest="relay_cmd", required=True)
setup = sub.add_parser("secure-link", help="Check Secure Link readiness and show setup instructions (read-only)")
setup.add_argument("--host", help="LAN, VPN, or DNS address the phone will use")
setup.add_argument("--port", type=int, help="Secure Link HTTPS port (default: current configuration)")
setup.add_argument("--relay-port", type=int, default=None, help="Running Relay loopback port")
setup.add_argument("--json", action="store_true", help="Emit the same readiness report used by Dashboard and Desktop")
setup.set_defaults(func=relay_secure_link_command)
start = sub.add_parser(
"start",
help="Run the Hermes-Relay WSS server (chat + terminal + bridge)",
)
start.add_argument("--host", metavar="HOST", help="Bind address (default: 0.0.0.0)")
start.add_argument("--port", type=int, help="Listen port (default: 8767)")
import argparse
start.add_argument("--secure-link", action=argparse.BooleanOptionalAction, default=None,
help="Enable or disable the optional pinned-TLS listener")
start.add_argument("--secure-link-host", help="Secure Link bind/advertised address")
start.add_argument("--secure-link-port", type=int, help="Secure Link HTTPS port (default: 9443)")
start.add_argument(
"--no-ssl",
action="store_true",
@@ -319,6 +333,45 @@ def relay_doctor_command(args) -> None:
raise SystemExit(code)
def relay_secure_link_command(args: Namespace) -> None:
"""Read the running host's report without enabling, rotating, or restarting."""
relay_port = args.relay_port or int(os.environ.get("RELAY_PORT", "8767"))
if not 1 <= relay_port <= 65535:
raise SystemExit("Relay port must be between 1 and 65535")
query = urllib.parse.urlencode({
key: value for key, value in {"host": args.host, "port": args.port}.items() if value is not None
})
try:
with urllib.request.urlopen(f"http://127.0.0.1:{relay_port}/secure-link/preflight?{query}", timeout=15) as response:
report = json.load(response)
except (urllib.error.URLError, OSError, ValueError) as exc:
raise SystemExit("Secure Link checks are unavailable. Start/update Relay and retry; no configuration was changed.") from exc
if not isinstance(report, dict) or report.get("schema_version") != 1 or not isinstance(report.get("checks"), list):
raise SystemExit("Relay returned an unsupported setup report. Update Relay and retry; no configuration was changed.")
if args.json:
sys.stdout.write(json.dumps(report, indent=2) + "\n")
else:
lines = [f"Secure Link: {report['state'].replace('_', ' ')}"]
if report.get("url"):
lines.append(f"HTTPS origin: {report['url']}")
lines.extend(f"[{c['status']}] {c['label']}: {c['detail']}" for c in report["checks"])
lines.extend([report["restart_notice"], report.get("configuration_note", "")])
if report["ready_to_enable"] and not report["pairing_ready"]:
lines.append("Add these settings to the existing Relay environment, then restart its owner:")
lines.extend(f"{key}={value}" for key, value in report["environment"].items())
lines.extend([
"Or add to the existing Relay startup command: " + " ".join(report["start_arguments"]),
"Re-run this check after restart. Do not launch a second Relay.",
])
if report["pairing_ready"]:
lines.extend(["Create a fresh signed QR: hermes pair --png",
"Scan it, then sign into Dashboard on the client. Listener health is not Chat readiness."])
lines.append("To disable: set RELAY_SECURE_LINK_ENABLED=0 (or use --no-secure-link), then restart Relay's owner.")
sys.stdout.write("\n".join(lines) + "\n")
if not report["ready_to_enable"]:
raise SystemExit(1)
def relay_compat_command(args) -> None:
"""Manage the optional legacy compatibility startup hook."""
from .compat import compat_command
@@ -436,6 +489,12 @@ def relay_start_command(args) -> None:
config.port = args.port
if getattr(args, "webapi_url", None):
config.webapi_url = args.webapi_url
if getattr(args, "secure_link", None) is not None:
config.secure_proxy_enabled = args.secure_link
if getattr(args, "secure_link_host", None):
config.secure_proxy_host = args.secure_link_host
if getattr(args, "secure_link_port", None) is not None:
config.secure_proxy_port = args.secure_link_port
if getattr(args, "log_level", None):
config.log_level = args.log_level
if getattr(args, "shell", None):
+7
View File
@@ -12,6 +12,13 @@ independent of the Hermes-Relay service. It renders a tokenless setup QR contain
standard Dashboard/Gateway connection. Hermes-Relay pairing remains a separate,
explicit **Pair new device** flow.
**Remote Access → Hermes Secure Link → Set up Secure Link** runs the host's
read-only preflight, displays blockers and restart impact, and supplies settings
for the existing Relay owner. **Check again** must confirm the active selected
origin before **Create pairing QR** is offered. This flow does not persist settings
or restart services. The Desktop Relay pane and `hermes relay secure-link` use the
same backend report; Android imports the resulting QR and signs in separately.
## Requirements
- Node.js 18+
+7 -6
View File
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -600,6 +600,7 @@ async def _proxy_get(
path: str,
*,
params: Optional[dict[str, Any]] = None,
timeout: float = _TIMEOUT,
) -> Any:
"""Forward a GET to the relay, translating errors per this module's contract.
@@ -609,7 +610,7 @@ async def _proxy_get(
"""
url = f"{_RELAY_BASE}{path}"
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url, params=params)
except (httpx.TimeoutException, httpx.ConnectError, httpx.TransportError) as err:
raise _relay_unreachable(err) from err
@@ -1090,6 +1091,7 @@ async def get_remote_access_status() -> dict[str, Any]:
secure_link: dict[str, Any] = {
"enabled": False,
"state": "disabled",
"reason": "Hermes Secure Link is not enabled on the Relay host",
}
try:
@@ -1105,6 +1107,7 @@ async def get_remote_access_status() -> dict[str, Any]:
reach = relay_secure_link.get("reach", {}) if isinstance(relay_secure_link, dict) else {}
secure_link = {
"enabled": True,
"state": "enabled",
"role": candidate.get("role"),
"recommended": candidate.get("recommended") is True,
"security": candidate.get("security"),
@@ -1116,9 +1119,16 @@ async def get_remote_access_status() -> dict[str, Any]:
"last_error": reach.get("last_error") if isinstance(reach.get("last_error"), str) else None,
} if isinstance(reach, dict) else {"enabled": False, "state": "disabled"},
}
elif isinstance(relay_health, dict) and isinstance(relay_health.get("secure_link"), dict) and relay_health["secure_link"].get("enabled") is True:
secure_link = {
"enabled": False,
"state": "unavailable",
"reason": "Secure Link is configured but its listener is unavailable. Run setup checks before retrying.",
}
except HTTPException as exc:
secure_link = {
"enabled": False,
"state": "unknown",
"reason": f"Relay status unavailable: {exc.detail}",
}
@@ -1134,6 +1144,14 @@ async def get_remote_access_status() -> dict[str, Any]:
}
@router.get("/remote-access/secure-link/preflight")
async def get_secure_link_preflight(host: str | None = None, port: str | None = None) -> Any:
"""Use the running Relay's read-only checks, not the Dashboard's environment."""
return await _proxy_get("/secure-link/preflight", params={
key: value for key, value in {"host": host, "port": port}.items() if value is not None
}, timeout=15.0)
@router.post("/remote-access/tailscale/enable")
async def tailscale_enable(
body: dict[str, Any] = Body(default_factory=dict),
@@ -0,0 +1,136 @@
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { useState, useRef, useEffect } = SDK.hooks;
const { Input, Label } = SDK.components;
import { getSecureLinkPreflight } from "../lib/api.js";
import { Button, Badge, Alert, AlertTitle, AlertDescription } from "../lib/ui-shims.jsx";
/** Readiness and instructions only: the browser never guesses a service owner. */
export default function SecureLinkSetup({ status, onPair, onInvalidateInvite, pairingBusy = false }) {
const initial = (() => { try { return new URL(status.url); } catch { return null; } })();
const [open, setOpen] = useState(false);
const [host, setHost] = useState(initial?.hostname || "");
const [port, setPort] = useState(initial?.port || "9443");
const [report, setReport] = useState(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const sequence = useRef(0);
useEffect(() => {
sequence.current += 1;
setReport(null);
setBusy(false);
onInvalidateInvite?.();
}, [status.url]);
const change = (setter, value) => {
sequence.current += 1;
setter(value);
setReport(null);
setError("");
setCopied(false);
setBusy(false);
onInvalidateInvite?.();
};
const check = async () => {
const request = ++sequence.current;
setOpen(true);
setBusy(true);
setError("");
setCopied(false);
onInvalidateInvite?.();
try {
const next = await getSecureLinkPreflight({ host: host.trim() || undefined, port });
if (request !== sequence.current) return;
if (next?.schema_version !== 1 || !Array.isArray(next.checks)) throw new Error("Relay returned an unsupported setup report.");
setReport(next);
if (!host.trim() && next.host) setHost(next.host);
} catch (err) {
if (request !== sequence.current) return;
setReport(null);
setError(`Setup checks unavailable. Confirm Relay is running and supports Secure Link setup checks. ${err.message || ""}`);
} finally {
if (request === sequence.current) setBusy(false);
}
};
const environment = Object.entries(report?.environment || {}).map(([key, value]) => `${key}=${value}`).join("\n");
const copy = async () => {
try {
await navigator.clipboard.writeText(environment);
setCopied(true);
} catch { setError("Clipboard access is unavailable. Select and copy the settings below."); }
};
return (
<div className="space-y-3">
{!open ? <Button variant="outline" onClick={check}>{status.enabled ? "Check Secure Link" : "Set up Secure Link"}</Button> : (
<div className="space-y-4 border-t border-border pt-3">
<div className="space-y-2">
<h4 className="text-sm font-semibold">1. Check this server</h4>
<p className="text-xs text-muted-foreground">Checks are read-only. They do not change settings, create keys, or restart services.</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="secure-link-host">Address the phone will use</Label>
<Input id="secure-link-host" value={host} placeholder="192.168.1.20 or relay.example"
onChange={(event) => change(setHost, event.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="secure-link-port">HTTPS port</Label>
<Input id="secure-link-port" value={port} inputMode="numeric"
onChange={(event) => change(setPort, event.target.value)} />
</div>
</div>
<Button size="sm" variant="outline" disabled={busy} onClick={check}>{busy ? "Checking…" : "Check again"}</Button>
</div>
{error ? <Alert variant="destructive"><AlertTitle>Check needed</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
{report ? (
<>
<div role="list" className="space-y-2" aria-live="polite">
{report.checks.map((item) => (
<div role="listitem" key={item.id} className="rounded-md border border-border p-3 space-y-1">
<div className="flex items-center justify-between gap-2 text-sm">
<span>{item.label}</span><Badge variant={item.status === "blocked" ? "destructive" : "outline"}>
{item.status === "ok" ? "Checked" : item.status === "warning" ? "Optional / unavailable" : "Needs attention"}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
))}
</div>
{report.ready_to_enable && !report.pairing_ready ? (
<div className="space-y-2">
<h4 className="text-sm font-semibold">2. Review and enable</h4>
<p className="font-mono text-xs break-all">{report.url}</p>
{report.requires_repair ? <p className="text-xs text-muted-foreground">This changes the paired address or port. Existing clients must re-pair after activation; no certificate is replaced by this check.</p> : null}
<p className="text-xs text-muted-foreground">Add these settings to the existing Relay environment. Restart its owner, then choose Check again. Do not start a second Relay.</p>
<p className="text-xs text-muted-foreground">{report.connected_clients} Relay client(s) connected. {report.restart_notice}</p>
<p className="text-xs text-muted-foreground">{report.configuration_note}</p>
<pre className="rounded-md bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all select-text">{environment}</pre>
<Button size="sm" variant="outline" onClick={copy}>{copied ? "Copied" : "Copy settings"}</Button>
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">Foreground / CLI startup</summary>
<p className="mt-2">Add to your existing <code>hermes relay start</code> command, preserving its other arguments:</p>
<pre className="mt-2 whitespace-pre-wrap break-all select-text">{report.start_arguments.join(" ")}</pre>
</details>
</div>
) : null}
{report.pairing_ready ? (
<div className="space-y-2">
<h4 className="text-sm font-semibold">3. Pair a device</h4>
<p className="font-mono text-xs break-all">{report.current_url}</p>
<p className="text-xs text-muted-foreground">Secure Link is listening. Create a fresh signed QR below, scan it in Android, then sign into Dashboard. Chat becomes ready only after Gateway connects. Existing devices must re-pair to import this certificate and pin.</p>
<Button disabled={pairingBusy || busy} onClick={() => onPair(report.current_url)}>{pairingBusy ? "Creating invite…" : "Create pairing QR"}</Button>
</div>
) : null}
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">Disable or recover</summary>
<p className="mt-2">Set <code>RELAY_SECURE_LINK_ENABLED=0</code> (or replace the startup flag with <code>--no-secure-link</code>), then restart Relay using its existing manager. Keep the certificate and key if you intend to re-enable the same route. Address or certificate changes require explicit re-pairing.</p>
<p className="mt-2">This flow does not restart services or open firewall ports. If activation fails, restore the previous Relay settings and check its health before retrying.</p>
</details>
</>
) : null}
</div>
)}
</div>
);
}
+19 -3
View File
@@ -84,6 +84,13 @@ export function getRemoteAccessStatus() {
return fetchJSON("/remote-access/status");
}
export function getSecureLinkPreflight({ host, port } = {}) {
const query = new URLSearchParams();
if (host !== undefined) query.set("host", host);
if (port !== undefined) query.set("port", port);
return fetchJSON(`/remote-access/secure-link/preflight?${query}`);
}
export function enableTailscale(port) {
return fetchJSON("/remote-access/tailscale/enable", {
method: "POST",
@@ -115,12 +122,21 @@ export function putPublicUrl(url, { legacyDirectRelay = false } = {}) {
});
}
export function probeEndpoints(candidates) {
return fetchJSON("/remote-access/probe", {
export async function probeEndpoints(candidates) {
const entries = Array.isArray(candidates) ? candidates : [];
// A generic server-side TLS probe does not own the recipient's paired
// trust. Do not misreport self-signed Secure Link as broken or disable TLS.
const paired = entries.filter((item) => item.requires_paired_client).map((item) => ({
...item, reachable: null, status: null, latency_ms: null, error: null,
}));
const ordinary = entries.filter((item) => !item.requires_paired_client);
if (!ordinary.length) return { results: paired };
const result = await fetchJSON("/remote-access/probe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidates: Array.isArray(candidates) ? candidates : [] }),
body: JSON.stringify({ candidates: ordinary }),
});
return { ...result, results: [...(result.results || []), ...paired] };
}
/**
+3 -1
View File
@@ -6,6 +6,8 @@ export function pairingQrRenderOptions() {
return {
scale: 4,
margin: 4,
errorCorrectionLevel: "M",
// Match the CLI: certificate-bearing Secure Link invites can exceed the
// largest medium-correction QR even though they fit at low correction.
errorCorrectionLevel: "L",
};
}
+22 -6
View File
@@ -41,21 +41,34 @@ function normalizeRoute(endpoint, index, globalPayload, endpointCount) {
? endpoint.priority
: index;
const fallbackGlobal = endpointCount <= 1;
const issues = [];
const proxy = endpoint?.proxy;
const proxyBase = proxy ? cleanUrl(proxy.url, new Set(["https:"])) : null;
const validPin = typeof proxy?.pin_sha256 === "string" && /^sha256\/[A-Za-z0-9+/]{43}=$/.test(proxy.pin_sha256);
const validProxy = proxyBase && /^https:\/\/[^/?#]+\/?$/i.test(proxy.url.trim()) && validPin &&
typeof proxy.cert_der === "string" && proxy.cert_der.length > 0;
if (proxy && !validProxy) issues.push(`${role}: Secure Link needs a safe HTTPS origin, paired pin, and certificate`);
const protectedServices = new Set(validProxy && Array.isArray(proxy.surfaces) ? proxy.surfaces : []);
const proxyDashboard = protectedServices.has("dashboard") ? `${proxyBase}/dashboard` : null;
const proxyRelay = protectedServices.has("relay") ? `${proxyBase.replace(/^https:/, "wss:")}/relay/ws` : null;
const proxyApi = protectedServices.has("api") ? `${proxyBase}/api` : null;
const dashboardRaw = endpoint && endpoint.dashboard && endpoint.dashboard.url
? endpoint.dashboard.url
: fallbackGlobal ? globalPayload.dashboard_url : null;
: proxyDashboard || (fallbackGlobal ? globalPayload.dashboard_url : null);
const relayRaw = endpoint && endpoint.relay && endpoint.relay.url
? endpoint.relay.url
: fallbackGlobal && globalPayload.relay ? globalPayload.relay.url : null;
: proxyRelay || (fallbackGlobal && globalPayload.relay ? globalPayload.relay.url : null);
const dashboard = cleanUrl(dashboardRaw, HTTP_SCHEMES);
const relay = cleanUrl(relayRaw, RELAY_SCHEMES);
const api = apiUrl(endpoint && endpoint.api ? endpoint.api : fallbackGlobal ? globalPayload : null);
const api = endpoint?.api ? apiUrl(endpoint.api) : proxyApi || apiUrl(fallbackGlobal ? globalPayload : null);
const surfaces = [
dashboard ? { surface: "dashboard", label: "Dashboard", url: dashboard } : null,
relay ? { surface: "relay", label: "Relay", url: relay } : null,
api ? { surface: "api", label: "Direct API", url: api } : null,
].filter(Boolean);
const issues = [];
].filter(Boolean).map((surface) => ({
...surface,
...(validProxy && [proxyDashboard, proxyRelay, proxyApi].includes(surface.url) ? { requiresPairedClient: true } : {}),
}));
if (dashboardRaw && !dashboard) issues.push(`${role}: invalid Dashboard URL`);
if (relayRaw && !relay) issues.push(`${role}: invalid Relay URL`);
@@ -85,7 +98,8 @@ function normalizeRoute(endpoint, index, globalPayload, endpointCount) {
role,
priority,
surfaces,
protection: routeProtection(role, surfaces),
protection: surfaces.length && surfaces.every((s) => s.requiresPairedClient)
? "Secure Link · paired TLS" : routeProtection(role, surfaces),
issues,
};
}
@@ -129,6 +143,7 @@ export function pairingSurfaceProbes(receipt) {
priority: route.priority,
surface: surface.surface,
url: surface.url,
...(surface.requiresPairedClient ? { requires_paired_client: true } : {}),
})),
);
}
@@ -140,6 +155,7 @@ export function pairingProbeKey(entry) {
/** Convert a surface probe into honest, product-facing reachability. */
export function pairingProbeStatus(result) {
if (!result) return { healthy: null, label: "Not checked" };
if (result.requires_paired_client) return { healthy: null, label: "Import QR to verify" };
if (
result.surface === "relay" &&
(result.status === 401 || result.status === 403) &&
+31 -8
View File
@@ -15,6 +15,7 @@ import {
import { relativeTime } from "../lib/formatters.js";
import { canonicalDashboardOrigin } from "../lib/mobile-setup.mjs";
import { pairingQrRenderOptions } from "../lib/pairing-qr.mjs";
import SecureLinkSetup from "../components/SecureLinkSetup.jsx";
import {
classifyPublicRouteInput,
dashboardServeState,
@@ -262,7 +263,7 @@ function TailscaleCard({ status, onEnable, onDisable, busy, resultMessage }) {
);
}
function SecureLinkCard({ status }) {
function SecureLinkCard({ status, onPair, onInvalidateInvite, pairingBusy }) {
const enabled = !!(status && status.enabled);
const url = status && status.url;
const surfaces = Array.isArray(status && status.surfaces) ? status.surfaces : [];
@@ -284,7 +285,7 @@ function SecureLinkCard({ status }) {
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Dot tone={enabled ? "ok" : "muted"} />
<span>{enabled ? "Enabled" : "Not enabled"}</span>
<span>{enabled ? "Enabled" : status?.state === "unavailable" ? "Needs attention" : status?.state === "unknown" ? "Not checked" : "Not enabled"}</span>
{enabled ? <Badge variant="outline">Pinned TLS</Badge> : null}
</div>
{url ? (
@@ -311,6 +312,7 @@ function SecureLinkCard({ status }) {
When enabled, new pairing invites include Secure Link alongside other
reachable candidates. Existing devices must re-pair to trust its certificate pin.
</p>
<SecureLinkSetup status={status || {}} onPair={onPair} onInvalidateInvite={onInvalidateInvite} pairingBusy={pairingBusy} />
</CardContent>
</Card>
);
@@ -663,6 +665,19 @@ export default function RemoteAccess({ autoRefresh }) {
const [reachability, setReachability] = useState([]);
const [publicUrl, setPublicUrl] = useState(null);
const [preferRole, setPreferRole] = useState(null);
const mintSequence = useRef(0);
const inviteRef = useRef(null);
useEffect(() => {
if (mintResult?.qr_payload) {
inviteRef.current?.scrollIntoView({ block: "start" });
inviteRef.current?.focus({ preventScroll: true });
}
}, [mintResult]);
const invalidateInvite = useCallback(() => {
mintSequence.current += 1;
setMintResult(null);
setBusy((current) => current === "mint" ? null : current);
}, []);
const load = useCallback(async () => {
setError(null);
@@ -724,26 +739,32 @@ export default function RemoteAccess({ autoRefresh }) {
// surface the preview by inspecting the endpoints echoed in the
// response shape (``qr_payload`` is the string to scan; we parse it
// to render the preview table).
const onRegenerate = useCallback(async () => {
const onRegenerate = useCallback(async (secureLinkUrl = null) => {
const generation = ++mintSequence.current;
setBusy("mint");
setMintResult(null);
try {
const dashboardUrl = canonicalDashboardOrigin(window.location);
const secureLink = typeof secureLinkUrl === "string";
const dashboardUrl = secureLink ? `${secureLinkUrl}/dashboard` : canonicalDashboardOrigin(window.location);
if (!dashboardUrl) {
throw new Error("This Dashboard does not have a valid HTTP(S) origin for pairing.");
}
const data = await mintPairingWithMode({
mode: "auto",
prefer: preferRole || undefined,
prefer: secureLink ? "plugin_proxy" : preferRole || undefined,
dashboard_url: dashboardUrl,
legacy_direct_relay:
classifyPublicRouteInput(publicUrl || "").kind === "legacy-relay-path",
});
if (generation !== mintSequence.current) return;
if (secureLink && !JSON.parse(data.qr_payload).endpoints?.some((endpoint) => endpoint.proxy?.url === secureLinkUrl)) {
throw new Error("Secure Link changed. Check it again before pairing.");
}
setMintResult(data || null);
} catch (err) {
setMintResult({ error: err && err.message ? err.message : String(err) });
if (generation === mintSequence.current) setMintResult({ error: err && err.message ? err.message : String(err) });
} finally {
setBusy(null);
if (generation === mintSequence.current) setBusy(null);
}
}, [preferRole, publicUrl]);
@@ -830,7 +851,7 @@ export default function RemoteAccess({ autoRefresh }) {
resultMessage={helperMessage}
/>
<SecureLinkCard status={secureLink} />
<SecureLinkCard status={secureLink} onPair={onRegenerate} onInvalidateInvite={invalidateInvite} pairingBusy={busy === "mint"} />
<ExperimentalReachCard status={secureLink} />
@@ -842,6 +863,7 @@ export default function RemoteAccess({ autoRefresh }) {
}}
/>
<div ref={inviteRef} role="region" aria-label="Pairing invite" tabIndex={-1}>
<EndpointPreviewCard
endpoints={previewEndpoints}
reachability={reachability}
@@ -854,6 +876,7 @@ export default function RemoteAccess({ autoRefresh }) {
onPreferChange={setPreferRole}
blockingIssues={previewReceipt && previewReceipt.blockingIssues ? previewReceipt.blockingIssues : []}
/>
</div>
{mintResult && mintResult.error ? (
<Alert variant="destructive">
+46 -1
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import QRCode from "qrcode";
import { pairingQrRenderOptions } from "../src/lib/pairing-qr.mjs";
@@ -15,11 +16,55 @@ test("pairing QR keeps integer modules and a four-module quiet zone", () => {
assert.deepEqual(pairingQrRenderOptions(), {
scale: 4,
margin: 4,
errorCorrectionLevel: "M",
errorCorrectionLevel: "L",
});
assert.equal(Object.hasOwn(pairingQrRenderOptions(), "width"), false);
});
test("certificate-bearing pairing invite fits the Dashboard QR renderer", () => {
const payload = JSON.stringify({
hermes: 2,
endpoints: [{ role: "plugin_proxy", proxy: {
url: "https://relay.example:9443",
cert_der: "a".repeat(2500),
pin_sha256: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
} }],
});
assert.throws(() => QRCode.create(payload, { errorCorrectionLevel: "M" }), /too big/i);
const qr = QRCode.create(payload, pairingQrRenderOptions());
assert.ok(qr.version <= 40);
});
test("Secure Link receipt derives only its advertised namespaces without exposing trust material", () => {
const payload = { endpoints: [{ role: "plugin_proxy", proxy: {
url: "https://relay.example:9443",
pin_sha256: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
cert_der: "public-certificate",
surfaces: ["relay", "dashboard"],
} }] };
const receipt = pairingEndpointReceipt(payload);
assert.deepEqual(receipt.blockingIssues, []);
assert.equal(receipt.routes[0].protection, "Secure Link · paired TLS");
assert.deepEqual(receipt.routes[0].surfaces.map(s => s.url), [
"https://relay.example:9443/dashboard", "wss://relay.example:9443/relay/ws",
]);
const probes = pairingSurfaceProbes(receipt);
assert.ok(probes.every(probe => probe.requires_paired_client));
assert.deepEqual(pairingProbeStatus(probes[0]), { healthy: null, label: "Import QR to verify" });
assert.equal(JSON.stringify(receipt).includes("public-certificate"), false);
assert.equal(JSON.stringify(receipt).includes("sha256/"), false);
payload.endpoints[0].proxy.pin_sha256 = "wrong";
assert.ok(pairingEndpointReceipt(payload).blockingIssues.length);
});
test("Secure Link receipt rejects path traversal rather than normalizing it into an origin", () => {
const receipt = pairingEndpointReceipt({ endpoints: [{ role: "plugin_proxy", proxy: {
url: "https://relay.example/a/..", pin_sha256: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
cert_der: "public-certificate", surfaces: ["relay"],
} }] });
assert.ok(receipt.blockingIssues.length);
});
test("only Dashboard ingress Relay auth challenges are healthy", () => {
assert.deepEqual(
pairingProbeStatus({
@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
const calls = [];
globalThis.window = { __HERMES_PLUGIN_SDK__: {
fetchJSON: async (path, opts) => {
calls.push({ path, opts });
return path.includes("preflight") ? { schema_version: 1 } : { results: [{ surface: "api", reachable: true }] };
},
} };
const { getSecureLinkPreflight, probeEndpoints } = await import("../src/lib/api.js");
test("setup input stays inside a read-only preflight query", async () => {
calls.length = 0;
assert.deepEqual(await getSecureLinkPreflight({ host: "relay.example&port=22", port: "9443" }), { schema_version: 1 });
assert.equal(calls[0].path, "/api/plugins/hermes-relay/remote-access/secure-link/preflight?host=relay.example%26port%3D22&port=9443");
assert.equal(calls[0].opts, undefined);
});
test("pinned routes are not sent to an unpaired system-TLS probe", async () => {
calls.length = 0;
const pinned = { surface: "dashboard", url: "https://relay.example:9443/dashboard", requires_paired_client: true };
const result = await probeEndpoints([pinned]);
assert.equal(calls.length, 0);
assert.equal(result.results[0].reachable, null);
assert.equal(result.results[0].requires_paired_client, true);
const mixed = await probeEndpoints([pinned, { surface: "api", url: "http://192.168.1.20:8642" }]);
assert.equal(JSON.parse(calls[0].opts.body).candidates.length, 1);
assert.equal(mixed.results.length, 2);
});
+20
View File
@@ -1071,6 +1071,26 @@ class RemoteAccessProbeTests(PluginApiTestCase):
class RemoteAccessStatusTests(PluginApiTestCase):
def test_configured_but_failed_secure_link_is_not_reported_as_disabled(self) -> None:
with patch.object(plugin_api, "_proxy_get", new=AsyncMock(return_value={
"secure_link": {"enabled": True, "status": "unavailable"},
})), patch.object(plugin_api, "_tailscale_status_dict", return_value={}), patch.object(plugin_api, "_canonical_upstream_present", return_value=False):
response = self.client.get("/remote-access/status")
self.assertEqual(response.json()["secure_link"]["state"], "unavailable")
self.assertFalse(response.json()["secure_link"]["enabled"])
def test_secure_link_preflight_uses_running_relay_and_preserves_report(self) -> None:
report = {"schema_version": 1, "read_only": True, "state": "needs_attention", "checks": []}
captured = _install_mock_transport(self, lambda request: httpx.Response(200, json=report))
response = self.client.get("/remote-access/secure-link/preflight", params={"host": "relay.example", "port": "9443"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), report)
self.assertEqual(captured[0].url.host, "127.0.0.1")
self.assertEqual(captured[0].url.path, "/secure-link/preflight")
self.assertEqual(captured[0].url.params["host"], "relay.example")
self.assertEqual(captured[0].url.params["port"], "9443")
self.assertEqual(captured[0].extensions["timeout"]["read"], 15.0)
def test_status_surfaces_tailscale_dict_and_public_pin(self) -> None:
# Monkey-patch the tailscale helper so the test doesn't shell out.
from plugin.relay import tailscale as ts_mod
+7
View File
@@ -14,6 +14,13 @@ All backend calls use the official profile-aware `ctx.rest()` namespace and the
existing `dashboard/plugin_api.py` routes. The pane keeps no server state, does
not poll, does not notify, and does not perform network work while closed.
**Remote access → Secure Link setup** uses the running Relay's shared read-only
preflight. It shows address, listener, certificate, upstream-authentication, and
restart checks; provides copyable settings; and creates a signed pairing invite
only after the selected listener is active. It does not manage services or rotate
keys. The Dashboard flow and host-side `hermes relay secure-link` expose the same
report. Use Dashboard or `hermes pair --png` when a phone needs a scannable QR.
## SDK baseline
Implemented against upstream Hermes Desktop source
+107 -2
View File
@@ -18,7 +18,7 @@ import {
useQueryClient,
useValue
} from '@hermes/plugin-sdk'
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
const PLUGIN_ID = 'hermes-relay'
@@ -383,7 +383,111 @@ function createMedia(ctx) {
}
}
export function secureLinkPreflightPath(address, port) {
const query = new URLSearchParams()
if (address.trim()) query.set('host', address.trim())
if (port) query.set('port', String(port))
return `/remote-access/secure-link/preflight?${query}`
}
export function secureLinkPairingBody(report) {
if (!report?.pairing_ready || !report.current_url) throw new Error('Check Secure Link before creating an invite.')
return { mode: 'auto', prefer: 'plugin_proxy', dashboard_url: `${report.current_url}/dashboard` }
}
export function createSecureLinkSetup(ctx) {
return function SecureLinkSetup({ status }) {
const current = (() => { try { return new URL(status?.url) } catch { return null } })()
const [address, setAddress] = useState(current?.hostname || '')
const [port, setPort] = useState(current?.port || '9443')
const [report, setReport] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [invite, setInvite] = useState('')
const [copied, setCopied] = useState(false)
const sequence = useRef(0)
useEffect(() => {
sequence.current += 1
setReport(null); setInvite(''); setError(''); setBusy(false); setCopied(false)
}, [status?.url])
const edit = (setter, value) => {
sequence.current += 1
setter(value); setReport(null); setInvite(''); setError(''); setBusy(false); setCopied(false)
}
const check = async () => {
const request = ++sequence.current
setBusy('check'); setError(''); setInvite(''); setCopied(false)
try {
const value = await ctx.rest(secureLinkPreflightPath(address, port))
if (request !== sequence.current) return
if (value?.schema_version !== 1 || !Array.isArray(value.checks)) throw new Error('Update Relay to use Secure Link setup checks.')
setReport(value)
if (!address && value.host) setAddress(value.host)
} catch (e) { if (request === sequence.current) { setReport(null); setError(friendlyError(e)) } }
finally { if (request === sequence.current) setBusy(false) }
}
const pair = async () => {
const request = ++sequence.current
setBusy('pair'); setError(''); setInvite(''); setCopied(false)
try {
const value = await ctx.rest('/pairing', { method: 'POST', body: secureLinkPairingBody(report) })
if (request !== sequence.current) return
const payload = JSON.parse(value.qr_payload)
if (!payload.endpoints?.some(endpoint => endpoint.proxy?.url === report.current_url)) {
throw new Error('Secure Link changed. Check it again before pairing.')
}
setInvite(value.pairing_url || value.qr_payload)
} catch (e) { if (request === sequence.current) setError(friendlyError(e)) }
finally { if (request === sequence.current) setBusy(false) }
}
const environment = Object.entries(report?.environment || {}).map(([key, value]) => `${key}=${value}`).join('\n')
const copy = async value => {
if (await ctx.os.writeClipboard(value)) setCopied(true)
else setError('Clipboard unavailable. Select and copy the text below.')
}
return jsxs('div', {
className: 'mt-4 space-y-3 rounded-md border border-(--ui-stroke-tertiary) p-3',
children: [
jsx('h3', { className: 'text-sm font-semibold', children: 'Secure Link setup' }),
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: 'Read-only checks and startup instructions. No service is restarted and no certificate is created here.' }),
jsx('label', { htmlFor: 'relay-secure-host', className: 'text-xs', children: 'Address the phone will use' }),
jsx(Input, { id: 'relay-secure-host', value: address, placeholder: '192.168.1.20 or relay.example', onChange: event => edit(setAddress, event.target.value) }),
jsx('label', { htmlFor: 'relay-secure-port', className: 'text-xs', children: 'HTTPS port' }),
jsx(Input, { id: 'relay-secure-port', value: port, inputMode: 'numeric', onChange: event => edit(setPort, event.target.value) }),
jsx(Button, { size: 'sm', variant: 'outline', disabled: !!busy, onClick: check, children: busy === 'check' ? 'Checking…' : report || error ? 'Check again' : 'Check this server' }),
error ? jsx(ErrorState, { title: 'Secure Link check needed', description: error }) : null,
...(report?.checks || []).map(item => jsx(Field, { label: `${item.label} · ${item.status}`, value: item.detail }, item.id)),
report ? jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: `${report.connected_clients} Relay client(s) connected. ${report.restart_notice}` }) : null,
report?.ready_to_enable && !report.pairing_ready ? jsxs('div', { className: 'space-y-2', children: [
jsx(Field, { label: 'Proposed HTTPS origin', value: report.url }),
report.requires_repair ? jsx('p', { className: 'text-xs', children: 'The paired address or port changes. Existing clients must re-pair after activation; this check does not replace certificates.' }) : null,
jsx('p', { className: 'text-xs', children: 'Add these settings to the existing Relay environment, restart its owner, then check again. Do not start a second Relay.' }),
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: report.configuration_note }),
jsx('pre', { className: 'whitespace-pre-wrap break-all select-text text-xs', children: environment }),
jsx(Button, { size: 'sm', variant: 'outline', onClick: () => copy(environment), children: copied ? 'Copied' : 'Copy settings' })
] }) : null,
report?.pairing_ready ? jsxs('div', { className: 'space-y-2', children: [
jsx(Field, { label: 'Listening', value: report.current_url }),
jsx('p', { className: 'text-xs', children: 'Create a fresh signed invite, then sign into Dashboard on the client. A healthy listener does not mean Chat is ready.' }),
jsx(Button, { size: 'sm', disabled: !!busy, onClick: pair, children: busy === 'pair' ? 'Creating invite…' : 'Create pairing invite' }),
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: 'For a scannable phone QR, use the Dashboard setup flow or hermes pair --png on the host.' })
] }) : null,
invite ? jsxs('div', { className: 'space-y-2', children: [
jsx('p', { className: 'text-xs', children: 'One-time invite. Keep it private and pair before it expires.' }),
jsx('textarea', { readOnly: true, rows: 3, value: invite, 'aria-label': 'Secure Link pairing invite', className: 'w-full rounded border border-(--ui-stroke-tertiary) bg-transparent p-2 font-mono text-xs' }),
jsx(Button, { size: 'sm', onClick: () => copy(invite), children: copied ? 'Copied' : 'Copy invite' })
] }) : null,
jsxs('details', { className: 'text-xs text-(--ui-text-tertiary)', children: [
jsx('summary', { children: 'Disable or recover' }),
jsx('p', { children: 'Set RELAY_SECURE_LINK_ENABLED=0 (or use --no-secure-link), then restart the existing Relay owner. Keep its certificate/key to reuse the same route. Certificate or address changes require re-pairing.' })
] })
]
})
}
}
function createRemoteAccess(ctx) {
const SecureLinkSetup = createSecureLinkSetup(ctx)
return function RemoteAccess() {
const t = usePluginI18n(PLUGIN_ID)
const profile = useValue(host.state.profile)
@@ -422,7 +526,7 @@ function createRemoteAccess(ctx) {
children: [
jsx(SectionHeader, {
title: t('remote.title'),
description: 'All changes require a labeled action and a second confirmation.'
description: 'Service changes require confirmation. Secure Link setup checks are read-only.'
}),
jsx(QueryState, {
query: status,
@@ -438,6 +542,7 @@ function createRemoteAccess(ctx) {
jsx(Field, { label: 'Upstream helper', value: data.upstream_canonical ? 'available' : 'not detected' })
]
}),
jsx(SecureLinkSetup, { status: data.secure_link }, profile || 'default'),
jsxs('div', {
className: 'mt-4 flex flex-wrap gap-2',
children: [
+1 -1
View File
@@ -19,7 +19,7 @@ export const useQueryClient = () => ({ invalidateQueries: () => {} })
export const useValue = atom => atom.get()
`
const react = `export const useState = initial => [initial, () => {}]`
const react = `export const useState = initial => [initial, () => {}]; export const useRef = initial => ({ current: initial }); export const useEffect = () => {}`
const jsx = `
export const Fragment = Symbol.for('fixture.fragment')
export const jsx = (type, props) => ({ type, props: props || {} })
+10 -1
View File
@@ -4,10 +4,19 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import plugin, { pairingMintBody, profileQueryKey } from '../plugin.js'
import plugin, { pairingMintBody, profileQueryKey, secureLinkPreflightPath, secureLinkPairingBody } from '../plugin.js'
const here = dirname(fileURLToPath(import.meta.url))
test('Secure Link checks encode inputs and pairing requires verified listener state', () => {
assert.equal(secureLinkPreflightPath('relay.example', 9443), '/remote-access/secure-link/preflight?host=relay.example&port=9443')
assert.equal(secureLinkPreflightPath('relay.example&port=22', 9443), '/remote-access/secure-link/preflight?host=relay.example%26port%3D22&port=9443')
assert.throws(() => secureLinkPairingBody({ pairing_ready: false }), /Check Secure Link/)
assert.deepEqual(secureLinkPairingBody({ pairing_ready: true, current_url: 'https://relay.example:9443' }), {
mode: 'auto', prefer: 'plugin_proxy', dashboard_url: 'https://relay.example:9443/dashboard'
})
})
function harness() {
const contributions = []
const disposed = []
+213
View File
@@ -0,0 +1,213 @@
"""Read-only Secure Link setup checks shared by the host CLI and control UIs."""
from __future__ import annotations
import asyncio
import ipaddress
import os
import re
import shutil
import socket
import ssl
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from .secure_proxy import _api_available, _dashboard_gate_enabled, _loopback_http_base
def setup_address(host: str, port: str | int) -> tuple[str, int]:
"""Accept an address, never a URL, command fragment, or unspecified bind."""
host = host.strip()
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
if not host or len(host) > 253:
raise ValueError("Enter the LAN, VPN, or DNS address the phone will use.")
if "%" in host:
raise ValueError("Use an unscoped server address; interface-scoped IPv6 cannot be paired across devices.")
try:
address = ipaddress.ip_address(host)
except ValueError:
if all(label.isdigit() for label in host.split(".")):
raise ValueError("Use a complete IPv4 address or a DNS hostname.") from None
if not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in host.split(".")):
raise ValueError("Enter a hostname or IP address without a scheme, path, or credentials.") from None
host = host.lower()
else:
if address.is_unspecified or address.is_multicast:
raise ValueError("Use a reachable server address, not a wildcard or multicast address.")
host = str(address)
if isinstance(port, bool) or not str(port).isdigit() or not 1 <= int(port) <= 65535:
raise ValueError("Port must be between 1 and 65535.")
return host, int(port)
async def _can_bind(host: str, port: int) -> bool:
try:
addresses = await asyncio.wait_for(
asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM), 2,
)
for family, kind, protocol, _, address in addresses:
try:
if ipaddress.ip_address(address[0]).is_loopback:
continue
with socket.socket(family, kind, protocol) as listener:
listener.bind(address)
return True
except OSError:
continue
except (OSError, asyncio.TimeoutError):
pass
return False
async def _certificate_matches(cert: Path, host: str) -> bool:
try:
ipaddress.ip_address(host)
check = "-checkip"
except ValueError:
check = "-checkhost"
async def inspect(*arguments: str) -> tuple[int | None, bytes]:
process = await asyncio.create_subprocess_exec(
"openssl", "x509", "-in", str(cert), "-noout", *arguments,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
try:
output, _ = await asyncio.wait_for(process.communicate(), 3)
return process.returncode, output
except asyncio.TimeoutError:
process.kill()
await process.wait()
return None, b""
except asyncio.CancelledError:
if process.returncode is None:
process.kill()
await process.wait()
raise
expiry, _ = await inspect("-checkend", "0")
if expiry != 0:
return False
result, message = await inspect(check, host)
# Some supported OpenSSL versions return zero for a hostname mismatch.
return result == 0 and b"does match certificate" in message
async def secure_link_preflight(server: Any, host: str | None = None, port: str | int | None = None) -> dict[str, Any]:
"""Inspect the running Relay's configuration; never write keys or restart it."""
config = server.config
candidate = getattr(server, "secure_proxy_candidate", None)
current_url = candidate.get("proxy", {}).get("url") if isinstance(candidate, dict) else None
current = urlsplit(current_url or "")
proposed_host = host if host is not None else current.hostname or config.secure_proxy_host
if proposed_host in {"0.0.0.0", "::"}:
# Ask for the advertised address instead of guessing which interface a
# phone can reach or turning a bind wildcard into an unusable QR.
proposed_host = ""
checks: list[dict[str, str]] = []
def add(key: str, label: str, status: str, detail: str) -> None:
checks.append({"id": key, "label": label, "status": status, "detail": detail})
try:
address, selected_port = setup_address(proposed_host, port if port is not None else config.secure_proxy_port)
except ValueError as exc:
add("address", "Server address", "blocked", str(exc))
address, selected_port = "", config.secure_proxy_port
url_host = f"[{address}]" if ":" in address else address
proposed_url = f"https://{url_host}:{selected_port}" if address else None
try:
current_address, current_port = setup_address(current.hostname or "", current.port or 443)
except ValueError:
current_address, current_port = "", 0
active = bool(current_url and current.scheme == "https" and current_address == address
and current_port == selected_port and current.path in {"", "/"}
and not any((current.username, current.password, current.query, current.fragment)))
if address:
loopback = address == "localhost"
try:
loopback = loopback or ipaddress.ip_address(address).is_loopback
except ValueError:
pass
add("address", "Server address", "blocked" if loopback else "ok",
"A phone cannot use the server's loopback address. Choose its LAN, VPN, or DNS address."
if loopback else "This is the address the client will pair with. LAN/VPN/public reachability is still required.")
available = active or await _can_bind(address, selected_port)
add("listener", "HTTPS listener", "ok" if available else "blocked",
"The existing Secure Link listener owns this address." if active else
"The address and port are available to this Relay process." if available else
"Relay cannot bind this address and port. Check the local interface, port owner, and permissions.")
async def upstream(raw: str, label: str, key: str, dashboard: bool) -> None:
try:
base = _loopback_http_base(raw, label)
except (ValueError, AttributeError):
add(key, label, "blocked",
f"{label} must use a loopback HTTP upstream for Secure Link. Configure a working local listener first; "
"do not replace a LAN-only address with localhost unless that listener actually accepts loopback connections.")
return
ready = await (_dashboard_gate_enabled(base) if dashboard else _api_available(base))
add(key, label, "ok" if ready else "blocked" if dashboard else "warning",
("Password/OAuth protection is enabled. Clients still sign in separately." if dashboard else "The optional API upstream is reachable.")
if ready else
("Dashboard is unavailable or password/OAuth protection is disabled. Enable its authentication before exposing it."
if dashboard else "The optional API is unavailable; this does not prove Chat or voice is unavailable."))
await asyncio.gather(
upstream(config.webapi_url, "API upstream", "api", False),
upstream(config.secure_proxy_dashboard_url, "Dashboard protection", "dashboard", True),
)
identity = Path(config.hermes_config_path).expanduser().parent / "relay-secure-proxy"
cert = Path(config.secure_proxy_cert).expanduser() if config.secure_proxy_cert else identity / "cert.pem"
key = Path(config.secure_proxy_key).expanduser() if config.secure_proxy_key else identity / "key.pem"
openssl = shutil.which("openssl") is not None
if not openssl:
add("certificate", "Certificate and pin", "blocked", "Install OpenSSL on the Relay host before enabling Secure Link.")
elif cert.exists() or key.exists():
try:
valid = bool(address and cert.is_file() and key.is_file() and os.access(key, os.R_OK)
and await _certificate_matches(cert, address))
if os.name == "posix" and key.is_file() and key.stat().st_mode & 0o077:
valid = False
if valid:
# Also reject mismatched or encrypted keys without prompting.
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(cert, key, password="")
except OSError:
valid = False
add("certificate", "Certificate and pin", "ok" if valid else "blocked",
"Existing certificate matches this address and is not expired. No identity was changed." if valid else
"Check the existing certificate/key pair: it must be readable, match this address, be unexpired, and keep the private key owner-only. Review rotation and re-pairing if the identity must change; this check never replaces keys.")
else:
parents = [cert.parent, key.parent]
for index, parent in enumerate(parents):
while not parent.exists() and parent != parent.parent:
parent = parent.parent
parents[index] = parent
writable = all(os.access(parent, os.W_OK) for parent in parents)
add("certificate", "Certificate and pin", "ok" if writable else "blocked",
"Relay will create a local certificate and pin on enablement. Nothing has been generated by this check."
if writable else "Relay cannot write its certificate directory. Fix its service-user permissions first.")
ready = not any(check["status"] == "blocked" for check in checks)
return {
"schema_version": 1,
"state": "enabled" if active and ready else "ready_to_configure" if ready else "needs_attention",
"read_only": True,
"url": proposed_url,
"current_url": current_url,
"host": address,
"port": selected_port,
"checks": checks,
"ready_to_enable": ready,
"pairing_ready": active and ready,
"requires_repair": bool(current_url and not active),
"connected_clients": server.client_count,
"activation_mode": "instructions",
"restart_notice": "Restarting Relay briefly disconnects its clients. Use the manager that already owns Relay; embedded installations may also require a Gateway restart.",
"configuration_note": "Startup flags override environment settings. Update any existing --secure-link, --no-secure-link, --secure-link-host, and --secure-link-port flags to match the intended settings.",
"environment": {
"RELAY_SECURE_LINK_ENABLED": "1", "RELAY_SECURE_LINK_HOST": address,
"RELAY_SECURE_LINK_PORT": str(selected_port),
} if ready else {},
"start_arguments": ["--secure-link", "--secure-link-host", address, "--secure-link-port", str(selected_port)] if ready else [],
"disable_environment": {"RELAY_SECURE_LINK_ENABLED": "0"},
"pair_command": "hermes pair --png" if active and ready else None,
}
+208 -18
View File
@@ -12,6 +12,7 @@ import asyncio
import base64
import hashlib
import ipaddress
import json
import os
import ssl
import subprocess
@@ -24,6 +25,8 @@ from urllib.parse import urlsplit
import aiohttp
from aiohttp import web
from . import __version__
if TYPE_CHECKING:
from .server import RelayServer
@@ -234,9 +237,21 @@ def _scope_dashboard_cookie(value: str) -> str:
def _rewrite_dashboard_location(value: str, upstream_base: str) -> str | None:
"""Map same-Dashboard redirects under /dashboard; reject unsafe HTTP hops."""
"""Map same-Dashboard redirects under /dashboard; reject unsafe HTTP hops.
Upstream may already emit `/dashboard/...` when it honors
`X-Forwarded-Prefix: /dashboard`. Prefix only bare same-origin paths so we
never produce `/dashboard/dashboard/...`.
"""
def _under_dashboard(path_and_query: str) -> str:
if path_and_query == "/dashboard" or path_and_query.startswith("/dashboard/"):
return path_and_query
if not path_and_query.startswith("/"):
path_and_query = "/" + path_and_query
return "/dashboard" + path_and_query
if value.startswith("/") and not value.startswith("//"):
return "/dashboard" + value
return _under_dashboard(value)
target = urlsplit(value)
upstream = urlsplit(upstream_base)
if (
@@ -247,13 +262,81 @@ def _rewrite_dashboard_location(value: str, upstream_base: str) -> str | None:
suffix = target.path or "/"
if target.query:
suffix += "?" + target.query
return "/dashboard" + suffix
return _under_dashboard(suffix)
if target.scheme == "https" and target.hostname:
# OAuth providers intentionally leave the Hermes origin.
return value
return None
# Login HTML hard-codes root-absolute paths (fetch('/auth/password-login'),
# href="/auth/login", assign('/')). Behind Secure Link the public origin is
# /dashboard/*, so those hit bare /auth/* → 404 and the form shows
# "Sign-in failed. Please try again." Rewrite only known auth roots.
_DASHBOARD_HTML_ROOT_REWRITES: tuple[tuple[bytes, bytes], ...] = (
(b"fetch('/auth/", b"fetch('/dashboard/auth/"),
(b'fetch("/auth/', b'fetch("/dashboard/auth/'),
(b'href="/auth/', b'href="/dashboard/auth/'),
(b"href='/auth/", b"href='/dashboard/auth/"),
(b'href="/login', b'href="/dashboard/login'),
(b"href='/login", b"href='/dashboard/login"),
(b"url('/fonts/", b"url('/dashboard/fonts/"),
(b'url("/fonts/', b'url("/dashboard/fonts/'),
# password-login success fallback when JSON next is missing
(
b"window.location.assign((data && data.next) || '/');",
b"window.location.assign((data && data.next) || '/dashboard/');",
),
(
b'window.location.assign((data && data.next) || "/");',
b'window.location.assign((data && data.next) || "/dashboard/");',
),
)
def _under_dashboard_path(path: str) -> str:
if path == "/dashboard" or path == "/dashboard/" or path.startswith("/dashboard/"):
return path if path != "/dashboard" else "/dashboard/"
if not path.startswith("/"):
return path
if path == "/":
return "/dashboard/"
return "/dashboard" + path
def _rewrite_dashboard_json_next(body: bytes) -> bytes:
"""Prefix JSON ``next`` landing paths for password-login responses."""
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
return body
if not isinstance(payload, dict) or "next" not in payload:
return body
nxt = payload.get("next")
if not isinstance(nxt, str) or not nxt.startswith("/") or nxt.startswith("//"):
return body
# Native loopback redirects stay absolute http://127.0.0.1 — untouched above.
rewritten = _under_dashboard_path(nxt)
if rewritten == nxt:
return body
payload["next"] = rewritten
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
def _rewrite_dashboard_body(content_type: str, body: bytes) -> bytes:
"""Fix root-absolute dashboard auth URLs for the /dashboard Secure Link mount."""
lowered = content_type.lower()
if "application/json" in lowered:
return _rewrite_dashboard_json_next(body)
if "text/html" not in lowered:
return body
out = body
for old, new in _DASHBOARD_HTML_ROOT_REWRITES:
if old in out:
out = out.replace(old, new)
return out
async def _dashboard_gate_enabled(base: str) -> bool:
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as client:
@@ -305,6 +388,14 @@ async def _proxy_http(
dashboard=dashboard,
forwarded_host=forwarded_host,
)
# Plain bodies so we can rewrite login HTML/JSON under /dashboard.
if dashboard:
headers = {
name: value
for name, value in headers.items()
if name.lower() != "accept-encoding"
}
headers["Accept-Encoding"] = "identity"
timeout = aiohttp.ClientTimeout(
total=PROXY_HTTP_TOTAL_TIMEOUT_SECONDS,
connect=5,
@@ -323,6 +414,25 @@ async def _proxy_http(
and response.content_length > MAX_PROXY_RESPONSE_BYTES
):
raise web.HTTPBadGateway(text="upstream response exceeds secure limit")
content_type = response.headers.get("Content-Type", "")
needs_body_rewrite = dashboard and (
"text/html" in content_type.lower()
or "application/json" in content_type.lower()
)
upstream_body = b""
if needs_body_rewrite:
# The rewrite requires identity bytes. Never remove an encoding
# header from a body the upstream compressed despite our request.
if response.headers.get("Content-Encoding", "identity").lower() != "identity":
raise web.HTTPBadGateway(text="unexpected encoded Dashboard response")
buffered = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
if len(buffered) + len(chunk) > MAX_PROXY_RESPONSE_BYTES:
raise web.HTTPBadGateway(text="upstream response exceeds secure limit")
buffered.extend(chunk)
upstream_body = _rewrite_dashboard_body(content_type, bytes(buffered))
if len(upstream_body) > MAX_PROXY_RESPONSE_BYTES:
raise web.HTTPBadGateway(text="upstream response exceeds secure limit")
forwarded: list[tuple[str, str]] = []
for raw_name, raw_value in response.raw_headers:
name = raw_name.decode("latin1")
@@ -330,6 +440,15 @@ async def _proxy_http(
lowered = name.lower()
if lowered in _HOP_HEADERS or (not dashboard and lowered == "set-cookie"):
continue
if needs_body_rewrite and lowered in {
"content-length",
"content-encoding",
"transfer-encoding",
"etag",
"content-md5",
"digest",
}:
continue
if dashboard and lowered == "set-cookie":
value = _scope_dashboard_cookie(value)
elif dashboard and lowered == "location":
@@ -338,6 +457,14 @@ async def _proxy_http(
continue
value = rewritten
forwarded.append((name, value))
if needs_body_rewrite:
forwarded.append(("Content-Length", str(len(upstream_body))))
downstream = web.Response(
status=response.status,
headers=forwarded,
body=upstream_body,
)
return downstream
downstream = web.StreamResponse(status=response.status, headers=forwarded)
await downstream.prepare(request)
response_size = 0
@@ -352,32 +479,79 @@ async def _proxy_http(
async def _proxy_websocket(
request: web.Request, upstream_url: str, headers: dict[str, str]
request: web.Request, upstream_url: str, headers: dict[str, str],
) -> web.StreamResponse:
downstream = web.WebSocketResponse(heartbeat=30, max_msg_size=4 * 1024 * 1024)
await downstream.prepare(request)
# aiohttp's client handshake must own Sec-WebSocket-*; forwarding the
# caller's key/extensions confuses the upstream upgrade.
safe_headers = {
name: value
for name, value in headers.items()
if not name.lower().startswith("sec-websocket-")
and name.lower() not in {
"content-length",
"content-type",
"content-encoding",
}
}
protocols = [
protocol.strip()
for value in request.headers.getall("Sec-WebSocket-Protocol", [])
for protocol in value.split(",")
if protocol.strip()
]
downstream = web.WebSocketResponse(heartbeat=30, max_msg_size=4 * 1024 * 1024, protocols=protocols)
if not downstream.can_prepare(request).ok:
raise web.HTTPBadRequest(text="WebSocket upgrade required")
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=None, connect=5)
timeout=aiohttp.ClientTimeout(total=None, connect=5),
) as client:
async with client.ws_connect(
upstream_url, heartbeat=30, max_msg_size=4 * 1024 * 1024,
headers=headers,
upstream_url,
heartbeat=30,
max_msg_size=4 * 1024 * 1024,
headers=safe_headers,
protocols=protocols,
# Disable permessage-deflate on the upstream leg. Negotiating
# compression independently on phone↔proxy and proxy↔dashboard
# produced WS close 1002 (protocol error) right after
# gateway.ready, so chat stayed on "checking gateway".
compress=0,
) as upstream:
# Finish authentication/negotiation upstream first. Echo only
# its selected public protocol, never a ticket credential.
selected = upstream.protocol
if selected and selected.startswith("hermes-gateway-ticket."):
raise web.HTTPBadGateway(text="upstream selected a credential protocol")
downstream = web.WebSocketResponse(
heartbeat=30,
max_msg_size=4 * 1024 * 1024,
protocols=[selected] if selected else [],
)
await downstream.prepare(request)
async def forward(source, target) -> None:
async for message in source:
if message.type == aiohttp.WSMsgType.TEXT:
await target.send_str(message.data)
elif message.type == aiohttp.WSMsgType.BINARY:
await target.send_bytes(message.data)
tasks = [asyncio.create_task(forward(downstream, upstream)),
asyncio.create_task(forward(upstream, downstream))]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
tasks = [
asyncio.create_task(forward(downstream, upstream)),
asyncio.create_task(forward(upstream, downstream)),
]
done, pending = await asyncio.wait(
tasks, return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*done, *pending, return_exceptions=True)
await downstream.close()
except (aiohttp.ClientError, asyncio.TimeoutError):
await downstream.close(code=1011, message=b"upstream unavailable")
if not downstream.prepared:
raise web.HTTPBadGateway(text="upstream unavailable")
if not downstream.closed:
await downstream.close(code=1011, message=b"upstream unavailable")
return downstream
@@ -447,7 +621,11 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
services["api"]["available"] = api_available
services["dashboard"]["available"] = dashboard_available
return web.json_response({
"status": "ok", "surface": "hermes_secure_proxy",
"status": "ok",
"version": __version__,
"clients": server.client_count,
"sessions": server.sessions.active_count(),
"surface": "hermes_secure_proxy",
"display_name": SECURE_LINK_NAME,
"description": SECURE_LINK_DESCRIPTION,
"security": "pinned_tls",
@@ -470,8 +648,9 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
tail = _safe_tail(request, "/api")
if tail is None:
raise web.HTTPBadRequest(text="unsafe proxy path")
# Empty tail is bare /api — same as dashboard, land on upstream "/".
try:
return await _proxy_http(request, f"{api_base}{tail}")
return await _proxy_http(request, f"{api_base}{tail or '/'}")
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise web.HTTPBadGateway(text="API upstream unavailable") from exc
@@ -491,9 +670,15 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
forwarded_host=secure_link_authority,
)
if request.headers.get("Upgrade", "").lower() == "websocket":
return await _proxy_websocket(
request, upstream.replace("http://", "ws://", 1), headers
)
# Gateway chat WS carries auth as ?ticket=… (and optional profile).
# Dropping the query string makes upstream reject the upgrade and the
# proxy surfaces that as close 1011 "upstream unavailable".
qs = request.query_string
ws_url = upstream.replace("http://", "ws://", 1)
if qs:
sep = "&" if "?" in ws_url else "?"
ws_url = f"{ws_url}{sep}{qs}"
return await _proxy_websocket(request, ws_url, headers)
try:
return await _proxy_http(
request,
@@ -506,7 +691,12 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
app.router.add_get("/relay/health", health, allow_head=True)
app.router.add_get("/relay/ws", relay_ws)
# Bare /api and /dashboard (no trailing slash) must resolve — browsers and
# clients often omit the slash; aiohttp's /{tail:.*} pattern does not match
# the prefix alone and previously returned a plain 404.
app.router.add_route("*", "/api", api_proxy)
app.router.add_route("*", "/api/{tail:.*}", api_proxy)
app.router.add_route("*", "/dashboard", dashboard_proxy)
app.router.add_route("*", "/dashboard/{tail:.*}", dashboard_proxy)
return app
+29 -15
View File
@@ -385,6 +385,17 @@ async def handle_health(request: web.Request) -> web.Response:
return web.json_response(payload)
async def handle_secure_link_preflight(request: web.Request) -> web.Response:
"""Operator-only readiness report; public Secure Link never proxies it."""
_require_loopback(request)
from .secure_link_setup import secure_link_preflight
report = await secure_link_preflight(
request.app["server"], request.query.get("host"), request.query.get("port"),
)
return web.json_response(report)
async def handle_pairing_register(request: web.Request) -> web.Response:
"""Pre-register an externally-provided pairing code.
@@ -4878,6 +4889,7 @@ def create_app(config: RelayConfig) -> web.Application:
app.router.add_get("/ws", handle_ws)
app.router.add_get("/", handle_ws)
app.router.add_get("/health", handle_health)
app.router.add_get("/secure-link/preflight", handle_secure_link_preflight)
app.router.add_post("/pairing/register", handle_pairing_register)
app.router.add_post("/pairing/mint", handle_pairing_mint)
app.router.add_post("/pairing/approve", handle_pairing_approve)
@@ -5197,23 +5209,25 @@ async def _on_secure_proxy_startup(app: web.Application) -> None:
config = server.config
if server.secure_proxy_candidate is None:
return
if not config.secure_proxy_cert or not config.secure_proxy_key:
raise RuntimeError(f"{SECURE_LINK_NAME} identity is unavailable")
proxy_app = create_secure_proxy_app(server)
runner = web.AppRunner(proxy_app, access_log=None)
await runner.setup()
site = web.TCPSite(
runner,
host=config.secure_proxy_host,
port=config.secure_proxy_port,
ssl_context=secure_proxy_tls_context(
Path(config.secure_proxy_cert), Path(config.secure_proxy_key)
),
)
runner: web.AppRunner | None = None
try:
if not config.secure_proxy_cert or not config.secure_proxy_key:
raise ValueError(f"{SECURE_LINK_NAME} identity is unavailable")
proxy_app = create_secure_proxy_app(server)
runner = web.AppRunner(proxy_app, access_log=None)
await runner.setup()
site = web.TCPSite(
runner,
host=config.secure_proxy_host,
port=config.secure_proxy_port,
ssl_context=secure_proxy_tls_context(
Path(config.secure_proxy_cert), Path(config.secure_proxy_key)
),
)
await site.start()
except (OSError, ssl.SSLError) as exc:
await runner.cleanup()
except (OSError, ValueError) as exc:
if runner is not None:
await runner.cleanup()
server.secure_proxy_candidate = None
logger.error("%s disabled: listener failed: %s", SECURE_LINK_NAME, exc)
return
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import argparse
import io
import json
import tempfile
import shutil
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import web
from plugin import cli
from plugin.relay.config import RelayConfig
from plugin.relay.secure_link_setup import secure_link_preflight, setup_address, _certificate_matches
from plugin.relay.secure_proxy import ensure_tls_identity
from plugin.relay.server import _on_secure_proxy_startup, handle_secure_link_preflight
class SetupAddressTests(unittest.TestCase):
def test_address_is_not_a_command_url_or_unspecified_bind(self) -> None:
for host in ["", "0.0.0.0", "::", "224.0.0.1", "127.1", "https://relay.example", "a..example", "a;id", "a\nb", "user@host", "fe80::1%eth0;id", "relay.example]"]:
with self.subTest(host=host), self.assertRaises(ValueError):
setup_address(host, 9443)
for port in [0, 65536, True, "12;id", "3.5"]:
with self.subTest(port=port), self.assertRaises(ValueError):
setup_address("relay.example", port)
self.assertEqual(setup_address("[2001:db8::1]", "9443"), ("2001:db8::1", 9443))
self.assertEqual(setup_address("Relay.Example", 9443), ("relay.example", 9443))
class SecureLinkSetupTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.config = RelayConfig(hermes_config_path=str(Path(self.temp.name) / "config.yaml"))
self.server = SimpleNamespace(config=self.config, client_count=2, secure_proxy_candidate=None)
self.api = self.enterContext(patch("plugin.relay.secure_link_setup._api_available", new=AsyncMock(return_value=True)))
self.gate = self.enterContext(patch("plugin.relay.secure_link_setup._dashboard_gate_enabled", new=AsyncMock(return_value=True)))
self.bind = self.enterContext(patch("plugin.relay.secure_link_setup._can_bind", new=AsyncMock(return_value=True)))
self.enterContext(patch("plugin.relay.secure_link_setup.shutil.which", return_value="openssl"))
async def report(self, **kwargs):
return await secure_link_preflight(self.server, **{"host": "192.0.2.10", **kwargs})
async def test_readiness_does_not_enable_write_keys_or_claim_chat_ready(self) -> None:
before = vars(self.config).copy()
result = await self.report()
self.assertEqual(result["state"], "ready_to_configure")
self.assertTrue(result["read_only"])
self.assertFalse(result["pairing_ready"])
self.assertEqual(result["connected_clients"], 2)
self.assertEqual(result["activation_mode"], "instructions")
self.assertEqual(vars(self.config), before)
self.assertEqual(list(Path(self.temp.name).iterdir()), [])
self.assertNotIn("key.pem", json.dumps(result))
self.assertNotIn("certificate_pin", json.dumps(result))
async def test_wildcard_configuration_requires_an_advertised_address(self) -> None:
result = await secure_link_preflight(self.server)
self.assertEqual(result["state"], "needs_attention")
self.assertEqual(result["environment"], {})
self.bind.assert_not_awaited()
async def test_non_loopback_upstream_is_not_probed_or_silently_rewritten(self) -> None:
self.config.webapi_url = "http://192.0.2.10:8642"
result = await self.report()
self.assertFalse(result["ready_to_enable"])
self.api.assert_not_awaited()
self.assertEqual(self.config.webapi_url, "http://192.0.2.10:8642")
self.assertIn("unless that listener actually accepts loopback", next(c["detail"] for c in result["checks"] if c["id"] == "api"))
async def test_dashboard_auth_required_and_optional_api_failure_are_distinct(self) -> None:
self.api.return_value = False
result = await self.report()
self.assertTrue(result["ready_to_enable"])
self.assertEqual(next(c["status"] for c in result["checks"] if c["id"] == "api"), "warning")
self.gate.return_value = False
result = await self.report()
self.assertFalse(result["ready_to_enable"])
async def test_port_ownership_and_active_address_gate_qr_handoff(self) -> None:
self.bind.return_value = False
self.assertFalse((await self.report())["pairing_ready"])
self.server.secure_proxy_candidate = {"proxy": {"url": "https://192.0.2.10:9443"}}
result = await self.report()
self.assertEqual(result["state"], "enabled")
self.assertTrue(result["pairing_ready"])
changed = await self.report(port=9444)
self.assertFalse(changed["pairing_ready"])
async def test_incomplete_or_wrong_host_identity_never_rotates_keys(self) -> None:
cert = Path(self.temp.name) / "cert.pem"
key = Path(self.temp.name) / "key.pem"
cert.write_text("existing public certificate")
self.config.secure_proxy_cert, self.config.secure_proxy_key = str(cert), str(key)
self.assertFalse((await self.report())["ready_to_enable"])
key.write_text("test private material")
with patch("plugin.relay.secure_link_setup._certificate_matches", new=AsyncMock(return_value=False)):
report = await self.report()
self.assertFalse(report["ready_to_enable"])
self.assertNotIn("test private material", json.dumps(report))
self.assertEqual(key.read_text(), "test private material")
async def test_public_relay_caller_cannot_inspect_operator_configuration(self) -> None:
request = SimpleNamespace(remote="192.0.2.44")
with self.assertRaises(web.HTTPForbidden):
await handle_secure_link_preflight(request)
async def test_bad_secure_link_configuration_does_not_abort_ordinary_relay_startup(self) -> None:
self.config.webapi_url = "http://192.0.2.10:8642"
self.config.secure_proxy_cert = "unused-cert"
self.config.secure_proxy_key = "unused-key"
self.server.secure_proxy_candidate = {"proxy": {"url": "https://192.0.2.10:9443"}}
await _on_secure_proxy_startup({"server": self.server})
self.assertIsNone(self.server.secure_proxy_candidate)
@unittest.skipUnless(shutil.which("openssl"), "OpenSSL required")
class SetupCertificateTests(unittest.IsolatedAsyncioTestCase):
async def test_existing_certificate_must_match_selected_address(self) -> None:
with tempfile.TemporaryDirectory() as directory:
cert, key = Path(directory) / "cert.pem", Path(directory) / "key.pem"
ensure_tls_identity(cert, key, "192.0.2.10")
before = cert.read_bytes(), key.read_bytes()
self.assertTrue(await _certificate_matches(cert, "192.0.2.10"))
self.assertFalse(await _certificate_matches(cert, "192.0.2.11"))
self.assertEqual((cert.read_bytes(), key.read_bytes()), before)
class SecureLinkCliTests(unittest.TestCase):
def test_cli_reads_shared_report_and_registers_start_flags(self) -> None:
parser = argparse.ArgumentParser()
cli.register_relay_cli(parser)
args = parser.parse_args(["secure-link", "--host", "relay.example", "--port", "9443", "--json"])
response = MagicMock()
report = {"schema_version": 1, "checks": [], "ready_to_enable": True, "state": "ready_to_configure", "read_only": True}
response.__enter__.return_value = io.StringIO(json.dumps(report))
with patch("urllib.request.urlopen", return_value=response) as request, redirect_stdout(io.StringIO()) as out:
args.func(args)
self.assertEqual(json.loads(out.getvalue()), report)
self.assertEqual(request.call_args.args[0], "http://127.0.0.1:8767/secure-link/preflight?host=relay.example&port=9443")
start = parser.parse_args(["start", "--secure-link", "--secure-link-host", "relay.example", "--secure-link-port", "9443"])
self.assertTrue(start.secure_link)
self.assertEqual(start.secure_link_host, "relay.example")
+45 -1
View File
@@ -21,6 +21,7 @@ from plugin.relay.secure_proxy import (
spki_pin_sha256,
_forward_headers,
_scope_dashboard_cookie,
_rewrite_dashboard_body,
_rewrite_dashboard_location,
)
from plugin.relay.server import (
@@ -35,6 +36,8 @@ from plugin.relay.server import (
class SecureProxyRouteTests(AioHTTPTestCase):
async def get_application(self):
self.server_state = RelayServer(RelayConfig())
self.enterContext(patch("plugin.relay.secure_proxy._api_available", new=AsyncMock(return_value=False)))
self.enterContext(patch("plugin.relay.secure_proxy._dashboard_gate_enabled", new=AsyncMock(return_value=False)))
return create_secure_proxy_app(self.server_state)
async def asyncTearDown(self) -> None:
@@ -48,6 +51,8 @@ class SecureProxyRouteTests(AioHTTPTestCase):
self.assertEqual(body["surface"], "hermes_secure_proxy")
self.assertEqual(body["display_name"], SECURE_LINK_NAME)
self.assertEqual(body["security"], "pinned_tls")
self.assertIn("version", body)
self.assertTrue(str(body["version"]).strip())
self.assertEqual(body["capabilities"], ["relay", "api", "dashboard"])
self.assertEqual(body["namespaces"], ["relay", "api", "dashboard"])
self.assertEqual(body["services"]["relay"]["websocket_path"], "/relay/ws")
@@ -59,12 +64,17 @@ class SecureProxyRouteTests(AioHTTPTestCase):
for path in (
"/relay/sessions",
"/relay/voice/config",
"/relay/desktop/_ping", "/relay/pairing/register", "/health",
):
response = await self.client.get(path)
self.assertEqual(response.status, 404, path)
self.assertEqual((await self.client.get("/api/health")).status, 502)
# Deterministic failures; never depend on a developer's local services.
from aiohttp import ClientConnectionError
with patch("plugin.relay.secure_proxy._proxy_http", new=AsyncMock(side_effect=ClientConnectionError)):
self.assertEqual((await self.client.get("/api/health")).status, 502)
self.assertEqual((await self.client.get("/dashboard")).status, 503)
self.assertEqual((await self.client.get("/dashboard/")).status, 503)
async def test_mutating_health_is_rejected(self) -> None:
@@ -327,6 +337,18 @@ class SecureProxyAdvertisementTests(unittest.TestCase):
),
"/dashboard/auth/callback?code=x",
)
# Upstream that already honored X-Forwarded-Prefix must not double-prefix.
self.assertEqual(
_rewrite_dashboard_location("/dashboard/login", upstream),
"/dashboard/login",
)
self.assertEqual(
_rewrite_dashboard_location(
"http://127.0.0.1:9119/dashboard/auth/callback?code=x",
upstream,
),
"/dashboard/auth/callback?code=x",
)
self.assertEqual(
_rewrite_dashboard_location("https://idp.example/authorize", upstream),
"https://idp.example/authorize",
@@ -335,6 +357,28 @@ class SecureProxyAdvertisementTests(unittest.TestCase):
_rewrite_dashboard_location("http://attacker.example/", upstream)
)
def test_dashboard_html_and_json_auth_paths_are_scoped(self) -> None:
html = (
b"<script>fetch('/auth/password-login');"
b"window.location.assign((data && data.next) || '/');</script>"
)
rewritten = _rewrite_dashboard_body("text/html; charset=utf-8", html)
self.assertIn(b"fetch('/dashboard/auth/password-login')", rewritten)
self.assertIn(
b"window.location.assign((data && data.next) || '/dashboard/');",
rewritten,
)
payload = json.dumps({"ok": True, "next": "/"}).encode("utf-8")
out = _rewrite_dashboard_body("application/json", payload)
self.assertEqual(json.loads(out.decode("utf-8"))["next"], "/dashboard/")
already = json.dumps({"ok": True, "next": "/dashboard/sessions"}).encode(
"utf-8"
)
self.assertEqual(
_rewrite_dashboard_body("application/json", already),
already,
)
def test_auth_ok_does_not_replace_operator_reviewed_endpoints(self) -> None:
server = RelayServer(RelayConfig())
server.secure_proxy_candidate = advertised_candidate(
+214
View File
@@ -0,0 +1,214 @@
"""Loopback wire tests for the Secure Link Dashboard/Gateway boundary."""
from __future__ import annotations
import asyncio
import gzip
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from aiohttp import WSMsgType, WSServerHandshakeError, web
from aiohttp.test_utils import TestClient, TestServer
from plugin.relay import __version__, secure_proxy
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "test-fixtures" / "vanilla-gateway"))
from vanilla_gateway import GatewayFixture, load_scenario # noqa: E402
class SecureProxyContractTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.seen: list[dict[str, object]] = []
self.inner_health_calls = 0
self.gate_calls = 0
self.stream_release = asyncio.Event()
self.stream_bytes = 0
self.tickets = {"query-ticket", "protocol-ticket"}
app = web.Application()
app.router.add_get("/api/health", self.gate)
app.router.add_get("/health", self.inner_health)
app.router.add_get("/api/ws", self.gateway)
app.router.add_get("/chunked", self.chunked)
app.router.add_get("/compressed", self.compressed)
app.router.add_post("/auth/password-login", self.login)
self.upstream = TestServer(app)
await self.upstream.start_server()
base = str(self.upstream.make_url("/")).rstrip("/")
self.state = SimpleNamespace(
config=SimpleNamespace(
webapi_url=base,
secure_proxy_dashboard_url=base,
secure_proxy_host="localhost",
secure_proxy_port=9443,
port=self.upstream.port,
),
secure_proxy_candidate=None,
client_count=4,
sessions=SimpleNamespace(active_count=lambda: 7),
)
self.client = TestClient(TestServer(secure_proxy.create_secure_proxy_app(self.state)))
await self.client.start_server()
async def asyncTearDown(self) -> None:
await self.client.close()
await self.upstream.close()
async def gate(self, request: web.Request) -> web.Response:
self.gate_calls += 1
return web.json_response({"auth_required": True})
async def inner_health(self, request: web.Request) -> web.Response:
self.inner_health_calls += 1
return web.json_response({"status": "ok"})
async def gateway(self, request: web.Request) -> web.WebSocketResponse:
# Current upstream requires the public protocol alongside exactly one
# ticket protocol, and selects only the public protocol on admission.
protocols = [p.strip() for p in request.headers.get("Sec-WebSocket-Protocol", "").split(",")]
credentials = [p.removeprefix("hermes-gateway-ticket.") for p in protocols
if p.startswith("hermes-gateway-ticket.")]
if credentials and (len(credentials) != 1 or "hermes-gateway-v1" not in protocols):
raise web.HTTPForbidden()
ticket = credentials[0] if credentials else request.query.get("ticket")
if ticket not in self.tickets:
raise web.HTTPForbidden()
self.tickets.remove(ticket)
self.seen.append({
"profile": request.query.get("profile"),
"extensions": request.headers.get("Sec-WebSocket-Extensions"),
"protocols": protocols,
})
ws = web.WebSocketResponse(protocols=["hermes-gateway-v1"] if credentials else [])
await ws.prepare(request)
await ws.send_str("gateway.ready")
async for message in ws:
if message.type == WSMsgType.TEXT:
await ws.send_str(message.data)
elif message.type == WSMsgType.BINARY:
await ws.send_bytes(message.data)
return ws
async def chunked(self, request: web.Request) -> web.StreamResponse:
response = web.StreamResponse(headers={"Content-Type": "application/json"})
await response.prepare(request)
try:
# Hold EOF until the client receives the size-limit rejection.
for _ in range(3):
await response.write(b" " * 1024)
self.stream_bytes += 1024
await self.stream_release.wait()
await response.write_eof()
except ConnectionResetError:
pass
return response
async def compressed(self, request: web.Request) -> web.Response:
self.assertEqual(request.headers.get("Accept-Encoding"), "identity")
return web.Response(body=gzip.compress(b'{"next":"/"}'), headers={
"Content-Type": "application/json", "Content-Encoding": "gzip",
})
async def login(self, request: web.Request) -> web.Response:
return web.json_response({"next": "/"}, headers={
"ETag": '"before-rewrite"', "Set-Cookie": "session=value; Path=/; HttpOnly",
})
async def test_query_ticket_profile_and_compressed_downstream_frames(self) -> None:
async with self.client.ws_connect(
"/dashboard/api/ws?ticket=query-ticket&profile=work", compress=15,
) as ws:
self.assertEqual((await ws.receive(timeout=2)).data, "gateway.ready")
await ws.send_str("message" * 100)
self.assertEqual((await ws.receive(timeout=2)).data, "message" * 100)
await ws.send_bytes(b"audio")
self.assertEqual((await ws.receive(timeout=2)).data, b"audio")
self.assertEqual(self.seen[0]["profile"], "work")
self.assertIsNone(self.seen[0]["extensions"])
async def test_ticket_subprotocol_selects_only_public_protocol_and_is_single_use(self) -> None:
protocols = ["hermes-gateway-v1", "hermes-gateway-ticket.protocol-ticket"]
async with self.client.ws_connect("/dashboard/api/ws?profile=work", protocols=protocols) as ws:
self.assertEqual(ws.protocol, "hermes-gateway-v1")
self.assertEqual((await ws.receive(timeout=2)).data, "gateway.ready")
self.assertEqual(self.seen[0]["protocols"], protocols)
with self.assertRaises(WSServerHandshakeError) as rejected:
await self.client.ws_connect("/dashboard/api/ws", protocols=protocols)
self.assertEqual(rejected.exception.status, 502)
async def test_missing_or_ambiguous_credentials_never_upgrade(self) -> None:
for protocols in ([], ["hermes-gateway-ticket.protocol-ticket"], [
"hermes-gateway-v1", "hermes-gateway-ticket.protocol-ticket", "hermes-gateway-ticket.extra",
]):
with self.assertRaises(WSServerHandshakeError):
await self.client.ws_connect("/dashboard/api/ws", protocols=protocols)
self.assertEqual(self.seen, [])
async def test_public_health_uses_local_counts_and_coalesces_availability(self) -> None:
async def probe() -> dict:
async with self.client.get("/relay/health") as response:
return await response.json()
with patch.object(secure_proxy, "_api_available", new=AsyncMock(return_value=True)) as api:
results = await asyncio.gather(*(probe() for _ in range(20)))
api.assert_awaited_once()
self.assertEqual(self.gate_calls, 1)
self.assertEqual(self.inner_health_calls, 0)
for result in results:
self.assertEqual((result["version"], result["clients"], result["sessions"]),
(__version__, 4, 7))
async def test_chunked_rewrite_stops_at_limit_without_waiting_for_eof(self) -> None:
with patch.object(secure_proxy, "MAX_PROXY_RESPONSE_BYTES", 2048):
try:
response = await asyncio.wait_for(self.client.get("/dashboard/chunked"), timeout=2)
self.assertEqual(response.status, 502)
await response.read()
finally:
self.stream_release.set()
async def test_unexpected_compression_fails_closed(self) -> None:
response = await self.client.get("/dashboard/compressed")
self.assertEqual(response.status, 502)
self.assertIn("encoded Dashboard response", await response.text())
async def test_rewritten_login_has_scoped_cookie_and_fresh_length(self) -> None:
response = await self.client.post("/dashboard/auth/password-login")
body = await response.read()
self.assertEqual(await response.json(), {"next": "/dashboard/"})
self.assertEqual(int(response.headers["Content-Length"]), len(body))
self.assertNotIn("ETag", response.headers)
self.assertIn("Path=/dashboard", response.headers["Set-Cookie"])
async def test_declarative_gateway_scenario_through_proxy_reconnects_to_same_session(self) -> None:
fixture = GatewayFixture(load_scenario("secure_link_gateway_auth"))
fixture.app.router.add_get("/api/health", self.gate)
async with TestServer(fixture.app) as upstream:
self.state.config.secure_proxy_dashboard_url = str(upstream.make_url("/")).rstrip("/")
async with TestClient(TestServer(secure_proxy.create_secure_proxy_app(self.state))) as proxy:
for use_protocol in (False, True):
response = await proxy.post("/dashboard/api/auth/ws-ticket")
ticket = (await response.json())["ticket"]
url = "/dashboard/api/ws"
protocols = ["hermes-gateway-v1", f"hermes-gateway-ticket.{ticket}"] if use_protocol else []
if not use_protocol:
url += f"?ticket={ticket}"
async with proxy.ws_connect(url, protocols=protocols, compress=15) as ws:
self.assertEqual((await ws.receive_json(timeout=2))["params"]["type"], "gateway.ready")
self.assertEqual(ws.protocol, "hermes-gateway-v1" if use_protocol else None)
await ws.send_json({"jsonrpc": "2.0", "id": 1, "method": "session.activate",
"params": {"session_id": fixture.scenario.live_session_id}})
while True:
frame = await ws.receive_json(timeout=2)
if frame.get("id") == 1:
self.assertEqual(frame["result"]["session_id"], fixture.scenario.live_session_id)
break
if not use_protocol:
await ws.send_json({"jsonrpc": "2.0", "id": 2, "method": "prompt.submit", "params": {}})
while True:
frame = await ws.receive_json(timeout=2)
if frame.get("params", {}).get("type") == "message.complete":
self.assertEqual(frame["params"]["payload"]["text"], "Secure Link Gateway response.")
break
with self.assertRaises(WSServerHandshakeError):
await proxy.ws_connect(url, protocols=protocols)
+9 -1
View File
@@ -8,6 +8,7 @@ from types import SimpleNamespace
from unittest import mock
from aiohttp.test_utils import TestClient, TestServer
from aiohttp import ClientConnectionError
from plugin.relay.secure_proxy import (
PROXY_HTTP_IDLE_TIMEOUT_SECONDS,
@@ -18,7 +19,11 @@ from plugin.relay.secure_proxy import (
class SecureProxySecurityTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.enterContext(mock.patch("plugin.relay.secure_proxy._api_available", new=mock.AsyncMock(return_value=False)))
self.enterContext(mock.patch("plugin.relay.secure_proxy._dashboard_gate_enabled", new=mock.AsyncMock(return_value=False)))
relay = SimpleNamespace(
client_count=0,
sessions=SimpleNamespace(active_count=lambda: 0),
config=SimpleNamespace(
port=8767,
webapi_url="http://127.0.0.1:8642",
@@ -54,6 +59,8 @@ class SecureProxySecurityTests(unittest.IsolatedAsyncioTestCase):
"/relay/security",
"/bridge/status",
"/pairing/mint",
"/secure-link/preflight",
"/relay/secure-link/preflight",
"/media/inspect",
"/relay/ws/../sessions",
"/relay/%2e%2e/sessions",
@@ -65,7 +72,8 @@ class SecureProxySecurityTests(unittest.IsolatedAsyncioTestCase):
# Fixed API/Dashboard namespaces exist, but never expose arbitrary
# Relay/operator routes or an unauthenticated loopback Dashboard.
self.assertEqual((await self.client.get("/api/health")).status, 502)
with mock.patch("plugin.relay.secure_proxy._proxy_http", new=mock.AsyncMock(side_effect=ClientConnectionError)):
self.assertEqual((await self.client.get("/api/health")).status, 502)
self.assertEqual((await self.client.get("/dashboard/api/auth/me")).status, 503)
async def test_health_is_read_only_and_bounded(self) -> None:
+54 -2
View File
@@ -37,7 +37,9 @@ SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch"
SESSION_INITIALIZATION = "gateway.session_initialization"
API_BOUNDARY = "api.fallback_boundary"
CLARIFY = "gateway.clarify"
TICKET_PROTOCOL = "gateway.ticket_subprotocol"
ALL_CONTRACTS = (
TICKET_PROTOCOL,
CLARIFY,
GATEWAY_TERMINAL,
GATEWAY_SETTLED_INFO,
@@ -89,7 +91,7 @@ class SourceFile:
if (
isinstance(decorator, ast.Call)
and isinstance(decorator.func, ast.Name)
and decorator.func.id == "method"
and decorator.func.id in {"method", "_session_method"}
and decorator.args
and isinstance(decorator.args[0], ast.Constant)
and decorator.args[0].value == method_name
@@ -241,8 +243,26 @@ def _check_activate(server: SourceFile, methods: SourceFile) -> CheckResult:
payload = server.function("_live_session_payload")
handler_text = methods.segment(handler)
payload_strings = _string_constants(payload)
required_calls = ("_sess_nowait(", "_live_session_payload(")
required_calls = ("_live_session_payload(",)
missing_calls = [marker for marker in required_calls if marker not in handler_text]
if "_sess_nowait(" not in handler_text:
# Upstream moved live-id lookup into its session decorator. Verify
# that wrapper rather than accepting any similarly named alias.
wrapped = any(
isinstance(decorator, ast.Call)
and isinstance(decorator.func, ast.Name)
and decorator.func.id == "_session_method"
and not decorator.keywords
for decorator in handler.decorator_list
)
wrapper = methods.function("_session_method") if wrapped else None
bindings = [node for node in methods.tree.body if isinstance(node, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == "_with_session" for t in node.targets)]
if not (wrapper and "method(name)" in methods.segment(wrapper)
and "_with_live_session if live else _with_session" in methods.segment(wrapper)
and "live: bool = False" in methods.segment(wrapper)
and any(_call_lines(node, "_sess_nowait") for node in bindings)):
missing_calls.append("_sess_nowait(")
required_fields = {"session_id", "session_key", "messages", "running", "status"}
missing_fields = sorted(required_fields - payload_strings)
if missing_calls:
@@ -581,6 +601,10 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
raise ValueError("fork marker(s) found in upstream source: " + ", ".join(fork_hits))
checks = {
TICKET_PROTOCOL: lambda: _check_ticket_protocol(
SourceFile(root, "hermes_cli/web_server_chat.py"),
SourceFile(root, "hermes_cli/web_routers/chat_ws.py"),
),
CLARIFY: lambda: _check_clarify(server),
GATEWAY_TERMINAL: lambda: _check_gateway_terminal(
SourceFile(root, "tui_gateway/prompt_turn.py")
@@ -600,6 +624,34 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
return [checks[requirement]() for requirement in requirements]
def _check_ticket_protocol(auth: SourceFile, router: SourceFile) -> CheckResult:
parser = auth.function("_gateway_ws_ticket_from_subprotocol")
admission = auth.function("_ws_auth_reason")
upgrade = router.function("gateway_ws")
passed = (
{"hermes-gateway-v1", "hermes-gateway-ticket."} <= _string_constants(auth.tree)
and {"sec-websocket-protocol", "invalid", "ok"} <= _string_constants(parser)
and bool(_call_lines(admission, "_gateway_ws_ticket_from_subprotocol"))
and bool(_call_lines(admission, "consume_ticket"))
and {"ticket", "ticket-subprotocol"} <= _string_constants(admission)
and "ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL" in auth.segment(admission)
and any(
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name) and node.func.id == "handle_ws"
and any(keyword.arg == "subprotocol" and "_hermes_ws_subprotocol" in
_string_constants(keyword.value) for keyword in node.keywords)
for node in ast.walk(upgrade)
)
)
return CheckResult(
TICKET_PROTOCOL, passed,
(auth.evidence(parser, "ticket protocol parser"),
auth.evidence(admission, "single-use query or protocol admission"),
router.evidence(upgrade, "public protocol selection")),
None if passed else "Gateway ticket admission or public subprotocol selection changed",
)
def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
("git", *args),
@@ -193,6 +193,21 @@ class GatewayScenarioConformanceTest(unittest.TestCase):
module.PROMPT_METHODS: PROMPT_METHODS_SOURCE,
module.ACTIVE_SESSIONS: ACTIVE_SESSIONS_SOURCE,
module.API_SERVER: API_SOURCE,
"hermes_cli/web_server_chat.py": '''
_GATEWAY_WS_PROTOCOL = "hermes-gateway-v1"
_GATEWAY_WS_TICKET_PROTOCOL_PREFIX = "hermes-gateway-ticket."
def _gateway_ws_ticket_from_subprotocol(ws):
return ws.headers.get("sec-websocket-protocol"), "ok", "invalid"
def _ws_auth_reason(ws):
ticket = _gateway_ws_ticket_from_subprotocol(ws) or ws.query_params.get("ticket")
consume_ticket(ticket)
ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL
return None, "ticket-subprotocol"
''',
"hermes_cli/web_routers/chat_ws.py": '''
async def gateway_ws(ws):
await handle_ws(ws, subprotocol=getattr(ws, "_hermes_ws_subprotocol", None))
''',
}
for relative, text in sources.items():
path = self.root / relative
@@ -208,6 +223,34 @@ class GatewayScenarioConformanceTest(unittest.TestCase):
self.assertEqual(module.ALL_CONTRACTS, tuple(result.contract for result in results))
self.assertTrue(all(result.passed for result in results), results)
def test_ticket_protocol_requires_public_selection_and_single_use_admission(self):
for relative, before, after in (
("hermes_cli/web_server_chat.py", "consume_ticket(ticket)", "accept(ticket)"),
("hermes_cli/web_server_chat.py", "ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL",
"ws._hermes_ws_subprotocol = ticket"),
("hermes_cli/web_routers/chat_ws.py", "subprotocol=getattr", "ignored=getattr"),
):
path = self.root / relative
original = path.read_text(encoding="utf-8")
with self.subTest(relative=relative, removed=before):
path.write_text(original.replace(before, after), encoding="utf-8")
self.assertFalse(module.audit_sources(self.root, (module.TICKET_PROTOCOL,))[0].passed)
path.write_text(original, encoding="utf-8")
def test_activate_accepts_verified_upstream_session_decorator(self):
path = self.root / module.SESSION_METHODS
wrapped = METHODS_SOURCE.replace('@method("session.activate")', '@_session_method("session.activate")')
wrapped = wrapped.replace(' session, error = _sess_nowait(params, rid)\n', '')
wrapped += '''
_with_session = _session_arg(lambda params, rid: _sess_nowait(params, rid))
def _session_method(name: str, *, live: bool = False):
return lambda fn: method(name)((_with_live_session if live else _with_session)(fn))
'''
path.write_text(wrapped, encoding="utf-8")
self.assertTrue(module.audit_sources(self.root, (module.SESSION_ACTIVATE,))[0].passed)
path.write_text(wrapped.replace('_sess_nowait(params, rid)', '_sess(params, rid)'), encoding="utf-8")
self.assertFalse(module.audit_sources(self.root, (module.SESSION_ACTIVATE,))[0].passed)
def test_missing_terminal_emit_fails_only_terminal_contract(self):
path = self.root / module.SERVER
path.write_text(
+15 -6
View File
@@ -1,10 +1,10 @@
---
name: hermes-relay-pair
description: Generate a pairing QR for the Hermes-Relay Android app — one scan configures chat (API server) and terminal/bridge (relay) in a single step.
description: Generate a signed setup QR for Hermes-Relay Android, preserving Dashboard/Gateway ownership and optional Relay or API routes.
version: 1.0.0
author: Axiom Labs
license: MIT
platforms: [linux, macos]
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [pairing, qr, android, relay, setup, hermes-relay]
@@ -15,7 +15,7 @@ metadata:
# Hermes-Relay Pairing
[Hermes-Relay](https://github.com/Codename-11/hermes-relay) is a native Android client for Hermes. Chat rides the standard upstream Hermes path — the dashboard `/api/ws` gateway transport (live thinking), with API-server SSE as fallback; terminal and bridge channels go through a separate WSS relay. This skill generates a single QR code that configures both connections at once, using `plugin.pair` from the Hermes-Relay plugin.
[Hermes-Relay](https://github.com/Codename-11/hermes-relay) is a native Android client for Hermes. Standard Chat, Manage, and voice belong to upstream Dashboard/Gateway. API-only operation is explicit compatibility, never an automatic fallback for a Gateway-owned conversation. Relay adds optional terminal/bridge and other extensions. This skill generates a signed setup QR using `plugin.pair`.
## When to Use
@@ -35,13 +35,22 @@ Operators with the Hermes dashboard open can also mint the same QR from the web
## Prerequisites
1. **Hermes-Relay plugin installed and enabled.** Verify with `hermes pair --help`. If the command is unavailable, run `hermes plugins install Codename-11/hermes-relay/plugin --enable`. Use the full `install.sh` path instead only when the host also needs the relay service, editable package, and shell shims.
2. **Hermes API server reachable** on `API_SERVER_HOST:API_SERVER_PORT` (default `127.0.0.1:8642`). `plugin.pair` auto-reads this from `~/.hermes/config.yaml` → `~/.hermes/.env` → env vars → defaults.
2. **Dashboard/Gateway reachable** at its configured origin for standard Chat, Manage, and voice. The API server is required only when explicitly including API-only compatibility; `plugin.pair` reads optional API configuration from the Hermes configuration/environment.
3. **Relay server running** on `RELAY_HOST:RELAY_PORT` (default `0.0.0.0:8767`) if the user wants terminal/bridge channels. The Relay may stay host-internal: current Android pairing normally reaches it through the Dashboard's same-origin plugin transport. Tailscale Serve normally exposes dedicated HTTPS `10443` and proxies the host-local Dashboard on `9119`; a raw LAN/tailnet route may reach `9119` directly. Listener `443` is an advanced explicit override only when it is free. Without a live relay, the QR will configure chat only.
4. **Host is Linux or macOS.** The relay uses a real PTY backend, which is POSIX-only. Windows hosts can generate API-only QRs but the terminal channel will not work.
4. **Check optional host capabilities.** Standard Dashboard setup and QR generation are independent of PTY support. Terminal requires the supported POSIX backend; do not present a Windows host as terminal-capable merely because pairing succeeded.
## Procedure
1. **Probe the relay** — `curl -sf http://127.0.0.1:8767/health` (or `$RELAY_PORT`). If it returns 200, the relay is up. If it fails, tell the user: "No relay running at localhost:8767 — the QR will only configure chat. Run `hermes relay start` first if you want terminal access." Then ask whether to proceed with API-only or start the relay first. Do NOT start the relay yourself unless the user explicitly asks.
For Secure Link, run `hermes relay secure-link --host <reachable-server-address>`
before minting. It is a read-only report shared with Dashboard and Desktop setup.
Resolve its blockers and have the operator apply the reviewed settings/restart
through the existing service owner; this pairing skill must not guess a service
or change it. Re-check until the requested listener is active, then use
`hermes pair --png`. Keep the certificate and pin in the signed invite. QR trust,
Dashboard sign-in, and Chat readiness are separate steps; never learn a pin from
an untrusted health response or downgrade the route after a TLS failure.
1. **Probe the relay** — `curl -sf http://127.0.0.1:8767/health` (or `$RELAY_PORT`). If it returns 200, Relay is up; that alone does not prove Dashboard sign-in or Gateway readiness. If it fails, standard setup can still use Dashboard's **Connect mobile app** QR. Optional Relay pairing requires a running Relay. Do not offer API-only as automatic recovery for a Gateway connection, and do not start Relay unless the user asks.
2. **Generate the QR** — run via the `terminal` tool:
@@ -585,6 +585,7 @@ class ScenarioTestCase(unittest.TestCase):
"rapid_tools_interims",
"subagent_child_preview",
"terminal_gap_activate",
"secure_link_gateway_auth",
"terminal_gap_active_list",
"terminal_gap_session_info",
"queued_follow_up",
@@ -0,0 +1,11 @@
{
"name": "secure_link_gateway_auth",
"live_session_id": "fixture-live-1",
"stored_session_id": "20260821_120000_fixture",
"profile": "default",
"contract_requirements": ["gateway.ticket_subprotocol", "gateway.session_activate_live"],
"turns": [{"steps": [
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.complete", "payload": {"text": "Secure Link Gateway response.", "status": "complete"}}
]}]
}
@@ -157,12 +157,19 @@ class GatewayFixture:
return web.json_response({"ticket": token, "ttl_seconds": 30})
async def _websocket(self, request: web.Request) -> web.StreamResponse:
ticket = request.query.get("ticket", "")
protocols = [p.strip() for p in request.headers.get("Sec-WebSocket-Protocol", "").split(",")]
protocol_tickets = [p.removeprefix("hermes-gateway-ticket.") for p in protocols
if p.startswith("hermes-gateway-ticket.")]
if protocol_tickets and (len(protocol_tickets) != 1 or "hermes-gateway-v1" not in protocols):
raise web.HTTPUnauthorized(text="ambiguous ticket protocol")
ticket = protocol_tickets[0] if protocol_tickets else request.query.get("ticket", "")
if ticket not in self._tickets:
self.evidence.add("socket", outcome="ticket_rejected")
raise web.HTTPUnauthorized(text="invalid or already-used ticket")
self._tickets.remove(ticket)
socket = web.WebSocketResponse(heartbeat=None)
socket = web.WebSocketResponse(
heartbeat=None, protocols=["hermes-gateway-v1"] if protocol_tickets else [],
)
await socket.prepare(request)
self._sockets.add(socket)
self._connection_sequence += 1
+48 -5
View File
@@ -108,11 +108,54 @@ namespaces when their own credentials are present, or use independent direct or
Tailscale HTTPS fallback routes. Tailscale Serve remains the normal recommended
setup; Secure Link is opt-in.
Enable it with `--secure-link` or `RELAY_SECURE_LINK_ENABLED=1`, then re-pair so
the new QR carries the exact origin and pin. The Relay `/health` response
reports `secure_link.status`; the Secure Link endpoint at
`https://<host>:9443/relay/health` reports its namespaces. A certificate,
hostname, or port change requires another explicit re-pair.
Use **Relay → Remote Access → Hermes Secure Link → Set up Secure Link** in
Dashboard, or the Secure Link setup section in the Desktop Relay pane. The same
read-only checks are available on the server:
```bash
hermes relay secure-link --host relay.example --port 9443
```
Without Hermes CLI discovery, use `python -m plugin.cli secure-link --host
relay.example --port 9443` from the Relay environment. Add `--json` for the shared
readiness report. This is a host-side check, not a command for the separate
Hermes-Relay CLI+UI client.
1. **Check the address and prerequisites.** Use an address reachable from the
phone, not a wildcard or localhost. Checks cover the listener, certificate,
loopback upstreams, Dashboard password/OAuth protection, and connected-client
restart impact. They do not write configuration, generate keys, open firewall
ports, or restart anything.
2. **Apply the reviewed settings.** The flow supplies environment settings or
arguments for your existing Relay startup command. Enable with `--secure-link`
or `RELAY_SECURE_LINK_ENABLED=1`, preserving its other settings, then restart
the service/process that already owns Relay. Do not start a second instance.
Startup flags override environment settings; replace an existing
`--secure-link` or `--no-secure-link` flag when changing the setting.
If an upstream uses a LAN-only bind, configure a working loopback listener
first; merely changing its URL to localhost does not make it reachable.
3. **Check again and pair.** Only an enabled listener at the checked address
unlocks **Create pairing QR**. The server signs a fresh invite containing its
certificate and pin. Alternatively run `hermes pair --png`. Existing devices
must re-pair to import that trust. Desktop's pane can copy the equivalent
signed invite for clients that accept pairing URLs.
4. **Sign in on the client.** Scan the QR in Android, confirm its address and
services, and sign into Dashboard. Reachable transport, authenticated access,
and Gateway Chat readiness are separate states. Generic preview probes do
not bypass self-signed certificate checks: pinned routes are verified by the
paired client after importing the QR.
The Relay `/health` response reports `secure_link.status`; the Secure Link
endpoint at `https://<host>:9443/relay/health` reports its namespaces. An unavailable
optional API does not imply that Dashboard Chat or Standard Voice is unavailable.
A certificate, hostname, or port change requires explicit re-pairing.
To disable, set `RELAY_SECURE_LINK_ENABLED=0` or replace the startup flag with
`--no-secure-link`, then restart the existing Relay owner. Keep its certificate
and key if you plan to reuse the same identity. Failed Secure Link initialization
leaves ordinary Relay available and does not advertise a working Secure Link
route. Restore the previous settings and re-check health before retrying.
Automatic service-manager activation is not part of this guided-command flow.
## Hermes Reach (experimental)
+1
View File
@@ -28,6 +28,7 @@ phone-control or general Relay session routes.
|---|---|---|---|
| `GET` upgrade | `/ws`, `/` | Pairing code or paired session | Multiplexed phone connection |
| `GET` | `/health` | Public probe | Version, readiness and connected-client summary |
| `GET` | `/secure-link/preflight` | Loopback | Read-only setup report for the proposed `host` and `port`: prerequisites, certificate checks, startup settings, and restart impact. Never writes configuration, rotates keys, or restarts services. |
| `POST` | `/pairing` | Rate-limited public setup | Create a relay-side pairing code |
| `POST` | `/pairing/register` | Loopback | Register a host-minted code and policy metadata |
| `POST` | `/pairing/mint` | Loopback | Mint a signed QR payload for dashboard/CLI pairing |