Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f49f08dbe | ||
|
|
e96aa435f2 | ||
|
|
592affca40 | ||
|
|
7e5123b22f | ||
|
|
ad3786fb0b | ||
|
|
8bd67e3363 | ||
|
|
17cf12fff9 | ||
|
|
b55e798c3b | ||
|
|
a128e910f1 | ||
|
|
14500096c6 | ||
|
|
1ea84630d2 | ||
|
|
d98e0b113b | ||
|
|
ea2110fa14 | ||
|
|
43357a387d | ||
|
|
336475e451 | ||
|
|
13492610a8 | ||
|
|
4e75564d33 | ||
|
|
50adab99fa | ||
|
|
dc8e076836 | ||
|
|
b296b56bce | ||
|
|
a74ad0738f | ||
|
|
6bb186ee2b | ||
|
|
db4a6f37c7 | ||
|
|
0f19deae7f | ||
|
|
fa2d17338d | ||
|
|
bc2f2b0010 | ||
|
|
dcc8d54916 | ||
|
|
47e27f6ac9 | ||
|
|
5e53d5cfd1 |
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
+141
@@ -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)
|
||||
|
||||
+11
-13
@@ -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
|
||||
|
||||
+8
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -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>
|
||||
|
||||
@@ -2788,6 +2788,7 @@
|
||||
<string name="model_picker_title">Modell</string>
|
||||
<string name="model_picker_search">Modelle oder Anbieter suchen…</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2796,6 +2796,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>
|
||||
|
||||
@@ -2776,6 +2776,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>
|
||||
<string name="context_sheet_agent_sees">Что видит агент</string>
|
||||
<string name="context_sheet_transparency">Точный дополнительный контекст, добавляемый к вашему следующему ходу, для прозрачности.</string>
|
||||
<string name="context_sheet_persona">Персона / профиль</string>
|
||||
|
||||
@@ -3180,6 +3180,7 @@
|
||||
<string name="model_picker_title">Model</string>
|
||||
<string name="model_picker_search">Search models or providers…</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>
|
||||
|
||||
+29
@@ -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()
|
||||
|
||||
+23
@@ -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)
|
||||
|
||||
+79
@@ -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()
|
||||
|
||||
+32
@@ -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(
|
||||
|
||||
+26
-1
@@ -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"
|
||||
|
||||
+46
@@ -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(
|
||||
|
||||
+20
@@ -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))
|
||||
}
|
||||
}
|
||||
+73
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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+
|
||||
|
||||
Vendored
+7
-6
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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 || {} })
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user