Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a6bf0ce6 | ||
|
|
9e96111813 | ||
|
|
42efc01d58 | ||
|
|
dc93d5eab2 | ||
|
|
cafbadc583 | ||
|
|
607c26660c | ||
|
|
3882b857e3 | ||
|
|
81c186c6ba | ||
|
|
4ad0709fbe | ||
|
|
0324c9f5dd | ||
|
|
f8c31f8b59 | ||
|
|
abb4bc0ced | ||
|
|
07b683fbb5 | ||
|
|
759ac490c9 | ||
|
|
150e375284 | ||
|
|
1f49f08dbe | ||
|
|
e96aa435f2 | ||
|
|
592affca40 | ||
|
|
7e5123b22f | ||
|
|
ad3786fb0b | ||
|
|
8bd67e3363 | ||
|
|
17cf12fff9 | ||
|
|
b55e798c3b | ||
|
|
a128e910f1 | ||
|
|
a7cbf377a0 | ||
|
|
9435d43b04 | ||
|
|
52170f76ea | ||
|
|
9871b0cb7c | ||
|
|
14500096c6 | ||
|
|
1ea84630d2 | ||
|
|
d98e0b113b | ||
|
|
ea2110fa14 | ||
|
|
43357a387d | ||
|
|
336475e451 | ||
|
|
13492610a8 | ||
|
|
4e75564d33 | ||
|
|
50adab99fa | ||
|
|
dc8e076836 | ||
|
|
b296b56bce | ||
|
|
a74ad0738f | ||
|
|
6bb186ee2b | ||
|
|
db4a6f37c7 | ||
|
|
0f19deae7f | ||
|
|
fa2d17338d | ||
|
|
bc2f2b0010 | ||
|
|
dcc8d54916 | ||
|
|
47e27f6ac9 | ||
|
|
5e53d5cfd1 | ||
|
|
b21b9c225c |
@@ -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,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Provider usage shows Grok subscription periods, product usage, and on-demand credit state for hosts signed in with `xai-oauth`. Android shows SuperGrok by default when no provider visibility choice is saved.
|
||||
- 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
|
||||
|
||||
- Desktop tray notices, screenshot evidence, and grant prompts stay reusable after dismissal; screenshot evidence keeps the most recently selected image. (#606)
|
||||
- Proactive phone Thread messages render relay-token and host-path media as attachments while preserving multiline text; notification previews omit media markers. (#485)
|
||||
- Desktop computer screenshots attach validated image bytes to the host tool result instead of returning base64 as plain text.
|
||||
- Relay-owned media uploads are removed on token expiry, eviction, and shutdown; media activity logs omit tokens, file paths, and screenshot bytes.
|
||||
- Plugin screenshot and navigation tools resolve Android's authenticated media token, attach the actual bounded image to host vision, and keep legacy inline screenshots readable. (#593)
|
||||
- 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
|
||||
|
||||
@@ -27,7 +27,7 @@ data class ProviderUsagePreferences(
|
||||
val visibleProviders: Set<String> = DEFAULT_VISIBLE_PROVIDERS,
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go")
|
||||
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go", "supergrok")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-1
@@ -131,7 +131,7 @@ class ProactiveMessageHandler(
|
||||
ProactiveMessageNotifier.notify(
|
||||
context = context,
|
||||
title = msg.title,
|
||||
text = msg.text,
|
||||
text = mediaFreeProactivePreview(msg.text),
|
||||
messageId = msg.messageId,
|
||||
chatId = msg.chatId,
|
||||
)
|
||||
@@ -156,6 +156,30 @@ class ProactiveMessageHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/** Notification text is a preview; the Thread owns attachment rendering. */
|
||||
internal fun mediaFreeProactivePreview(text: String): String {
|
||||
var fence: String? = null
|
||||
val lines = mutableListOf<String>()
|
||||
for (line in text.lines()) {
|
||||
val trimmed = line.trim()
|
||||
val delimiter = when {
|
||||
trimmed.startsWith("```") -> "```"
|
||||
trimmed.startsWith("~~~") -> "~~~"
|
||||
else -> null
|
||||
}
|
||||
if (delimiter != null) {
|
||||
fence = if (fence == delimiter) null else if (fence == null) delimiter else fence
|
||||
}
|
||||
val markerOnly = fence == null && (
|
||||
trimmed.startsWith("MEDIA:hermes-relay://") ||
|
||||
trimmed.startsWith("MEDIA:/") ||
|
||||
Regex("^MEDIA:[A-Za-z]:\\\\").containsMatchIn(trimmed)
|
||||
)
|
||||
if (!markerOnly && trimmed.isNotEmpty()) lines += trimmed
|
||||
}
|
||||
return lines.joinToString(" ").ifBlank { "Attachment" }
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed agent-initiated message. `surfacing` is the optional route hint
|
||||
* (null = app default); Phase 2 keys inbox/session delivery off it.
|
||||
|
||||
@@ -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 {
|
||||
@@ -327,11 +336,11 @@ class RelayHttpClient(
|
||||
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchMedia failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
Log.w(TAG, "fetchMedia failed")
|
||||
Result.failure(if (e is RelayMediaLimitException) e else IOException("Relay media request failed"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchMedia unexpected error: ${e.message}")
|
||||
Result.failure(e)
|
||||
Log.w(TAG, "fetchMedia unexpected error")
|
||||
Result.failure(IOException("Relay media request failed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +416,7 @@ class RelayHttpClient(
|
||||
val reason = when (response.code) {
|
||||
401 -> "Unauthorized — re-pair with the relay"
|
||||
403 -> "Path not allowed by relay sandbox"
|
||||
404 -> "File not found on relay: $path"
|
||||
404 -> "File not found on relay"
|
||||
400 -> "Bad request — missing path"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
@@ -437,15 +446,15 @@ class RelayHttpClient(
|
||||
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchMediaByPath failed for $path: ${e.message}")
|
||||
Log.w(TAG, "fetchMediaByPath failed")
|
||||
if (e is RelayMediaLimitException) {
|
||||
Result.failure(e)
|
||||
} else {
|
||||
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
|
||||
Result.failure(IOException("Relay media request failed"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchMediaByPath unexpected error for $path: ${e.message}")
|
||||
Result.failure(e)
|
||||
Log.w(TAG, "fetchMediaByPath unexpected error")
|
||||
Result.failure(IOException("Relay media request failed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -479,12 +479,15 @@ class ChatHandler {
|
||||
arrivedWhileAway: Boolean = false,
|
||||
) {
|
||||
val id = messageId?.let { "proactive-$it" } ?: "proactive-${java.util.UUID.randomUUID()}"
|
||||
val mediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||
val cleanedText = extractMediaMarkersFromContent(id, text, mediaHits)
|
||||
val visibleText = if (mediaHits.isEmpty()) text else cleanedText
|
||||
_messages.update { list ->
|
||||
if (messageId != null && list.any { it.id == id }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = text,
|
||||
content = visibleText,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
agentName = agentName,
|
||||
badges = if (arrivedWhileAway) listOf("While away") else emptyList(),
|
||||
@@ -492,6 +495,8 @@ class ChatHandler {
|
||||
)
|
||||
(list + msg).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
// The row must exist before the ViewModel attaches a loading card.
|
||||
mediaHits.forEach { (_, hit) -> dispatchMediaHit(id, hit) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1735,34 +1740,7 @@ class ChatHandler {
|
||||
// Now that the reloaded messages are in state, fire callbacks so the
|
||||
// ViewModel can insert LOADING/FAILED attachments via mutateMessage.
|
||||
for ((messageId, hit) in pendingMediaHits) {
|
||||
when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> {
|
||||
val dedupeKey = "$messageId:relay:${hit.token}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
val alreadyHydrated = _messages.value
|
||||
.firstOrNull { it.matchesIdentity(messageId) }
|
||||
?.attachments
|
||||
?.any { it.relayToken == hit.token } == true
|
||||
if (!alreadyHydrated) {
|
||||
Log.d(TAG, "Media marker accepted from reloaded Relay history")
|
||||
onMediaAttachmentRequested(messageId, hit.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MediaMarkerHit.BarePath -> {
|
||||
val dedupeKey = "$messageId:bare:${hit.path}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
val alreadyHydrated = _messages.value
|
||||
.firstOrNull { it.matchesIdentity(messageId) }
|
||||
?.attachments
|
||||
?.any { it.relayToken == hit.path } == true
|
||||
if (!alreadyHydrated) {
|
||||
Log.d(TAG, "Media marker (bare-path, reload): ${hit.path}")
|
||||
onMediaBarePathRequested(messageId, hit.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatchMediaHit(messageId, hit)
|
||||
}
|
||||
for ((messageId, path) in pendingPersistedUserImages) {
|
||||
onPersistedUserImageRequested(messageId, path)
|
||||
@@ -1996,29 +1974,33 @@ class ChatHandler {
|
||||
content: String,
|
||||
out: MutableList<Pair<String, MediaMarkerHit>>,
|
||||
): String {
|
||||
var cleaned = content
|
||||
val visibleLines = mutableListOf<String>()
|
||||
var openFence: String? = null
|
||||
for (rawLine in content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
if (trimmed.isEmpty()) {
|
||||
visibleLines += rawLine
|
||||
continue
|
||||
}
|
||||
val delimiter = fenceDelimiter(rawLine)
|
||||
if (delimiter != null) {
|
||||
openFence = if (openFence == delimiter) null else if (openFence == null) delimiter else openFence
|
||||
visibleLines += rawLine
|
||||
continue
|
||||
}
|
||||
if (openFence != null) {
|
||||
visibleLines += rawLine
|
||||
continue
|
||||
}
|
||||
if (openFence != null) continue
|
||||
|
||||
val hits = parseMediaMarkerLine(trimmed)
|
||||
if (hits.isNotEmpty()) {
|
||||
hits.forEach { out.add(messageId to it) }
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
.replace("$rawLine\n", "")
|
||||
.replace(rawLine, "")
|
||||
} else {
|
||||
visibleLines += rawLine
|
||||
}
|
||||
}
|
||||
return cleaned.trim()
|
||||
return visibleLines.joinToString("\n").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2625,27 +2607,28 @@ class ChatHandler {
|
||||
*/
|
||||
private fun tryDispatchMediaMarker(messageId: String, line: String): Boolean {
|
||||
val hits = parseMediaMarkerLine(line)
|
||||
for (hit in hits) {
|
||||
when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> {
|
||||
val dedupeKey = "$messageId:relay:${hit.token}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker accepted from Relay stream")
|
||||
onMediaAttachmentRequested(messageId, hit.token)
|
||||
}
|
||||
}
|
||||
is MediaMarkerHit.BarePath -> {
|
||||
val dedupeKey = "$messageId:bare:${hit.path}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (bare-path): ${hit.path}")
|
||||
onMediaBarePathRequested(messageId, hit.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.forEach { dispatchMediaHit(messageId, it) }
|
||||
return hits.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun dispatchMediaHit(messageId: String, hit: MediaMarkerHit) {
|
||||
val (key, reference) = when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> "$messageId:relay:${hit.token}" to hit.token
|
||||
is MediaMarkerHit.BarePath -> "$messageId:bare:${hit.path}" to hit.path
|
||||
}
|
||||
if (!dispatchedMediaMarkers.add(key)) return
|
||||
val alreadyHydrated = _messages.value
|
||||
.firstOrNull { it.matchesIdentity(messageId) }
|
||||
?.attachments
|
||||
?.any { it.relayToken == reference } == true
|
||||
if (alreadyHydrated) return
|
||||
Log.d(TAG, "Media marker accepted")
|
||||
when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> onMediaAttachmentRequested(messageId, hit.token)
|
||||
is MediaMarkerHit.BarePath -> onMediaBarePathRequested(messageId, hit.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a matched annotation line from the message's displayed content.
|
||||
* This prevents the raw annotation text (e.g., `💻 terminal`) from showing
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -642,6 +642,7 @@ private fun ProviderUsageDisplaySettings(
|
||||
"openai-codex" to "Codex",
|
||||
"nous" to "Nous",
|
||||
"opencode-go" to "OpenCode Go",
|
||||
"supergrok" to "SuperGrok",
|
||||
)
|
||||
} else {
|
||||
providers.map { it.id to it.displayName }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,11 +44,20 @@ class ProviderUsagePreferencesTest {
|
||||
val preferences = repository.preferences.first()
|
||||
assertEquals(ProviderUsageLandingMode.Summary, preferences.landingMode)
|
||||
assertEquals(
|
||||
setOf("openai-codex", "nous", "opencode-go"),
|
||||
setOf("openai-codex", "nous", "opencode-go", "supergrok"),
|
||||
preferences.visibleProviders,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingInstallWithoutProviderChoiceGetsCurrentDefaults() = runTest {
|
||||
repository.setLandingMode(ProviderUsageLandingMode.Expanded)
|
||||
|
||||
val preferences = repository.preferences.first()
|
||||
assertEquals(ProviderUsageLandingMode.Expanded, preferences.landingMode)
|
||||
assertTrue("supergrok" in preferences.visibleProviders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsDisplayMode() = runTest {
|
||||
repository.setLandingMode(ProviderUsageLandingMode.Expanded)
|
||||
@@ -66,4 +75,13 @@ class ProviderUsagePreferencesTest {
|
||||
assertTrue("openai-codex" in preferences.visibleProviders)
|
||||
assertTrue("opencode-go" in preferences.visibleProviders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsSuperGrokHiddenChoice() = runTest {
|
||||
repository.setProviderVisible("supergrok", false)
|
||||
|
||||
val preferences = repository.preferences.first()
|
||||
assertTrue("nous" in preferences.visibleProviders)
|
||||
assertFalse("supergrok" in preferences.visibleProviders)
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -48,6 +48,15 @@ class ProactiveMessageHandlerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification previews omit media markers while thread text remains complete`() {
|
||||
val text = "Headline\nDetail\nMEDIA:hermes-relay://private-token-123456\nMEDIA:/tmp/report.png"
|
||||
assertEquals("Headline Detail", mediaFreeProactivePreview(text))
|
||||
assertEquals("Attachment", mediaFreeProactivePreview("MEDIA:hermes-relay://private-token-123456"))
|
||||
val fenced = "Example\n```\nMEDIA:/tmp/example.png\n```"
|
||||
assertTrue(mediaFreeProactivePreview(fenced).contains("MEDIA:/tmp/example.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inbox surfacing persists silently`() {
|
||||
val persisted = mutableListOf<ProactiveMessage>()
|
||||
|
||||
+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(
|
||||
|
||||
@@ -182,6 +182,49 @@ class ChatHandlerTest {
|
||||
assertTrue(handler.messages.value.single().content.contains("MEDIA:/tmp/example.pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun proactiveThreadMessage_dispatchesMediaAfterInsertionAndPreservesTextLines() {
|
||||
val tokens = mutableListOf<Pair<String, String>>()
|
||||
val paths = mutableListOf<Pair<String, String>>()
|
||||
handler.onMediaAttachmentRequested = { id, token ->
|
||||
assertTrue(handler.messages.value.any { it.id == id })
|
||||
tokens += id to token
|
||||
}
|
||||
handler.onMediaBarePathRequested = { id, path -> paths += id to path }
|
||||
|
||||
val content = "Headline\nDetail one\n\nMEDIA:hermes-relay://tok123\nDetail two\nMEDIA:/tmp/report.png"
|
||||
handler.addAgentThreadMessage(content, "push-1", "Agent")
|
||||
handler.addAgentThreadMessage(content, "push-1", "Agent")
|
||||
|
||||
assertEquals(1, handler.messages.value.size)
|
||||
assertEquals("Headline\nDetail one\n\nDetail two", handler.messages.value.single().content)
|
||||
assertEquals(listOf("proactive-push-1" to "tok123"), tokens)
|
||||
assertEquals(listOf("proactive-push-1" to "/tmp/report.png"), paths)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun proactiveThreadMessage_keepsFencedAndProseMediaExamples() {
|
||||
val tokens = mutableListOf<String>()
|
||||
handler.onMediaAttachmentRequested = { _, token -> tokens += token }
|
||||
val content = "Intro\n```\nMEDIA:hermes-relay://example123\n```\nExample: MEDIA:hermes-relay://example456"
|
||||
|
||||
handler.addAgentThreadMessage(content, "push-2", null)
|
||||
|
||||
assertEquals(content, handler.messages.value.single().content)
|
||||
assertTrue(tokens.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun proactiveThreadMessage_doesNotRemoveIdenticalMarkerInsideCodeFence() {
|
||||
val tokens = mutableListOf<String>()
|
||||
handler.onMediaAttachmentRequested = { _, token -> tokens += token }
|
||||
val marker = "MEDIA:hermes-relay://same-token-123456"
|
||||
handler.addAgentThreadMessage("Text\n```\n$marker\n```\n$marker", "push-3", null)
|
||||
|
||||
assertEquals("Text\n```\n$marker\n```", handler.messages.value.single().content)
|
||||
assertEquals(listOf("same-token-123456"), tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onTextDelta_setsStreamingFlag() {
|
||||
handler.onTextDelta("assist-1", "delta")
|
||||
|
||||
@@ -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
|
||||
@@ -425,6 +433,8 @@ The server-side plugin (`plugin/tools/desktop_tool.py`) registers `desktop_*` to
|
||||
|
||||
`desktop_computer_status`, `desktop_computer_screenshot`, `desktop_computer_action`, `desktop_computer_grant_request`, and `desktop_computer_cancel` are registered server-side but the desktop client advertises and serves them only when explicitly enabled:
|
||||
|
||||
The screenshot tool attaches a validated PNG or JPEG as a native host image result when the bounded relay response contains image bytes. A `save_to` capture remains a saved-path response. The desktop wire budget still rejects oversized captures rather than placing unbounded image data in a tool result.
|
||||
|
||||
```sh
|
||||
hermes-relay computer-use enable
|
||||
hermes-relay computer-use status
|
||||
|
||||
@@ -329,6 +329,7 @@ async function cuaSnapshot(args: Record<string, unknown>, ctx: ToolContext): Pro
|
||||
elements: safeElements,
|
||||
tree_markdown: raw.tree_markdown,
|
||||
screenshot_base64: raw.screenshot_base64,
|
||||
screenshot_mime_type: raw.screenshot_mime_type,
|
||||
screenshot_width: raw.screenshot_width,
|
||||
screenshot_height: raw.screenshot_height,
|
||||
truncated: elements.length > safeElements.length
|
||||
|
||||
@@ -51,7 +51,9 @@ class FakeCuaSession {
|
||||
return {
|
||||
snapshot_id: `s0000000${this.snapshotNumber}`,
|
||||
elements: [{ element_index: 7, element_token: 'e1234abcd', role: 'button', label: 'Seven' }],
|
||||
tree_markdown: '[7] button Seven'
|
||||
tree_markdown: '[7] button Seven',
|
||||
screenshot_base64: 'aW1hZ2U=',
|
||||
screenshot_mime_type: 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +142,13 @@ test('CUA handlers issue a Hermes token, execute once, and verify with a fresh s
|
||||
ok: boolean
|
||||
backend: string
|
||||
elements: Array<{ snapshot_token: string; element_token?: string }>
|
||||
screenshot_base64: string
|
||||
screenshot_mime_type: string
|
||||
}
|
||||
assert.equal(observed.ok, true)
|
||||
assert.equal(observed.backend, 'cua_driver')
|
||||
assert.equal(observed.screenshot_base64, 'aW1hZ2U=')
|
||||
assert.equal(observed.screenshot_mime_type, 'image/png')
|
||||
assert.equal(observed.elements[0]!.element_token, undefined)
|
||||
assert.match(observed.elements[0]!.snapshot_token, /^hermes-snapshot-/)
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "secondary-windows",
|
||||
"description": "Dismiss the connection notice and screenshot evidence windows",
|
||||
"windows": ["notice", "evidence"],
|
||||
"permissions": ["core:window:allow-hide"]
|
||||
}
|
||||
@@ -2514,10 +2514,14 @@ mod app {
|
||||
label,
|
||||
event: WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} if label == "main" => {
|
||||
} if matches!(label.as_str(), "main" | "grant" | "notice" | "evidence") => {
|
||||
api.prevent_close();
|
||||
if let Some(window) = handle.get_webview_window("main") {
|
||||
request_main_hide(&window);
|
||||
if let Some(window) = handle.get_webview_window(&label) {
|
||||
if label == "main" {
|
||||
request_main_hide(&window);
|
||||
} else {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -340,3 +340,47 @@ fn management_window_keeps_the_reviewed_compact_geometry() {
|
||||
assert!(ui.contains("hide().finally(() => setWindowVisible(true))"));
|
||||
assert!(ui.contains("document.visibilityState === 'visible'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_windows_have_only_the_dismissal_permission() {
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let main: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../capabilities/default.json")).unwrap();
|
||||
let secondary: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../capabilities/secondary-windows.json")).unwrap();
|
||||
|
||||
let configured: std::collections::BTreeSet<&str> = config["app"]["windows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|window| window["label"].as_str().unwrap())
|
||||
.collect();
|
||||
let main_windows: std::collections::BTreeSet<&str> = main["windows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|label| label.as_str().unwrap())
|
||||
.collect();
|
||||
let secondary_windows: std::collections::BTreeSet<&str> = secondary["windows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|label| label.as_str().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(main_windows, ["main", "grant"].into_iter().collect());
|
||||
assert_eq!(
|
||||
secondary_windows,
|
||||
["notice", "evidence"].into_iter().collect()
|
||||
);
|
||||
assert_eq!(
|
||||
configured,
|
||||
main_windows.union(&secondary_windows).copied().collect()
|
||||
);
|
||||
assert!(main_windows.is_disjoint(&secondary_windows));
|
||||
assert_eq!(
|
||||
secondary["permissions"],
|
||||
serde_json::json!(["core:window:allow-hide"])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,16 +286,20 @@ function EvidenceWindow() {
|
||||
const [evidenceId, setEvidenceId] = useState<string | null>(null)
|
||||
const [source, setSource] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const requestGeneration = useRef(0)
|
||||
useEffect(() => {
|
||||
const receive = (event: Event) => {
|
||||
const id = (event as CustomEvent<{ evidenceId: string }>).detail.evidenceId
|
||||
const generation = ++requestGeneration.current
|
||||
setEvidenceId(id); setSource(null); setError(null)
|
||||
void call<string>('get_activity_screenshot', { evidenceId: id }).then(setSource).catch(value => setError(String(value)))
|
||||
void call<string>('get_activity_screenshot', { evidenceId: id })
|
||||
.then(value => { if (generation === requestGeneration.current) setSource(value) })
|
||||
.catch(value => { if (generation === requestGeneration.current) setError(String(value)) })
|
||||
}
|
||||
const close = (event: KeyboardEvent) => { if (event.key === 'Escape') void getCurrentWindow().hide() }
|
||||
window.addEventListener('hermes-screenshot-evidence', receive)
|
||||
window.addEventListener('keydown', close)
|
||||
return () => { window.removeEventListener('hermes-screenshot-evidence', receive); window.removeEventListener('keydown', close) }
|
||||
return () => { requestGeneration.current++; window.removeEventListener('hermes-screenshot-evidence', receive); window.removeEventListener('keydown', close) }
|
||||
}, [])
|
||||
return <div className="evidence-shell">
|
||||
<header><span><Eye /><strong>Screenshot evidence</strong><small>Stored locally with this activity event</small></span><button aria-label="Close screenshot" onClick={() => getCurrentWindow().hide()}><X /></button></header>
|
||||
|
||||
@@ -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. |
|
||||
@@ -378,10 +379,11 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{"ttl_seconds": 3600}`, `{"grants": {"terminal": 600}}`, or both may shorten the caller's current session policy. A bearer cannot target another session, extend its lifetime, add or lengthen grants, or change a finite expiry to never-expire; authority-increasing changes require a fresh operator-approved pairing flow. Omitted grants retain their existing absolute ceilings and are clamped if the parent session is shortened. Returns 200 with the reduced `{expires_at, grants}`; 400 on missing/invalid or unknown grants; 403 on cross-session targets or policy expansion; 404 on prefix miss; 409 on ambiguous prefix. |
|
||||
| `/chat/image-activity` | GET | Optional read-only Standard Gateway compatibility route. Requires a valid Relay bearer with an active `chat` grant and query parameters `profile`, `session_id`, and `since` (Unix seconds). Reads the selected profile's Hermes `state.db` without mutation and returns persisted `image_generate` calls as `running` or `completed`. Android polls only during an active turn, deduplicates against native Gateway tool events, and silently disables the bridge when the route is absent. |
|
||||
| `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. |
|
||||
| `/media/register` | POST | **Loopback only.** Register a file path with the in-memory `MediaRegistry` and receive an opaque token. Used by host-local tools (`android_screenshot` etc.) to make a file fetchable by the paired phone without leaking the filesystem path. Request body: `{"path": "/abs/path", "content_type": "image/jpeg", "file_name": "screenshot.jpg"}`. Response: `{"ok": true, "token": "<url-safe-16>", "expires_at": <unix>}`. Returns 403 for non-loopback callers, 400 on validation failure (relative path, missing file, oversized, outside allowed roots, etc). Path sandboxing is enforced server-side — see ADR 14. |
|
||||
| `/media/upload` | POST | Bearer-auth'd small upload endpoint for phone-originated media. Accepts JSON `{file_name, content_type, content}` where `content` is base64 and registers the decoded bytes with the media registry. |
|
||||
| `/media/register` | POST | **Loopback only.** Register a file path with the in-memory `MediaRegistry` and receive an opaque token. Host-owned files are never deleted by the registry. A relay-managed `android_screenshot_` temp file can opt into cleanup with `owned_file: true`; the registry validates its location and name. Request body: `{"path": "/abs/path", "content_type": "image/png", "file_name": "screenshot.png"}`. Response: `{"ok": true, "token": "<token>", "expires_at": <unix>}`. Returns 403 for non-loopback callers, 400 on validation failure. |
|
||||
| `/media/upload` | POST | Bearer-authenticated multipart upload for phone-originated media (`file` field). Streams to a size-bounded relay-owned temporary file and registers a token. Relay-owned files are removed on token expiry, LRU eviction, or orderly shutdown. |
|
||||
| `/media/{token}/sensitive` | POST | **Loopback only.** Mark an existing token sensitive before the host tool returns its marker. The subsequent media fetch includes `X-Media-Sensitive: 1`; no second image copy is created. Returns 404 for missing or expired tokens. |
|
||||
| `/media/{token}` | GET | Stream the bytes of a previously-registered file. Requires `Authorization: Bearer <session_token>` (same token the WSS channel uses; validated against `SessionManager`). Response has the registered `Content-Type` plus `Content-Disposition: inline; filename="..."` when a file name was provided. Returns 401 without auth or with an invalid bearer, 404 if the token is unknown or expired. The client never sees the underlying path — the token is the only handle. |
|
||||
| `/media/by-path` | GET | Stream the bytes of a file **addressed by absolute path** rather than by registry token. Covers the case where an agent's LLM freeform-emits a `MEDIA:/abs/path.ext` marker in its response text (upstream `hermes-agent/agent/prompt_builder.py` explicitly instructs the model to do this) — no loopback register step is needed. Query parameters: `path` (required, absolute) and `content_type` (optional; otherwise guessed from extension via Python's `mimetypes`). Requires `Authorization: Bearer <session_token>`. Path sandboxing is identical to `/media/register`: must be absolute, must `realpath`-resolve under an allowed root (`tempfile.gettempdir()` + `HERMES_WORKSPACE` + `RELAY_MEDIA_ALLOWED_ROOTS`), must exist, must be a regular file, must fit under `RELAY_MEDIA_MAX_SIZE_MB`. Response carries `Content-Type` and `Content-Disposition: inline; filename="<basename>"`. Error shapes: 400 missing `path`; 401 missing/invalid bearer; 403 outside sandbox / not absolute / too large; 404 file not found or not a regular file. See ADR 14. |
|
||||
| `/media/by-path` | GET | Bearer-authenticated fetch for an absolute-path `MEDIA:/...` marker. Requires a regular file within the size cap and always rejects credential/system paths. Allowed-root enforcement is opt-in through `RELAY_MEDIA_STRICT_SANDBOX=1`; default mode accepts other absolute readable paths. Optional `content_type` overrides extension guessing. Returns 400 for a missing path, 401 for invalid auth, 403 for policy/size failures, and 404 for a missing file. |
|
||||
| `/voice/transcribe` | POST | Bearer-auth'd via either a Relay session token with active `voice:stt` grant or a valid Hermes API bearer token. Non-loopback API-bearer calls require HTTPS unless `RELAY_ALLOW_INSECURE_API_BEARER=1`. `multipart/form-data` with an audio file field (any name — first field is used). Android may include `?profile=<name>` so the active profile context is recorded in the response/UI; execution still goes through the upstream STT helper. |
|
||||
| `/voice/synthesize` | POST | Bearer-auth'd via either a Relay session token with active `voice:tts` grant or a valid Hermes API bearer token. Non-loopback API-bearer calls require HTTPS unless `RELAY_ALLOW_INSECURE_API_BEARER=1`. JSON body `{"text": "...", "profile": "mizu"}` (max 5000 chars) runs the basic fallback TTS helper and serves the resulting mp3. Normal assistant speech prefers `/voice/output/*`. |
|
||||
| `/voice/config` | GET | Bearer-auth'd via either a Relay session token with active `voice:config` grant or a valid Hermes API bearer token. Optional `?profile=<name>` resolves `tts:` / `stt:` from `~/.hermes/profiles/<name>/config.yaml` where present, otherwise falls back. Returns provider info plus `profile`, `config_scope`, and `fallback_to_global`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -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. |
|
||||
@@ -976,7 +980,7 @@ Tools register against the Hermes plugin API in `plugin/tools/android_tool.py` (
|
||||
|------|-----------|---------|--------|
|
||||
| `android_ping` | `GET /ping` | Liveness check — does not require master enable | sideload Device Control |
|
||||
| `android_screen` | `GET /screen` | Serialize the accessibility tree → `ScreenContent` | sideload Device Control |
|
||||
| `android_screenshot` | `GET /screenshot` | `MediaProjection` PNG → `MEDIA:hermes-relay://<token>` | sideload Device Control |
|
||||
| `android_screenshot` | `GET /screenshot`, then authenticated `GET /media/<token>` | `MediaProjection` PNG → bounded native image tool result with the `MEDIA:hermes-relay://<token>` phone-delivery marker; older inline base64 responses remain readable | sideload Device Control |
|
||||
| `android_current_app` | `GET /current_app` | Best-effort foregrounded package name; use `/screen` for verification | sideload Device Control |
|
||||
| `android_get_apps` (`/apps` legacy) | `GET /get_apps` | Installed launcher apps | sideload Device Control |
|
||||
| `android_tap` | `POST /tap` | Tap at `(x, y)` or on resolved `node_id` | sideload Device Control |
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -54,7 +54,10 @@ ADB_RESPONSE_TIMEOUT = 300.0 # seconds — approval plus a bounded 120 s operat
|
||||
|
||||
# Keys whose values must never appear in the ring buffer. Matched
|
||||
# case-insensitively against the full key name.
|
||||
_REDACT_KEYS = frozenset({"password", "token", "secret", "otp", "bearer", "api_key"})
|
||||
_REDACT_KEYS = frozenset({
|
||||
"password", "token", "secret", "otp", "bearer", "api_key",
|
||||
"bytes_base64", "screenshot_base64",
|
||||
})
|
||||
|
||||
# Cap for the recent-commands ring buffer.
|
||||
RECENT_COMMANDS_MAX = 100
|
||||
@@ -648,7 +651,7 @@ class DesktopHandler:
|
||||
payload = envelope.get("payload") or {}
|
||||
request_id = payload.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id:
|
||||
logger.warning("desktop: response missing request_id: %s", payload)
|
||||
logger.warning("desktop: response missing request_id")
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
@@ -693,9 +696,12 @@ class DesktopHandler:
|
||||
record.error = error_msg
|
||||
if result is not None and record.result_summary is None:
|
||||
try:
|
||||
summary = json.dumps(result, default=str)
|
||||
if record.tool == "desktop_computer_screenshot":
|
||||
summary = "Desktop screenshot response received"
|
||||
else:
|
||||
summary = json.dumps(_redact_args(result), default=str)
|
||||
except (TypeError, ValueError):
|
||||
summary = str(result)
|
||||
summary = "Desktop result unavailable for activity summary"
|
||||
if len(summary) > 500:
|
||||
summary = summary[:497] + "..."
|
||||
record.result_summary = summary
|
||||
|
||||
+23
-4
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
@@ -68,7 +69,7 @@ def _post_loopback(
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("POST %s returned non-JSON body: %r", url, raw[:200])
|
||||
logger.warning("Relay POST returned non-JSON body")
|
||||
return None
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as exc:
|
||||
logger.warning("POST %s failed: %s", url, exc)
|
||||
@@ -83,6 +84,7 @@ def register_media(
|
||||
port: int | None = None,
|
||||
timeout: float = 5.0,
|
||||
sensitive: bool = False,
|
||||
owned_file: bool = False,
|
||||
) -> str | None:
|
||||
"""Register ``path`` with the local relay and return an opaque token.
|
||||
|
||||
@@ -104,6 +106,7 @@ def register_media(
|
||||
"content_type": content_type,
|
||||
"file_name": file_name,
|
||||
"sensitive": bool(sensitive),
|
||||
"owned_file": bool(owned_file),
|
||||
}
|
||||
|
||||
data = _post_loopback(host, port, "/media/register", payload, timeout)
|
||||
@@ -111,9 +114,7 @@ def register_media(
|
||||
return None
|
||||
|
||||
if not data.get("ok"):
|
||||
logger.warning(
|
||||
"Relay rejected media registration: %s", data.get("error")
|
||||
)
|
||||
logger.warning("Relay rejected media registration")
|
||||
return None
|
||||
|
||||
token = data.get("token")
|
||||
@@ -122,3 +123,21 @@ def register_media(
|
||||
return None
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def mark_media_sensitive(token: str, port: int | None = None) -> bool:
|
||||
"""Mark an existing relay token private without copying its image bytes."""
|
||||
if not isinstance(token, str) or not re.fullmatch(r"[A-Za-z0-9_-]{16,128}", token):
|
||||
return False
|
||||
relay_port = _default_port() if port is None else port
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{relay_port}/media/{token}/sensitive",
|
||||
data=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5.0) as response:
|
||||
return response.status == 200
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return False
|
||||
|
||||
+67
-16
@@ -226,6 +226,8 @@ class _MediaEntry:
|
||||
# at registration → False (not sensitive). See
|
||||
# docs/plans/2026-06-18-attachment-experience.md §C4.
|
||||
sensitive: bool = False
|
||||
# Only relay-created upload files are deleted when this entry retires.
|
||||
owned_file: bool = False
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
@@ -531,16 +533,16 @@ class MediaRegistry:
|
||||
self.allowed_roots: list[str] = base_roots
|
||||
|
||||
self._entries: "OrderedDict[str, _MediaEntry]" = OrderedDict()
|
||||
self._owned_paths: set[str] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
logger.info(
|
||||
"MediaRegistry initialized (max_entries=%d, ttl=%ds, "
|
||||
"max_size=%d bytes, strict_sandbox=%s, roots=%s)",
|
||||
"max_size=%d bytes, strict_sandbox=%s)",
|
||||
max_entries,
|
||||
ttl_seconds,
|
||||
max_size_bytes,
|
||||
strict_sandbox,
|
||||
self.allowed_roots,
|
||||
)
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────
|
||||
@@ -551,6 +553,7 @@ class MediaRegistry:
|
||||
content_type: str,
|
||||
file_name: str | None = None,
|
||||
sensitive: bool = False,
|
||||
owned_file: bool = False,
|
||||
) -> _MediaEntry:
|
||||
"""Validate ``path`` and register a new media entry.
|
||||
|
||||
@@ -570,6 +573,13 @@ class MediaRegistry:
|
||||
real_path, size = validate_media_path(
|
||||
path, self.allowed_roots, self.max_size_bytes
|
||||
)
|
||||
if owned_file and (
|
||||
os.path.dirname(real_path) != os.path.realpath(tempfile.gettempdir())
|
||||
or not os.path.basename(real_path).startswith(
|
||||
("hermes-relay-upload-", "android_screenshot_")
|
||||
)
|
||||
):
|
||||
raise MediaRegistrationError("managed upload path required")
|
||||
|
||||
token = secrets.token_urlsafe(16)
|
||||
now = time.time()
|
||||
@@ -583,24 +593,22 @@ class MediaRegistry:
|
||||
expires_at=now + self.ttl_seconds,
|
||||
last_accessed=now,
|
||||
sensitive=bool(sensitive),
|
||||
owned_file=owned_file,
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
self._cleanup_locked()
|
||||
self._entries[token] = entry
|
||||
if owned_file:
|
||||
self._owned_paths.add(real_path)
|
||||
self._cleanup_locked()
|
||||
# Evict oldest while over cap
|
||||
while len(self._entries) > self.max_entries:
|
||||
evicted_token, evicted = self._entries.popitem(last=False)
|
||||
logger.info(
|
||||
"MediaRegistry LRU eviction: token=%s... path=%s",
|
||||
evicted_token[:8],
|
||||
evicted.path,
|
||||
)
|
||||
_, evicted = self._entries.popitem(last=False)
|
||||
self._retire_entry(evicted)
|
||||
logger.info("MediaRegistry LRU eviction")
|
||||
|
||||
logger.info(
|
||||
"Registered media token=%s... path=%s size=%d type=%s sensitive=%s",
|
||||
token[:8],
|
||||
real_path,
|
||||
"Registered media size=%d type=%s sensitive=%s",
|
||||
size,
|
||||
content_type,
|
||||
entry.sensitive,
|
||||
@@ -622,9 +630,8 @@ class MediaRegistry:
|
||||
return None
|
||||
if entry.is_expired:
|
||||
del self._entries[token]
|
||||
logger.info(
|
||||
"MediaRegistry expired on read: token=%s...", token[:8]
|
||||
)
|
||||
self._retire_entry(entry)
|
||||
logger.info("MediaRegistry entry expired on read")
|
||||
return None
|
||||
entry.last_accessed = time.time()
|
||||
self._entries.move_to_end(token)
|
||||
@@ -635,6 +642,24 @@ class MediaRegistry:
|
||||
async with self._lock:
|
||||
return self._cleanup_locked()
|
||||
|
||||
async def mark_sensitive(self, token: str) -> bool:
|
||||
"""Increase the sensitivity of an existing token without copying bytes."""
|
||||
async with self._lock:
|
||||
self._cleanup_locked()
|
||||
entry = self._entries.get(token)
|
||||
if entry is None:
|
||||
return False
|
||||
entry.sensitive = True
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release relay-owned upload files on shutdown; leave caller files alone."""
|
||||
async with self._lock:
|
||||
self._entries.clear()
|
||||
for path in list(self._owned_paths):
|
||||
if self._unlink_owned_path(path):
|
||||
self._owned_paths.remove(path)
|
||||
|
||||
async def list_all(
|
||||
self, *, include_expired: bool = False
|
||||
) -> list[dict]:
|
||||
@@ -698,7 +723,33 @@ class MediaRegistry:
|
||||
"""Prune expired entries. Caller must hold ``self._lock``."""
|
||||
expired = [k for k, v in self._entries.items() if v.is_expired]
|
||||
for k in expired:
|
||||
del self._entries[k]
|
||||
self._retire_entry(self._entries.pop(k))
|
||||
# A prior unlink can fail while another reader has the file open.
|
||||
# Retry it on the periodic cleanup sweep after that reader exits.
|
||||
for path in list(self._owned_paths):
|
||||
if not any(entry.path == path for entry in self._entries.values()):
|
||||
if self._unlink_owned_path(path):
|
||||
self._owned_paths.remove(path)
|
||||
if expired:
|
||||
logger.debug("MediaRegistry cleaned up %d expired entries", len(expired))
|
||||
return len(expired)
|
||||
|
||||
def _retire_entry(self, entry: _MediaEntry) -> None:
|
||||
path = entry.path
|
||||
if path not in self._owned_paths:
|
||||
return
|
||||
if any(active.path == path for active in self._entries.values()):
|
||||
return
|
||||
if self._unlink_owned_path(path):
|
||||
self._owned_paths.remove(path)
|
||||
|
||||
@staticmethod
|
||||
def _unlink_owned_path(path: str) -> bool:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError:
|
||||
logger.warning("Could not remove relay-owned media file")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Hermes already owns provider credentials and the canonical account-usage model.
|
||||
Relay reuses that model, adds credential-pool and balance structure for Android,
|
||||
and supplies the missing OpenCode Go adapter. Provider keys remain host-side
|
||||
and are never serialized into the response.
|
||||
and supplies the missing OpenCode Go and Grok subscription adapters. Provider
|
||||
keys remain host-side and are never serialized into the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,9 +24,18 @@ RELAY_CAPABILITIES = (
|
||||
"credential_pools",
|
||||
"structured_balances",
|
||||
"opencode_go",
|
||||
"supergrok",
|
||||
)
|
||||
_OPENCODE_GO_DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1"
|
||||
_OPENCODE_GO_USER_AGENT = "curl/8.4.0"
|
||||
# Grok subscription usage is only served by xAI's CLI proxy, not the public API.
|
||||
_SUPERGROK_IDENTITY_URL = "https://cli-chat-proxy.grok.com/v1/user"
|
||||
_SUPERGROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"
|
||||
_SUPERGROK_PERIOD_TYPE_PREFIX = "USAGE_PERIOD_TYPE_"
|
||||
_SUPERGROK_MAX_PRODUCT_WINDOWS = 8
|
||||
_SUPERGROK_CENTS_PER_USD = 100.0
|
||||
_SUPERGROK_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||
_SUPERGROK_SLUG = re.compile(r"[^a-z0-9]+")
|
||||
_MAX_DETAIL_LENGTH = 240
|
||||
_PROFILE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
@@ -505,6 +514,242 @@ async def fetch_opencode_go_usage(
|
||||
}
|
||||
|
||||
|
||||
def _supergrok_percent(value: Any) -> float | None:
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
return None
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return None
|
||||
return max(0.0, min(100.0, number))
|
||||
|
||||
|
||||
def _supergrok_cents(value: Any) -> float | None:
|
||||
"""Unwrap xAI's ``{"val": <int cents>}`` money wrapper."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
cents = value.get("val")
|
||||
if not isinstance(cents, (int, float)) or isinstance(cents, bool):
|
||||
return None
|
||||
amount = float(cents)
|
||||
if not math.isfinite(amount) or amount < 0:
|
||||
return None
|
||||
return amount
|
||||
|
||||
|
||||
def _supergrok_period_label(period_type: Any) -> str:
|
||||
raw = str(period_type or "").strip()
|
||||
if raw.startswith(_SUPERGROK_PERIOD_TYPE_PREFIX):
|
||||
raw = raw[len(_SUPERGROK_PERIOD_TYPE_PREFIX) :]
|
||||
return raw.replace("_", " ").strip().title() or "Current period"
|
||||
|
||||
|
||||
def _supergrok_product_label(product: Any) -> str | None:
|
||||
raw = _bounded_text(product, 60)
|
||||
if raw is None:
|
||||
return None
|
||||
return _SUPERGROK_CAMEL_BOUNDARY.sub(" ", raw).strip() or raw
|
||||
|
||||
|
||||
def _supergrok_windows(config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
period = config.get("currentPeriod")
|
||||
period = period if isinstance(period, dict) else {}
|
||||
reset_at = _iso(period.get("end")) or _iso(config.get("billingPeriodEnd"))
|
||||
|
||||
percent = _supergrok_percent(config.get("creditUsagePercent"))
|
||||
if percent is None:
|
||||
used = _supergrok_cents(config.get("used"))
|
||||
limit = _supergrok_cents(config.get("monthlyLimit"))
|
||||
if used is not None and limit is not None and limit > 0:
|
||||
percent = max(0.0, min(100.0, (used / limit) * 100))
|
||||
|
||||
windows: list[dict[str, Any]] = []
|
||||
if percent is not None:
|
||||
windows.append(
|
||||
{
|
||||
"id": "period",
|
||||
"label": _supergrok_period_label(period.get("type")),
|
||||
"used_percent": percent,
|
||||
"reset_at": reset_at,
|
||||
"detail": None,
|
||||
}
|
||||
)
|
||||
elif reset_at:
|
||||
# A period with no recorded usage yet omits the figure entirely rather
|
||||
# than reporting zero. The window is real, so surface it without
|
||||
# inventing a percentage for it.
|
||||
windows.append(
|
||||
{
|
||||
"id": "period",
|
||||
"label": _supergrok_period_label(period.get("type")),
|
||||
"used_percent": None,
|
||||
"reset_at": reset_at,
|
||||
"detail": "No usage reported yet",
|
||||
}
|
||||
)
|
||||
|
||||
products = config.get("productUsage")
|
||||
if isinstance(products, list):
|
||||
for item in products[:_SUPERGROK_MAX_PRODUCT_WINDOWS]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
product_percent = _supergrok_percent(item.get("usagePercent"))
|
||||
label = _supergrok_product_label(item.get("product"))
|
||||
if product_percent is None or label is None:
|
||||
continue
|
||||
windows.append(
|
||||
{
|
||||
"id": f"product_{_SUPERGROK_SLUG.sub('_', label.lower()).strip('_')[:32]}",
|
||||
"label": label,
|
||||
"used_percent": product_percent,
|
||||
"reset_at": reset_at,
|
||||
"detail": None,
|
||||
}
|
||||
)
|
||||
return windows
|
||||
|
||||
|
||||
def _supergrok_details(config: dict[str, Any], payload: dict[str, Any]) -> list[str]:
|
||||
details: list[str] = []
|
||||
cap = _supergrok_cents(config.get("onDemandCap"))
|
||||
used = _supergrok_cents(config.get("onDemandUsed"))
|
||||
enabled = payload.get("onDemandEnabled") is True or config.get("onDemandEnabled") is True
|
||||
if enabled or (cap or 0) > 0 or (used or 0) > 0:
|
||||
parts = [
|
||||
part
|
||||
for part in (
|
||||
f"${used / _SUPERGROK_CENTS_PER_USD:.2f} used" if used is not None else None,
|
||||
f"of ${cap / _SUPERGROK_CENTS_PER_USD:.2f}" if cap is not None else None,
|
||||
)
|
||||
if part is not None
|
||||
]
|
||||
if parts:
|
||||
details.append(f"On-demand: {' '.join(parts)}")
|
||||
elif enabled:
|
||||
details.append("On-demand enabled")
|
||||
prepaid = _supergrok_cents(config.get("prepaidBalance"))
|
||||
if prepaid:
|
||||
details.append(f"Prepaid balance: ${prepaid / _SUPERGROK_CENTS_PER_USD:.2f}")
|
||||
return details
|
||||
|
||||
|
||||
async def fetch_supergrok_usage(
|
||||
*,
|
||||
profile_home: Path | None = None,
|
||||
session_factory: Callable[[], Any] = aiohttp.ClientSession,
|
||||
credential_resolver: Callable[..., Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch Grok subscription usage behind the ``xai-oauth`` sign-in.
|
||||
|
||||
Subscription windows are only served by xAI's CLI proxy, so this adapter
|
||||
speaks the pinned ``cli-chat-proxy.grok.com`` contract: the
|
||||
Hermes-managed ``xai-oauth`` bearer reads the account identity, then the
|
||||
credits billing snapshot scoped to that identity.
|
||||
"""
|
||||
home_token = _set_home(profile_home)
|
||||
try:
|
||||
if credential_resolver is None:
|
||||
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
|
||||
|
||||
credential_resolver = resolve_xai_oauth_runtime_credentials
|
||||
|
||||
credentials = await asyncio.to_thread(credential_resolver)
|
||||
except Exception as exc:
|
||||
if getattr(exc, "code", None) == "xai_auth_missing":
|
||||
return unavailable_provider("supergrok", "SuperGrok")
|
||||
return unavailable_provider(
|
||||
"supergrok",
|
||||
"SuperGrok",
|
||||
status="unavailable",
|
||||
message="Could not resolve SuperGrok credentials",
|
||||
)
|
||||
finally:
|
||||
_reset_home(home_token)
|
||||
|
||||
access_token = str((credentials or {}).get("api_key") or "").strip()
|
||||
if not access_token:
|
||||
return unavailable_provider("supergrok", "SuperGrok")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
async with session.get(
|
||||
_SUPERGROK_IDENTITY_URL,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return unavailable_provider(
|
||||
"supergrok",
|
||||
"SuperGrok",
|
||||
status="unavailable",
|
||||
message=f"Provider returned HTTP {response.status}",
|
||||
)
|
||||
identity = await response.json()
|
||||
user_id = (
|
||||
str(identity.get("userId") or "").strip()
|
||||
if isinstance(identity, dict)
|
||||
else ""
|
||||
)
|
||||
if not user_id:
|
||||
return unavailable_provider(
|
||||
"supergrok",
|
||||
"SuperGrok",
|
||||
status="unavailable",
|
||||
message="Provider returned no account identity",
|
||||
)
|
||||
async with session.get(
|
||||
_SUPERGROK_BILLING_URL,
|
||||
headers={**headers, "x-userid": user_id},
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return unavailable_provider(
|
||||
"supergrok",
|
||||
"SuperGrok",
|
||||
status="unavailable",
|
||||
message=f"Provider returned HTTP {response.status}",
|
||||
)
|
||||
payload = await response.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, TypeError):
|
||||
return unavailable_provider(
|
||||
"supergrok",
|
||||
"SuperGrok",
|
||||
status="unavailable",
|
||||
message="Could not load SuperGrok usage",
|
||||
)
|
||||
|
||||
config = payload.get("config") if isinstance(payload, dict) else None
|
||||
if not isinstance(config, dict):
|
||||
return unavailable_provider(
|
||||
"supergrok",
|
||||
"SuperGrok",
|
||||
status="unavailable",
|
||||
message="Provider returned an unsupported usage payload",
|
||||
)
|
||||
windows = _supergrok_windows(config)
|
||||
if not windows:
|
||||
return unavailable_provider(
|
||||
"supergrok",
|
||||
"SuperGrok",
|
||||
status="unavailable",
|
||||
message="Provider returned no usage windows",
|
||||
)
|
||||
return {
|
||||
"id": "supergrok",
|
||||
"display_name": "SuperGrok",
|
||||
"status": "available",
|
||||
"source": "provider_api",
|
||||
"fetched_at": _now_iso(),
|
||||
"plan": _bounded_text(payload.get("subscriptionTier"), 80),
|
||||
"windows": windows,
|
||||
"details": _supergrok_details(config, payload),
|
||||
"message": None,
|
||||
}
|
||||
|
||||
|
||||
async def collect_provider_usage(
|
||||
*,
|
||||
profile_home: Path | None = None,
|
||||
@@ -513,6 +758,7 @@ async def collect_provider_usage(
|
||||
codex_fetcher: Callable[..., Awaitable[dict[str, Any]]] = fetch_codex_usage,
|
||||
nous_fetcher: Callable[[Path | None], Awaitable[dict[str, Any]]] = fetch_nous_usage,
|
||||
opencode_fetcher: Callable[..., Awaitable[dict[str, Any]]] = fetch_opencode_go_usage,
|
||||
supergrok_fetcher: Callable[..., Awaitable[dict[str, Any]]] = fetch_supergrok_usage,
|
||||
) -> dict[str, Any]:
|
||||
providers = await asyncio.gather(
|
||||
codex_fetcher(
|
||||
@@ -522,6 +768,7 @@ async def collect_provider_usage(
|
||||
),
|
||||
nous_fetcher(profile_home),
|
||||
opencode_fetcher(profile_home=profile_home),
|
||||
supergrok_fetcher(profile_home=profile_home),
|
||||
)
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+75
-41
@@ -31,6 +31,7 @@ import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
@@ -317,6 +318,7 @@ class RelayServer:
|
||||
await self.desktop.close()
|
||||
await self.tui.close()
|
||||
await self.proactive.close()
|
||||
await self.media.close()
|
||||
|
||||
# Close all WebSocket connections
|
||||
for ws in list(self._clients):
|
||||
@@ -385,6 +387,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.
|
||||
|
||||
@@ -1746,6 +1759,7 @@ async def handle_media_register(request: web.Request) -> web.Response:
|
||||
# Model-emitted sensitivity hint — transported verbatim. Absent/falsey
|
||||
# → not sensitive (back-compat with older callers that never send it).
|
||||
sensitive = _coerce_sensitive(payload.get("sensitive"))
|
||||
owned_file = payload.get("owned_file") is True
|
||||
|
||||
server: RelayServer = request.app["server"]
|
||||
try:
|
||||
@@ -1754,9 +1768,10 @@ async def handle_media_register(request: web.Request) -> web.Response:
|
||||
content_type=content_type,
|
||||
file_name=file_name,
|
||||
sensitive=sensitive,
|
||||
owned_file=owned_file,
|
||||
)
|
||||
except MediaRegistrationError as exc:
|
||||
logger.info("Media registration rejected: %s", exc)
|
||||
logger.info("Media registration rejected")
|
||||
return web.json_response(
|
||||
{"ok": False, "error": str(exc)}, status=400
|
||||
)
|
||||
@@ -1890,23 +1905,22 @@ async def handle_media_upload(request: web.Request) -> web.Response:
|
||||
path=tmp.name,
|
||||
content_type=file_content_type,
|
||||
file_name=base_name,
|
||||
owned_file=True,
|
||||
)
|
||||
except MediaRegistrationError as exc:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("Media upload registration rejected: %s", exc)
|
||||
logger.info("Media upload registration rejected")
|
||||
return web.json_response(
|
||||
{"ok": False, "error": str(exc)}, status=400
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Media uploaded: token=%s... bytes=%d type=%s file=%s",
|
||||
entry.token[:8],
|
||||
"Media uploaded: bytes=%d type=%s",
|
||||
bytes_written,
|
||||
file_content_type,
|
||||
base_name,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1977,17 +1991,22 @@ async def handle_media_get(request: web.Request) -> web.StreamResponse:
|
||||
else:
|
||||
headers["Content-Disposition"] = "inline"
|
||||
|
||||
logger.debug(
|
||||
"Serving media token=%s... path=%s size=%d sensitive=%s to session=%s...",
|
||||
token[:8],
|
||||
entry.path,
|
||||
entry.size,
|
||||
entry.sensitive,
|
||||
bearer[:8],
|
||||
)
|
||||
logger.debug("Serving media size=%d sensitive=%s", entry.size, entry.sensitive)
|
||||
return web.FileResponse(entry.path, headers=headers)
|
||||
|
||||
|
||||
async def handle_media_mark_sensitive(request: web.Request) -> web.Response:
|
||||
"""Allow a host-local screenshot tool to add the private-media hint."""
|
||||
_require_loopback(request)
|
||||
server: RelayServer = request.app["server"]
|
||||
token = request.match_info["token"]
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{16,128}", token):
|
||||
raise web.HTTPNotFound()
|
||||
if not await server.media.mark_sensitive(token):
|
||||
raise web.HTTPNotFound()
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def handle_media_by_path(request: web.Request) -> web.StreamResponse:
|
||||
"""Serve a media file by absolute path for LLM-emitted ``MEDIA:/abs/path`` markers.
|
||||
|
||||
@@ -2087,12 +2106,12 @@ async def handle_media_by_path(request: web.Request) -> web.StreamResponse:
|
||||
# retrying) from sandbox violations (phone should mark FAILED with
|
||||
# a different error). Both are non-retryable.
|
||||
if "does not exist" in msg or "not a regular file" in msg:
|
||||
logger.info("Media by-path: not found — %s", msg)
|
||||
logger.info("Media by-path: not found")
|
||||
raise web.HTTPNotFound(text=msg)
|
||||
# Everything else — not absolute, outside allowed root, too large,
|
||||
# bad stat — is a sandbox or policy violation. 403 signals this
|
||||
# distinctly from 401 (bad auth) and 400 (malformed request).
|
||||
logger.info("Media by-path: sandbox violation — %s", msg)
|
||||
logger.info("Media by-path: sandbox violation")
|
||||
raise web.HTTPForbidden(text=msg)
|
||||
|
||||
# Content type: honor phone-provided hint if given; otherwise guess.
|
||||
@@ -2119,12 +2138,10 @@ async def handle_media_by_path(request: web.Request) -> web.StreamResponse:
|
||||
headers["X-Media-Sensitive"] = "1"
|
||||
|
||||
logger.debug(
|
||||
"Serving media by-path %s size=%d type=%s sensitive=%s to session=%s...",
|
||||
real_path,
|
||||
"Serving media by-path size=%d type=%s sensitive=%s",
|
||||
size,
|
||||
content_type,
|
||||
sensitive,
|
||||
bearer[:8],
|
||||
)
|
||||
return web.FileResponse(real_path, headers=headers)
|
||||
|
||||
@@ -4252,7 +4269,7 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
|
||||
|
||||
server._clients[ws] = session_token
|
||||
server._client_tasks[ws] = set()
|
||||
logger.info("Client authenticated from %s (token=%s...)", remote_ip, session_token[:8])
|
||||
logger.info("Client authenticated from %s", remote_ip)
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
@@ -4878,6 +4895,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)
|
||||
@@ -4914,6 +4932,7 @@ def create_app(config: RelayConfig) -> web.Application:
|
||||
app.router.add_post("/media/register", handle_media_register)
|
||||
# === PHASE3-bridge-server-followup: /media/upload ===
|
||||
app.router.add_post("/media/upload", handle_media_upload)
|
||||
app.router.add_post("/media/{token}/sensitive", handle_media_mark_sensitive)
|
||||
# === END PHASE3-bridge-server-followup ===
|
||||
# Order matters: the fixed-path "/media/by-path" route must be declared
|
||||
# before the wildcard "/media/{token}" route or aiohttp will swallow
|
||||
@@ -5172,17 +5191,29 @@ async def _on_app_startup(app: web.Application) -> None:
|
||||
_profile_rescan_loop(app), name="profile-rescan-loop"
|
||||
)
|
||||
app["_profile_rescan_task"] = task
|
||||
app["_media_cleanup_task"] = asyncio.create_task(
|
||||
_media_cleanup_loop(app), name="media-cleanup-loop"
|
||||
)
|
||||
|
||||
|
||||
async def _media_cleanup_loop(app: web.Application) -> None:
|
||||
"""Retire expired relay-owned uploads even when no media is requested."""
|
||||
server: RelayServer = app["server"]
|
||||
while True:
|
||||
await asyncio.sleep(300)
|
||||
await server.media.cleanup()
|
||||
|
||||
|
||||
async def _on_app_cleanup(app: web.Application) -> None:
|
||||
"""Cancel background tasks cleanly."""
|
||||
task = app.get("_profile_rescan_task")
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
for key in ("_profile_rescan_task", "_media_cleanup_task"):
|
||||
task = app.get(key)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _on_app_shutdown(app: web.Application) -> None:
|
||||
@@ -5197,23 +5228,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
|
||||
@@ -5451,5 +5484,6 @@ def main() -> None:
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
ssl_context=ssl_ctx,
|
||||
access_log=None, # Media URLs carry private tokens or absolute paths.
|
||||
print=None, # Suppress aiohttp's default startup banner
|
||||
)
|
||||
|
||||
@@ -282,6 +282,27 @@ class TestSharedBridgeTransport(unittest.TestCase):
|
||||
response.raise_for_status.assert_called_once_with()
|
||||
|
||||
|
||||
class TestNavigateScreenshot(unittest.TestCase):
|
||||
def test_token_response_writes_fetched_png_for_vision(self) -> None:
|
||||
png = b"\x89PNG\r\n\x1a\nvision-bytes"
|
||||
with mock.patch.object(nav, "_get", return_value={
|
||||
"media": "MEDIA:hermes-relay://navigate-token-123456"
|
||||
}), mock.patch.object(nav, "_bridge_request") as request:
|
||||
response = request.return_value
|
||||
response.status_code = 200
|
||||
response.headers = {"Content-Type": "image/png"}
|
||||
response.iter_content.return_value = iter([png])
|
||||
shot = nav._capture_screenshot()
|
||||
try:
|
||||
self.assertEqual(Path(shot.local_path).read_bytes(), png)
|
||||
self.assertEqual(shot.token, "hermes-relay://navigate-token-123456")
|
||||
request.assert_called_once_with(
|
||||
"GET", "/media/navigate-token-123456", timeout=nav._timeout(), stream=True
|
||||
)
|
||||
finally:
|
||||
Path(shot.local_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestNavigateLoop(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# Belt-and-suspenders: make sure the stub env var never leaks
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Screenshot token retrieval and legacy payload safety checks."""
|
||||
|
||||
import base64
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from plugin.tools.android_screenshot_media import (
|
||||
MAX_SCREENSHOT_BYTES,
|
||||
ScreenshotMediaError,
|
||||
resolve_screenshot,
|
||||
)
|
||||
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\nimage-bytes"
|
||||
TOKEN = "MEDIA:hermes-relay://valid-token-123456"
|
||||
|
||||
|
||||
def response(status=200, body=PNG, content_type="image/png", length=None):
|
||||
value = mock.Mock(spec=requests.Response)
|
||||
value.status_code = status
|
||||
value.headers = {"Content-Type": content_type}
|
||||
if length is not None:
|
||||
value.headers["Content-Length"] = str(length)
|
||||
value.iter_content.return_value = iter([body])
|
||||
return value
|
||||
|
||||
|
||||
def test_token_bytes_and_authenticated_route():
|
||||
fetched = response()
|
||||
request = mock.Mock(return_value=fetched)
|
||||
assert resolve_screenshot({"data": {"media": TOKEN}}, request, 3) == (PNG, "image/png", TOKEN)
|
||||
request.assert_called_once_with("GET", "/media/valid-token-123456", timeout=3, stream=True)
|
||||
fetched.close.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 404, 500])
|
||||
def test_fetch_failure_is_safe(status):
|
||||
fetched = response(status=status)
|
||||
with pytest.raises(ScreenshotMediaError, match="unavailable or access denied") as error:
|
||||
resolve_screenshot({"media": TOKEN}, mock.Mock(return_value=fetched), 3)
|
||||
assert "valid-token" not in str(error.value)
|
||||
fetched.close.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("marker", ["", "MEDIA:hermes-relay://../x", "MEDIA:https://other/x", 5])
|
||||
def test_invalid_marker_does_not_fetch(marker):
|
||||
request = mock.Mock()
|
||||
with pytest.raises(ScreenshotMediaError, match="Invalid screenshot media token"):
|
||||
resolve_screenshot({"media": marker}, request, 3)
|
||||
request.assert_not_called()
|
||||
|
||||
|
||||
def test_stream_cap_and_type_check():
|
||||
fetched = response(body=PNG, length=MAX_SCREENSHOT_BYTES + 1)
|
||||
with pytest.raises(ScreenshotMediaError, match="size limit"):
|
||||
resolve_screenshot({"media": TOKEN}, mock.Mock(return_value=fetched), 3)
|
||||
fetched = response(body=PNG, content_type="image/jpeg")
|
||||
with pytest.raises(ScreenshotMediaError, match="type mismatch"):
|
||||
resolve_screenshot({"media": TOKEN}, mock.Mock(return_value=fetched), 3)
|
||||
fetched = response(body=b"x" * (MAX_SCREENSHOT_BYTES + 1))
|
||||
with pytest.raises(ScreenshotMediaError, match="size limit"):
|
||||
resolve_screenshot({"media": TOKEN}, mock.Mock(return_value=fetched), 3)
|
||||
|
||||
|
||||
def test_legacy_inline_is_bounded_and_validated():
|
||||
request = mock.Mock()
|
||||
assert resolve_screenshot({"image": base64.b64encode(PNG).decode()}, request, 3) == (PNG, "image/png", None)
|
||||
request.assert_not_called()
|
||||
with pytest.raises(ScreenshotMediaError, match="Invalid screenshot image data"):
|
||||
resolve_screenshot({"image": "%%%"}, request, 3)
|
||||
with pytest.raises(ScreenshotMediaError, match="size limit"):
|
||||
resolve_screenshot({"image": "A" * (MAX_SCREENSHOT_BYTES * 2)}, request, 3)
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -241,16 +242,70 @@ class TestPressKey:
|
||||
|
||||
class TestScreenshot:
|
||||
@responses.activate
|
||||
def test_screenshot(self, bridge_url):
|
||||
def test_screenshot_token_delivers_png_bytes(self, bridge_url):
|
||||
png = b"\x89PNG\r\n\x1a\nreal-image-bytes"
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{bridge_url}/screenshot",
|
||||
json={"image": "aGVsbG8=", "width": 1080, "height": 1920},
|
||||
json={"media": "MEDIA:hermes-relay://shot-token-123456"},
|
||||
)
|
||||
with mock.patch("plugin.relay.client.register_media", return_value="shot-token"):
|
||||
result = android_screenshot()
|
||||
assert "Screenshot captured (1080x1920)" in result
|
||||
assert "MEDIA:hermes-relay://shot-token" in result
|
||||
responses.add(
|
||||
responses.GET, f"{bridge_url}/media/shot-token-123456",
|
||||
body=png, content_type="image/png",
|
||||
)
|
||||
result = android_screenshot()
|
||||
assert result["_multimodal"] is True
|
||||
assert "MEDIA:hermes-relay://shot-token-123456" in result["text_summary"]
|
||||
assert result["content"][1]["image_url"]["url"] == (
|
||||
"data:image/png;base64," + base64.b64encode(png).decode("ascii")
|
||||
)
|
||||
via_registry = _HANDLERS["android_screenshot"]({})
|
||||
assert via_registry["_multimodal"] is True
|
||||
|
||||
@responses.activate
|
||||
def test_screenshot_legacy_inline(self, bridge_url):
|
||||
png = b"\x89PNG\r\n\x1a\nold-image"
|
||||
responses.add(responses.GET, f"{bridge_url}/screenshot",
|
||||
json={"data": {"image": base64.b64encode(png).decode("ascii")}})
|
||||
result = android_screenshot()
|
||||
assert result["content"][1]["image_url"]["url"].endswith(
|
||||
base64.b64encode(png).decode("ascii")
|
||||
)
|
||||
|
||||
@responses.activate
|
||||
def test_sensitive_screenshot_registers_private_media(self, bridge_url):
|
||||
png = b"\x89PNG\r\n\x1a\nsensitive"
|
||||
responses.add(responses.GET, f"{bridge_url}/screenshot",
|
||||
json={"media": "MEDIA:hermes-relay://old-token-123456"})
|
||||
responses.add(responses.GET, f"{bridge_url}/media/old-token-123456",
|
||||
body=png, content_type="image/png")
|
||||
with mock.patch("plugin.relay.client.mark_media_sensitive", return_value=True) as mark:
|
||||
result = android_screenshot(sensitive=True)
|
||||
mark.assert_called_once_with("old-token-123456")
|
||||
assert "MEDIA:hermes-relay://old-token-123456" in result["text_summary"]
|
||||
|
||||
@responses.activate
|
||||
def test_sensitive_legacy_screenshot_registers_managed_file(self, bridge_url):
|
||||
png = b"\x89PNG\r\n\x1a\nlegacy-private"
|
||||
responses.add(responses.GET, f"{bridge_url}/screenshot",
|
||||
json={"image": base64.b64encode(png).decode("ascii")})
|
||||
with mock.patch("plugin.relay.client.register_media", return_value="private-token") as register:
|
||||
result = android_screenshot(sensitive=True)
|
||||
path = register.call_args.args[0]
|
||||
try:
|
||||
assert Path(path).read_bytes() == png
|
||||
assert register.call_args.kwargs["sensitive"] is True
|
||||
assert register.call_args.kwargs["owned_file"] is True
|
||||
assert "MEDIA:hermes-relay://private-token" in result["text_summary"]
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
@responses.activate
|
||||
def test_screenshot_rejects_invalid_token(self, bridge_url):
|
||||
responses.add(responses.GET, f"{bridge_url}/screenshot",
|
||||
json={"media": "MEDIA:hermes-relay://../other"})
|
||||
assert android_screenshot() == {"error": "Screenshot unavailable"}
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
|
||||
class TestScroll:
|
||||
|
||||
@@ -7,9 +7,13 @@ smoke path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import unittest
|
||||
import zlib
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from plugin.tools import desktop_tool
|
||||
|
||||
@@ -23,6 +27,17 @@ COMPUTER_TOOLS = [
|
||||
]
|
||||
|
||||
|
||||
def one_pixel_png() -> bytes:
|
||||
def chunk(kind: bytes, data: bytes) -> bytes:
|
||||
return (struct.pack(">I", len(data)) + kind + data +
|
||||
struct.pack(">I", zlib.crc32(kind + data)))
|
||||
|
||||
header = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0)
|
||||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
|
||||
chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00\xff")) +
|
||||
chunk(b"IEND", b""))
|
||||
|
||||
|
||||
class DesktopComputerUseToolTests(unittest.TestCase):
|
||||
def test_all_computer_tools_have_schema_and_handler(self) -> None:
|
||||
for name in COMPUTER_TOOLS:
|
||||
@@ -114,6 +129,50 @@ class DesktopComputerUseToolTests(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_screenshot_attaches_actual_png_for_host_vision(self) -> None:
|
||||
png = one_pixel_png()
|
||||
encoded = base64.b64encode(png).decode("ascii")
|
||||
result = {"ok": True, "request_id": "request-1", "result": {
|
||||
"ok": True, "bytes_base64": encoded, "size_bytes": len(png),
|
||||
"display": {"width": 800, "height": 600},
|
||||
}}
|
||||
with mock.patch.object(desktop_tool, "_post", return_value=result):
|
||||
output = desktop_tool._HANDLERS["desktop_computer_screenshot"]({})
|
||||
self.assertTrue(output["_multimodal"])
|
||||
self.assertEqual(output["content"][1]["image_url"]["url"],
|
||||
f"data:image/png;base64,{encoded}")
|
||||
self.assertNotIn(encoded, output["content"][0]["text"])
|
||||
self.assertIn('"width": 800', output["content"][0]["text"])
|
||||
self.assertIn('"request_id": "request-1"', output["content"][0]["text"])
|
||||
|
||||
def test_screenshot_keeps_saved_path_and_rejects_bad_bytes(self) -> None:
|
||||
with mock.patch.object(desktop_tool, "_post", return_value={
|
||||
"ok": True, "saved_path": "/tmp/shot.png", "size_bytes": 10,
|
||||
}):
|
||||
self.assertEqual(json.loads(desktop_tool.desktop_computer_screenshot())["saved_path"],
|
||||
"/tmp/shot.png")
|
||||
with mock.patch.object(desktop_tool, "_post", return_value={
|
||||
"ok": True, "bytes_base64": "%%%",
|
||||
}):
|
||||
self.assertIn("error", json.loads(desktop_tool.desktop_computer_screenshot()))
|
||||
|
||||
def test_cua_screenshot_preserves_element_metadata_with_image(self) -> None:
|
||||
jpeg = b"\xff\xd8\xff\xe0private-window"
|
||||
encoded = base64.b64encode(jpeg).decode("ascii")
|
||||
with mock.patch.object(desktop_tool, "_post", return_value={
|
||||
"ok": True, "result": {
|
||||
"backend": "cua_driver",
|
||||
"elements": [{"snapshot_token": "one-use-token"}],
|
||||
"screenshot_base64": encoded,
|
||||
"screenshot_mime_type": "image/jpeg",
|
||||
},
|
||||
}):
|
||||
output = desktop_tool.desktop_computer_screenshot(pid=1, window_id=2)
|
||||
self.assertEqual(output["content"][1]["image_url"]["url"],
|
||||
f"data:image/jpeg;base64,{encoded}")
|
||||
self.assertIn("one-use-token", output["content"][0]["text"])
|
||||
self.assertNotIn(encoded, output["content"][0]["text"])
|
||||
|
||||
def test_grant_request_schema_only_requires_mode(self) -> None:
|
||||
schema = desktop_tool._SCHEMAS["desktop_computer_grant_request"]
|
||||
self.assertEqual(schema["parameters"]["required"], ["mode"])
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from plugin.relay.channels.desktop import (
|
||||
DesktopCommandRecord,
|
||||
DesktopError,
|
||||
DesktopHandler,
|
||||
DesktopRequesterContext,
|
||||
@@ -66,6 +67,18 @@ async def _register_two() -> tuple[DesktopHandler, _FakeWs, _FakeWs]:
|
||||
|
||||
|
||||
class DesktopMultiDeviceTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_screenshot_bytes_never_enter_activity_summary(self) -> None:
|
||||
handler = DesktopHandler()
|
||||
handler.recent_commands.append(DesktopCommandRecord(
|
||||
request_id="req-1", tool="desktop_computer_screenshot"
|
||||
))
|
||||
handler._update_record_from_response("req-1", {
|
||||
"status": 200,
|
||||
"result": {"bytes_base64": "private-pixels", "saved_path": "/private/shot.png"},
|
||||
})
|
||||
summary = handler.get_recent()[0]["result_summary"]
|
||||
self.assertEqual(summary, "Desktop screenshot response received")
|
||||
|
||||
async def test_tool_schema_exposes_script_and_device_selector(self) -> None:
|
||||
parameters = desktop_tool._SCHEMAS["desktop_powershell"]["parameters"]
|
||||
self.assertEqual(parameters["required"], ["script"])
|
||||
|
||||
@@ -9,6 +9,7 @@ stdlib.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -18,6 +19,20 @@ import unittest
|
||||
from plugin.relay.media import MediaRegistrationError, MediaRegistry, _MediaEntry
|
||||
|
||||
|
||||
def test_registry_logs_omit_tokens_and_media_paths(caplog) -> None:
|
||||
async def run() -> tuple[str, str]:
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = _write_file(root, "private-image.png")
|
||||
registry = MediaRegistry(allowed_roots=[root])
|
||||
entry = await registry.register(path, "image/png")
|
||||
return path, entry.token
|
||||
|
||||
caplog.set_level(logging.INFO, logger="hermes_relay.media")
|
||||
path, token = asyncio.run(run())
|
||||
assert path not in caplog.text
|
||||
assert token[:8] not in caplog.text
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -97,6 +112,78 @@ class MediaRegistryTests(unittest.IsolatedAsyncioTestCase):
|
||||
# And the expired entry has been pruned.
|
||||
self.assertEqual(await registry.size(), 0)
|
||||
|
||||
async def test_owned_upload_is_deleted_on_expiry_but_caller_file_is_preserved(self) -> None:
|
||||
registry = MediaRegistry()
|
||||
with tempfile.NamedTemporaryFile(prefix="hermes-relay-upload-", delete=False) as upload:
|
||||
upload.write(b"private-image")
|
||||
owned_path = upload.name
|
||||
caller_path = _write_file(self._sandbox, "caller.png")
|
||||
try:
|
||||
owned = await registry.register(owned_path, "image/png", owned_file=True)
|
||||
caller = await registry.register(caller_path, "image/png")
|
||||
async with registry._lock:
|
||||
registry._entries[owned.token].expires_at = time.time() - 1
|
||||
registry._entries[caller.token].expires_at = time.time() - 1
|
||||
self.assertEqual(await registry.cleanup(), 2)
|
||||
self.assertFalse(os.path.exists(owned_path))
|
||||
self.assertTrue(os.path.exists(caller_path))
|
||||
finally:
|
||||
if os.path.exists(owned_path):
|
||||
os.unlink(owned_path)
|
||||
|
||||
async def test_owned_upload_is_deleted_on_close(self) -> None:
|
||||
registry = MediaRegistry()
|
||||
with tempfile.NamedTemporaryFile(prefix="hermes-relay-upload-", delete=False) as upload:
|
||||
upload.write(b"private-image")
|
||||
owned_path = upload.name
|
||||
try:
|
||||
await registry.register(owned_path, "image/png", owned_file=True)
|
||||
await registry.close()
|
||||
self.assertFalse(os.path.exists(owned_path))
|
||||
finally:
|
||||
if os.path.exists(owned_path):
|
||||
os.unlink(owned_path)
|
||||
|
||||
async def test_owned_upload_is_deleted_on_lru_eviction(self) -> None:
|
||||
registry = MediaRegistry(max_entries=1)
|
||||
with tempfile.NamedTemporaryFile(prefix="hermes-relay-upload-", delete=False) as upload:
|
||||
upload.write(b"private-image")
|
||||
owned_path = upload.name
|
||||
try:
|
||||
await registry.register(owned_path, "image/png", owned_file=True)
|
||||
await registry.register(_write_file(self._sandbox, "keep.png"), "image/png")
|
||||
self.assertFalse(os.path.exists(owned_path))
|
||||
finally:
|
||||
if os.path.exists(owned_path):
|
||||
os.unlink(owned_path)
|
||||
|
||||
async def test_owned_file_survives_while_another_token_references_it(self) -> None:
|
||||
registry = MediaRegistry()
|
||||
with tempfile.NamedTemporaryFile(prefix="hermes-relay-upload-", delete=False) as upload:
|
||||
upload.write(b"shared-image")
|
||||
owned_path = upload.name
|
||||
try:
|
||||
owned = await registry.register(owned_path, "image/png", owned_file=True)
|
||||
other = await registry.register(owned_path, "image/png")
|
||||
async with registry._lock:
|
||||
registry._entries[owned.token].expires_at = time.time() - 1
|
||||
await registry.cleanup()
|
||||
self.assertTrue(os.path.exists(owned_path))
|
||||
async with registry._lock:
|
||||
registry._entries[other.token].expires_at = time.time() - 1
|
||||
await registry.cleanup()
|
||||
self.assertFalse(os.path.exists(owned_path))
|
||||
finally:
|
||||
if os.path.exists(owned_path):
|
||||
os.unlink(owned_path)
|
||||
|
||||
async def test_owned_file_cannot_delete_arbitrary_registered_path(self) -> None:
|
||||
registry = _make_registry(self._sandbox)
|
||||
path = _write_file(self._sandbox, "keep.png")
|
||||
with self.assertRaisesRegex(MediaRegistrationError, "managed upload path"):
|
||||
await registry.register(path, "image/png", owned_file=True)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
# ── LRU eviction ────────────────────────────────────────────────────
|
||||
|
||||
async def test_lru_eviction_when_cap_exceeded(self) -> None:
|
||||
|
||||
@@ -18,6 +18,7 @@ from plugin.relay.provider_usage import (
|
||||
fetch_codex_usage,
|
||||
fetch_nous_usage,
|
||||
fetch_opencode_go_usage,
|
||||
fetch_supergrok_usage,
|
||||
resolve_profile_home,
|
||||
serialize_account_snapshot,
|
||||
unavailable_provider,
|
||||
@@ -57,12 +58,34 @@ class _FakeSession:
|
||||
return self.response
|
||||
|
||||
|
||||
class _SequencedSession:
|
||||
"""Yield queued responses in call order, like aiohttp's request context manager."""
|
||||
|
||||
def __init__(self, responses: list[_FakeResponse]):
|
||||
self._responses = list(responses)
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def get(self, url, *, headers=None, timeout=None):
|
||||
self.calls.append({"url": url, "headers": headers or {}})
|
||||
if not self._responses:
|
||||
raise AssertionError("unexpected extra provider request")
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
class ProviderUsageModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_profile_home_is_exact_and_rejects_traversal(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
# Resolve the temp root so macOS /var -> /private/var matches
|
||||
# Path.resolve() inside resolve_profile_home.
|
||||
root = Path(raw).resolve()
|
||||
(root / "config.yaml").write_text("model: {}\n", encoding="utf-8")
|
||||
victor = root / "profiles" / "victor"
|
||||
victor = (root / "profiles" / "victor").resolve()
|
||||
victor.mkdir(parents=True)
|
||||
(victor / "config.yaml").write_text("model: {}\n", encoding="utf-8")
|
||||
self.assertEqual(resolve_profile_home(str(root / "config.yaml"), "Victor"), victor)
|
||||
@@ -159,6 +182,173 @@ class ProviderUsageModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertNotIn("limits", result)
|
||||
self.assertEqual(fake.headers["Authorization"], "Bearer secret")
|
||||
|
||||
async def test_supergrok_without_oauth_is_not_configured(self) -> None:
|
||||
result = await fetch_supergrok_usage(credential_resolver=lambda: {})
|
||||
|
||||
self.assertEqual(result["id"], "supergrok")
|
||||
self.assertEqual(result["status"], "not_configured")
|
||||
self.assertEqual(result["windows"], [])
|
||||
|
||||
async def test_supergrok_missing_oauth_state_is_not_configured(self) -> None:
|
||||
class MissingOAuthState(Exception):
|
||||
code = "xai_auth_missing"
|
||||
|
||||
def resolve_credentials() -> dict:
|
||||
raise MissingOAuthState("No credentials stored")
|
||||
|
||||
result = await fetch_supergrok_usage(credential_resolver=resolve_credentials)
|
||||
|
||||
self.assertEqual(result["status"], "not_configured")
|
||||
|
||||
async def test_supergrok_oauth_refresh_failure_is_unavailable(self) -> None:
|
||||
class RefreshFailure(Exception):
|
||||
code = "xai_refresh_failed"
|
||||
|
||||
def resolve_credentials() -> dict:
|
||||
raise RefreshFailure("private token details")
|
||||
|
||||
result = await fetch_supergrok_usage(credential_resolver=resolve_credentials)
|
||||
|
||||
self.assertEqual(result["status"], "unavailable")
|
||||
self.assertEqual(result["message"], "Could not resolve SuperGrok credentials")
|
||||
self.assertNotIn("private token details", str(result))
|
||||
|
||||
async def test_supergrok_maps_subscription_and_product_windows(self) -> None:
|
||||
session = _SequencedSession(
|
||||
[
|
||||
_FakeResponse(payload={"userId": "user-1"}),
|
||||
_FakeResponse(
|
||||
payload={
|
||||
"subscriptionTier": "SuperGrok",
|
||||
"onDemandEnabled": True,
|
||||
"config": {
|
||||
"creditUsagePercent": 14,
|
||||
"currentPeriod": {
|
||||
"type": "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
"start": "2026-09-06T08:34:12.348291+00:00",
|
||||
"end": "2026-09-13T08:34:12.348291+00:00",
|
||||
},
|
||||
"productUsage": [
|
||||
{"product": "GrokBuild", "usagePercent": 11},
|
||||
{"product": "GrokImagine", "usagePercent": 2},
|
||||
{"product": "GrokChat", "usagePercent": None},
|
||||
],
|
||||
"onDemandCap": {"val": 500},
|
||||
"onDemandUsed": {"val": 125},
|
||||
"prepaidBalance": {"val": 0},
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await fetch_supergrok_usage(
|
||||
session_factory=lambda: session,
|
||||
credential_resolver=lambda: {"api_key": "secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "available")
|
||||
self.assertEqual(result["source"], "provider_api")
|
||||
self.assertEqual(result["plan"], "SuperGrok")
|
||||
self.assertEqual(
|
||||
[row["id"] for row in result["windows"]],
|
||||
["period", "product_grok_build", "product_grok_imagine"],
|
||||
)
|
||||
self.assertEqual(result["windows"][0]["label"], "Weekly")
|
||||
self.assertEqual(result["windows"][0]["used_percent"], 14.0)
|
||||
self.assertEqual(result["windows"][0]["reset_at"], "2026-09-13T08:34:12.348291+00:00")
|
||||
self.assertEqual(result["windows"][1]["label"], "Grok Build")
|
||||
self.assertEqual(result["windows"][1]["used_percent"], 11.0)
|
||||
self.assertEqual(result["details"], ["On-demand: $1.25 used of $5.00"])
|
||||
self.assertEqual(session.calls[0]["headers"]["Authorization"], "Bearer secret")
|
||||
self.assertEqual(session.calls[1]["headers"]["x-userid"], "user-1")
|
||||
self.assertIn("/billing?format=credits", session.calls[1]["url"])
|
||||
self.assertNotIn("secret", str(result))
|
||||
|
||||
async def test_supergrok_stops_before_billing_without_account_identity(self) -> None:
|
||||
session = _SequencedSession([_FakeResponse(payload={"userId": ""})])
|
||||
|
||||
result = await fetch_supergrok_usage(
|
||||
session_factory=lambda: session,
|
||||
credential_resolver=lambda: {"api_key": "secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "unavailable")
|
||||
self.assertEqual(len(session.calls), 1)
|
||||
self.assertNotIn("secret", str(result))
|
||||
|
||||
async def test_supergrok_reports_top_level_on_demand_state_without_amounts(self) -> None:
|
||||
session = _SequencedSession(
|
||||
[
|
||||
_FakeResponse(payload={"userId": "user-1"}),
|
||||
_FakeResponse(
|
||||
payload={
|
||||
"onDemandEnabled": True,
|
||||
"config": {
|
||||
"creditUsagePercent": 0,
|
||||
"currentPeriod": {"type": "USAGE_PERIOD_TYPE_WEEKLY"},
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await fetch_supergrok_usage(
|
||||
session_factory=lambda: session,
|
||||
credential_resolver=lambda: {"api_key": "secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "available")
|
||||
self.assertEqual(result["details"], ["On-demand enabled"])
|
||||
|
||||
async def test_supergrok_fresh_period_surfaces_window_without_inventing_a_percent(self) -> None:
|
||||
session = _SequencedSession(
|
||||
[
|
||||
_FakeResponse(payload={"userId": "user-1"}),
|
||||
_FakeResponse(
|
||||
payload={
|
||||
"config": {
|
||||
"currentPeriod": {
|
||||
"type": "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
"start": "2026-09-13T08:34:12.348291+00:00",
|
||||
"end": "2026-09-20T08:34:12.348291+00:00",
|
||||
},
|
||||
"billingPeriodEnd": "2026-09-20T08:34:12.348291+00:00",
|
||||
}
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await fetch_supergrok_usage(
|
||||
session_factory=lambda: session,
|
||||
credential_resolver=lambda: {"api_key": "secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "available")
|
||||
self.assertEqual(len(result["windows"]), 1)
|
||||
self.assertEqual(result["windows"][0]["label"], "Weekly")
|
||||
self.assertIsNone(result["windows"][0]["used_percent"])
|
||||
self.assertEqual(result["windows"][0]["reset_at"], "2026-09-20T08:34:12.348291+00:00")
|
||||
self.assertEqual(result["windows"][0]["detail"], "No usage reported yet")
|
||||
|
||||
async def test_supergrok_unusable_payload_is_unavailable(self) -> None:
|
||||
session = _SequencedSession(
|
||||
[
|
||||
_FakeResponse(payload={"userId": "user-1"}),
|
||||
_FakeResponse(payload={"config": {"isUnifiedBillingUser": True}}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await fetch_supergrok_usage(
|
||||
session_factory=lambda: session,
|
||||
credential_resolver=lambda: {"api_key": "secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "unavailable")
|
||||
self.assertEqual(result["windows"], [])
|
||||
self.assertEqual(result["message"], "Provider returned no usage windows")
|
||||
|
||||
async def test_collection_keeps_provider_order_and_schema(self) -> None:
|
||||
async def codex(_home, **_kwargs):
|
||||
return unavailable_provider("openai-codex", "Codex")
|
||||
@@ -169,19 +359,23 @@ class ProviderUsageModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def opencode(*, profile_home=None):
|
||||
return unavailable_provider("opencode-go", "OpenCode Go")
|
||||
|
||||
async def supergrok(*, profile_home=None):
|
||||
return unavailable_provider("supergrok", "SuperGrok")
|
||||
|
||||
result = await collect_provider_usage(
|
||||
codex_fetcher=codex,
|
||||
nous_fetcher=nous,
|
||||
opencode_fetcher=opencode,
|
||||
supergrok_fetcher=supergrok,
|
||||
)
|
||||
self.assertEqual(result["schema_version"], 2)
|
||||
self.assertEqual(
|
||||
result["capabilities"],
|
||||
["credential_pools", "structured_balances", "opencode_go"],
|
||||
["credential_pools", "structured_balances", "opencode_go", "supergrok"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["id"] for row in result["providers"]],
|
||||
["openai-codex", "nous", "opencode-go"],
|
||||
["openai-codex", "nous", "opencode-go", "supergrok"],
|
||||
)
|
||||
|
||||
async def test_codex_pool_marks_exact_live_session_credential_active(self) -> None:
|
||||
|
||||
@@ -19,7 +19,7 @@ import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp import FormData, web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay import media
|
||||
@@ -186,6 +186,50 @@ class RelayMediaRoutesTests(AioHTTPTestCase):
|
||||
body = await resp.read()
|
||||
self.assertEqual(body, contents)
|
||||
|
||||
async def test_mark_sensitive_requires_loopback_and_preserves_image_bytes(self) -> None:
|
||||
contents = b"\x89PNG\r\n\x1a\nprivate-image"
|
||||
path = _write_file(self._sandbox, "private.png", content=contents)
|
||||
entry = await self._server().media.register(path, "image/png")
|
||||
response = await self.client.post(f"/media/{entry.token}/sensitive")
|
||||
self.assertEqual(response.status, 200)
|
||||
bearer = await self._create_session_token()
|
||||
fetched = await self.client.get(
|
||||
f"/media/{entry.token}", headers={"Authorization": f"Bearer {bearer}"}
|
||||
)
|
||||
self.assertEqual(fetched.headers.get("X-Media-Sensitive"), "1")
|
||||
self.assertEqual(await fetched.read(), contents)
|
||||
missing = await self.client.post("/media/missing-token-123456/sensitive")
|
||||
self.assertEqual(missing.status, 404)
|
||||
from plugin.relay.server import handle_media_mark_sensitive
|
||||
|
||||
forged = mock.Mock()
|
||||
forged.remote = "203.0.113.10"
|
||||
forged.app = self.app
|
||||
forged.match_info = {"token": entry.token}
|
||||
with self.assertRaises(web.HTTPForbidden):
|
||||
await handle_media_mark_sensitive(forged)
|
||||
|
||||
async def test_uploaded_file_is_owned_and_deleted_when_token_expires(self) -> None:
|
||||
bearer = await self._create_session_token()
|
||||
form = FormData()
|
||||
form.add_field("file", b"\x89PNG\r\n\x1a\nprivate-image",
|
||||
filename="capture.png", content_type="image/png")
|
||||
self._server().media.allowed_roots.append(os.path.realpath(tempfile.gettempdir()))
|
||||
response = await self.client.post(
|
||||
"/media/upload", data=form,
|
||||
headers={"Authorization": f"Bearer {bearer}"},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
token = (await response.json())["token"]
|
||||
entry = await self._server().media.get(token)
|
||||
self.assertIsNotNone(entry)
|
||||
path = entry.path
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
async with self._server().media._lock:
|
||||
self._server().media._entries[token].expires_at = time.time() - 1
|
||||
await self._server().media.cleanup()
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
async def test_fetch_expired_token_returns_404(self) -> None:
|
||||
path = _write_file(self._sandbox, "gone.bin", content=b"x")
|
||||
entry = await self._server().media.register(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -35,6 +35,7 @@ from typing import Any, Callable
|
||||
|
||||
from .android_navigate_prompt import ParsedAction, build_prompt, parse_response
|
||||
from .android_tool import _bridge_request, _timeout
|
||||
from .android_screenshot_media import resolve_screenshot
|
||||
|
||||
logger = logging.getLogger("hermes_relay.tools.android_navigate")
|
||||
|
||||
@@ -172,45 +173,29 @@ def _capture_screenshot() -> _Screenshot:
|
||||
an error-JSON envelope. The navigate loop needs structured data.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
|
||||
raw = _get("/screenshot")
|
||||
if "error" in raw:
|
||||
raise RuntimeError(f"bridge /screenshot error: {raw['error']}")
|
||||
|
||||
# The bridge wraps its response in {"data": {...}} in some builds and
|
||||
# returns a flat object in others — match android_tool.py's tolerant
|
||||
# shape-check.
|
||||
result = raw.get("data", raw)
|
||||
img_b64 = result.get("image") or ""
|
||||
if not img_b64:
|
||||
raise RuntimeError("bridge /screenshot returned no image data")
|
||||
|
||||
img_bytes = base64.b64decode(img_b64)
|
||||
img_bytes, mime, marker = resolve_screenshot(raw, _bridge_request, _timeout())
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
suffix=".jpg", prefix="android_navigate_", delete=False
|
||||
suffix=".png" if mime == "image/png" else ".jpg",
|
||||
prefix="android_navigate_", delete=False
|
||||
)
|
||||
try:
|
||||
tmp.write(img_bytes)
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
# Try to register with the local relay for an opaque token. Fall
|
||||
# back to the bare path marker if the relay isn't reachable (same
|
||||
# graceful degradation as android_screenshot).
|
||||
token_marker = f"file://{tmp.name}"
|
||||
try:
|
||||
from ..relay.client import register_media # type: ignore
|
||||
token_marker = marker.removeprefix("MEDIA:") if marker else f"file://{tmp.name}"
|
||||
if marker is None:
|
||||
try:
|
||||
from ..relay.client import register_media # type: ignore
|
||||
|
||||
token = register_media(tmp.name, "image/jpeg", file_name="nav_step.jpg")
|
||||
if token:
|
||||
token_marker = f"hermes-relay://{token}"
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"register_media unavailable — navigate trace will use file:// marker",
|
||||
exc_info=True,
|
||||
)
|
||||
token = register_media(tmp.name, mime, file_name="nav_step.png" if mime == "image/png" else "nav_step.jpg")
|
||||
if token:
|
||||
token_marker = f"hermes-relay://{token}"
|
||||
except Exception:
|
||||
logger.debug("register_media unavailable for legacy screenshot")
|
||||
|
||||
return _Screenshot(token=token_marker, local_path=tmp.name)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user