Compare commits

..
Author SHA1 Message Date
Bailey Dixon d8a6bf0ce6 Merge pull request #607 from AYin-Z/fix/tray-notice-capability
fix(desktop): grant tray capability to the notice and evidence windows
2026-09-23 18:20:13 -04:00
Bailey Dixon 9e96111813 fix(desktop): restore secondary tray window dismissal
Give notice and evidence only the Tauri hide permission, retain secondary windows on native close, and ignore stale screenshot responses.

Based on contributor report and PR #607 by AYin-Z. Refs #606.

(cherry picked from commit 0d7ea149cf3e0e7605607733499e7ac821ce4ff2)
2026-09-23 18:06:31 -04:00
Bailey Dixon 42efc01d58 Merge PR #607 tray capability fix into current dev 2026-09-23 18:06:06 -04:00
Bailey Dixon dc93d5eab2 Merge pull request #628 from Codename-11/fix/media-consumer-parity
fix: render proactive and desktop media with private cleanup
2026-09-23 18:02:47 -04:00
Bailey Dixon cafbadc583 docs: align media route reference with managed uploads 2026-09-23 17:49:19 -04:00
Bailey Dixon 607c26660c merge: integrate current dev into media consumer fixes 2026-09-23 17:48:41 -04:00
Bailey Dixon 3882b857e3 fix(relay): retire owned media and redact activity logs 2026-09-23 17:48:33 -04:00
Bailey Dixon 81c186c6ba fix(plugin): attach desktop screenshots as host images 2026-09-23 17:48:26 -04:00
Bailey Dixon 4ad0709fbe fix(android): render proactive Thread media markers 2026-09-23 17:48:25 -04:00
Bailey Dixon 0324c9f5dd Merge pull request #615 from ophirhan/feat/provider-usage-supergrok
feat(plugin): report SuperGrok subscription usage on the provider surface
2026-09-23 17:39:22 -04:00
Bailey Dixon f8c31f8b59 fix(plugin): distinguish SuperGrok credential failures and on-demand state 2026-09-23 17:16:12 -04:00
Bailey Dixon abb4bc0ced Merge PR #615 SuperGrok provider usage into current dev 2026-09-23 17:14:32 -04:00
Bailey Dixon 07b683fbb5 Merge pull request #627 from Codename-11/fix/android-screenshot-media-token
fix(plugin): resolve Android screenshot media tokens for host tools
2026-09-23 16:39:40 -04:00
Bailey Dixon 759ac490c9 fix(plugin): resolve Android screenshot media tokens for host tools 2026-09-23 16:37:50 -04:00
Bailey Dixon 150e375284 Merge pull request #626 from Codename-11/fix/android-chat-model-picker-cold-load
fix(android): load Chat models when picker first opens
2026-09-23 16:00:51 -04:00
Bailey Dixon 1f49f08dbe fix(android): load Chat models when picker first opens 2026-09-23 15:42:43 -04:00
Bailey Dixon e96aa435f2 Merge pull request #625 from Codename-11/integration/agp-941
chore(deps): update Android Gradle plugins to 9.4.1
2026-09-23 14:24:01 -04:00
Bailey Dixon 592affca40 Merge remote-tracking branch 'origin/pr-614-dependabot' into integration/agp-941
# Conflicts:
#	build.gradle.kts
2026-09-23 14:11:02 -04:00
Bailey Dixon 7e5123b22f Merge remote-tracking branch 'origin/pr-612-dependabot' into integration/agp-941 2026-09-23 14:10:28 -04:00
Bailey Dixon ad3786fb0b Merge pull request #624 from Codename-11/integration/secure-link-contract
feat: guide Secure Link setup and preserve paired routes
2026-09-23 13:57:11 -04:00
Bailey Dixon 8bd67e3363 fix: render Desktop recovery instructions as static children 2026-09-23 13:21:50 -04:00
Bailey Dixon 17cf12fff9 fix: retire Desktop setup results when the listener changes 2026-09-23 13:18:09 -04:00
Bailey Dixon b55e798c3b fix: allow bounded Secure Link preflight checks to finish 2026-09-23 13:12:33 -04:00
Bailey Dixon a128e910f1 feat: guide Secure Link setup across Relay surfaces 2026-09-23 13:10:25 -04:00
ophirhan a7cbf377a0 test(plugin): resolve temp paths in profile-home usage test
On macOS tempfile lives under /var which Path.resolve() maps to
/private/var; compare resolved paths so the assertion is stable.
2026-09-23 19:36:16 +03:00
ophirhan 9435d43b04 feat(android): show SuperGrok in default usage-provider visibility
Include supergrok in DEFAULT_VISIBLE_PROVIDERS and the Usage Limits
fallback list so the Settings summary surfaces Grok once the host
reports it, without a one-off toggle.
2026-09-23 19:36:16 +03:00
ophirhan 52170f76ea fix(plugin): keep the Grok window when a period reports no usage yet
For a billing period that has recorded no usage, xAI omits `creditUsagePercent` and `productUsage` from the credits snapshot instead of reporting zero. The adapter treated the resulting empty window list as an upstream failure, so a freshly rolled-over period surfaced as 'usage is temporarily unavailable' on the device.

Emit the period window with no percentage and an explicit detail line whenever the period bounds are known: the window, its label, and its reset time are real, only the figure is absent. A payload carrying neither a figure nor period bounds still reports unavailable, so a genuinely broken upstream contract is not masked.

Verified against a rolled-over weekly period that previously produced the error state; the surface now reports the window with its reset time.
2026-09-23 19:36:15 +03:00
ophirhan 9871b0cb7c feat(plugin): report Grok subscription usage in the provider surface
Hosts signed in with `xai-oauth` have a Grok subscription whose windows are
only served by xAI's CLI proxy, not the public API, so the provider-neutral
usage surface could not see them.

Add a `supergrok` adapter that reads the account identity and then the credits
billing snapshot over the pinned `cli-chat-proxy.grok.com` contract with the
host-side OAuth bearer: the current billing period, per-product usage, and
on-demand credit state map onto the existing window/detail shape. Hosts with
no `xai-oauth` credential report `not_configured`, upstream failures degrade to
`unavailable`, and the bearer never enters the response.
2026-09-23 19:36:15 +03:00
Bailey Dixon 14500096c6 fix: simplify Secure Link routes and fit pairing QR payloads 2026-09-23 11:17:12 -04:00
Bailey Dixon 1ea84630d2 chore: refresh Secure Link integration from dev 2026-09-23 10:03:24 -04:00
Bailey Dixon d98e0b113b Merge pull request #620 from ophirhan/fix/appearance-light-mode-cold-start
fix(android): lock night mode to Appearance on cold start
2026-09-23 09:57:42 -04:00
Bailey Dixon ea2110fa14 chore: refresh Secure Link integration from dev 2026-09-23 09:49:27 -04:00
Bailey Dixon 43357a387d fix: enforce the combined Secure Link transport contract 2026-09-23 09:48:56 -04:00
Bailey Dixon 336475e451 Merge remote-tracking branch 'origin/dev' into codex/fix-appearance-cold-start
# Conflicts:
#	CHANGELOG.md
2026-09-23 09:40:55 -04:00
Bailey Dixon 13492610a8 Merge pull request #622 from Codename-11/fix/android-gateway-onboarding
fix(android): clarify Gateway onboarding and HTTP consent
2026-09-23 09:39:06 -04:00
Bailey Dixon 4e75564d33 fix: integrate Android Secure Link transport 2026-09-23 09:29:34 -04:00
Bailey Dixon 50adab99fa fix: integrate Secure Link proxy contract 2026-09-23 09:29:16 -04:00
Bailey Dixon dc8e076836 fix(android): hydrate appearance before first frame 2026-09-23 09:26:30 -04:00
ophirhan b296b56bce fix(android): remove forceDarkAllowed from base values theme
Companion to values-v29 override; clears lint NewApi on minSdk 26.
2026-09-22 22:39:31 +03:00
ophirhan a74ad0738f fix(android): gate forceDarkAllowed behind values-v29
Lint NewApi: android:forceDarkAllowed needs API 29; minSdk is 26.
Keep base Theme.HermesRelay in values/ and disable OEM force-dark only
on API 29+.
2026-09-22 22:39:02 +03:00
ophirhan 6bb186ee2b docs(android): changelog Appearance cold-start night mode lock 2026-09-22 22:14:32 +03:00
ophirhan db4a6f37c7 docs(android): changelog Secure Link pin path; scrub test fixture hosts
Use existing 192.168.1.x examples in presentation tests (no personal LAN).
2026-09-22 22:04:17 +03:00
ophirhan 0f19deae7f fix(android): pin Secure Link only in TrustManager, not CertificatePinner
OkHttp CertificatePinner can fail with an empty peer chain after the custom
TrustManager already accepted the paired SPKI (OEM stacks). That surfaces as
Certificate pinning failure / TLS failed — server may be http:// while the
Mac live pin still matches.
2026-09-22 22:00:52 +03:00
ophirhan fa2d17338d fix(android): Secure Link pin path for HTTP probes, routes UI, gateway WS
Carry pairing SPKI pin through Relay HTTP/voice probes, LAN endpoint
resolution, Secure Link route labels, and GatewayChatClient upgrades so
self-signed Secure Link stays healthy beyond the WSS path alone.
2026-09-22 22:00:45 +03:00
ophirhan bc2f2b0010 fix(android): lock night mode to Appearance on cold start
DayNight followed system/OEM force-dark while DataStore still said Light,
so the UI opened dark until the mode control was toggled. Apply
AppCompatDelegate from the saved preference before first frame and disable
force-dark on the activity theme.
2026-09-22 16:01:35 +03:00
ophirhan dcc8d54916 fix(plugin): make Secure Link proxy usable for Gateway chat and login
Preserve WebSocket query strings (auth tickets) when proxying dashboard
and Gateway sockets, disable nested permessage-deflate on the upstream
leg, scope dashboard login HTML/JSON and redirects under /dashboard
without double-prefixing, register bare /api and /dashboard routes, and
include version on /relay/health for route probes.
2026-09-22 14:40:45 +03:00
dependabot[bot] 47e27f6ac9 chore(deps): bump com.android.application from 9.4.0 to 9.4.1
Bumps com.android.application from 9.4.0 to 9.4.1.

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 11:56:23 +00:00
AYin-Z b21b9c225c fix(desktop): grant tray capability to the notice and evidence windows
The tray capability listed only main and grant while tauri.conf.json declares four windows: main, grant, notice and evidence. The notice and evidence windows are created with decorations:false and dismiss themselves through the core window API, so without the capability their close controls and auto-hide timers are rejected - silently, because the callers use void on the promise.

Closes #606.
2026-09-19 20:58:07 +08:00
112 changed files with 4161 additions and 424 deletions
+7
View File
@@ -183,6 +183,13 @@ jobs:
./gradlew :app:testSideloadDebugUnitTest \
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.network.shared.PluginProxyTransportTest \
--tests '*GatewayChatClientTest*retarget*' \
--tests '*RelayVoiceClientRoutingTest.proxyProviderOwnsBothVoiceSessionAndWebSocketRequests' \
--tests com.hermesandroid.relay.network.relay.RelayHttpClientDiagnosticsTest \
--tests com.hermesandroid.relay.ui.components.GatewayRoutesAccessPresentationTest \
--tests com.hermesandroid.relay.ui.components.EndpointsCardCompactLayoutTest \
--tests com.hermesandroid.relay.ui.screens.ConnectionDetailPresentationTest \
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
--tests com.hermesandroid.relay.util.ServerAddressTest \
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
+4
View File
@@ -103,6 +103,10 @@ jobs:
python -m pytest \
plugin/tests/test_manifest_compatibility.py \
plugin/tests/test_relay_security.py \
plugin/tests/test_secure_proxy.py \
plugin/tests/test_secure_proxy_contract.py \
plugin/tests/test_secure_link_setup.py \
plugin/tests/test_secure_proxy_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_native_layout_imports.py \
+16
View File
@@ -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)
@@ -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")
}
}
@@ -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)
@@ -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
@@ -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
}
}
}
@@ -481,8 +481,17 @@ class UpstreamTransportController(
}
gatewayClientCache?.third?.shutdown()
lateinit var client: GatewayChatClient
// Dashboard REST already carries the pairing SPKI pin for Secure Link.
// The gateway WS upgrade must use the SAME client — a bare OkHttpClient
// rejects the self-signed Secure Link cert ("Trust anchor … not found")
// and leaves Chat stuck on "Checking gateway…".
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
val gatewayHttpClient = dashboardRestHttpClients[
connectionId to dashboardUrl.trim().trimEnd('/'),
]
client = GatewayChatClient(
initialDashboardClient = dashboardClientFor(connectionId, dashboardUrl),
initialDashboardClient = dashboardClient,
okHttpClient = gatewayHttpClient,
onGatewayUnsupported = {
updateGatewayAvailabilityIfCurrent(
connectionId,
@@ -554,12 +563,16 @@ class UpstreamTransportController(
if (cached.activeRequests == 0 && cached.retained == 0) shutdownRouteEntry(cached)
}
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
val gatewayHttpClient = dashboardRestHttpClients[
connectionId to dashboardUrl.trim().trimEnd('/'),
]
entry = RouteGatewayEntry(
dashboardUrl = dashboardUrl,
dashboardClient = dashboardClient,
client = GatewayChatClient(
initialDashboardClient = dashboardClient,
fixedSessionProfile = profile,
okHttpClient = gatewayHttpClient,
).also { it.setKeepAliveInBackground(gatewayKeepAliveProvider()) },
)
if (retain) entry.retained = 1 else entry.activeRequests = 1
@@ -2671,6 +2671,7 @@
<string name="model_picker_title">Modelo</string>
<string name="model_picker_search">Pesquisar modelos ou provedores…</string>
<string name="model_picker_empty">Nenhum modelo corresponde à pesquisa</string>
<string name="model_picker_no_models">Nenhum modelo disponível. Tente atualizar.</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">O que o agente vê</string>
<string name="context_sheet_transparency">O contexto adicional exato adicionado ao início do próximo turno, para transparência.</string>
@@ -2782,6 +2782,7 @@
<string name="model_picker_title">模型</string>
<string name="model_picker_search">搜索模型或提供商…</string>
<string name="model_picker_empty">没有匹配您搜索的模型</string>
<string name="model_picker_no_models">没有可用的模型。请尝试刷新。</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">代理看到的内容</string>
+1
View File
@@ -2788,6 +2788,7 @@
<string name="model_picker_title">Modell</string>
<string name="model_picker_search">Modelle oder Anbieter suchen&#8230;</string>
<string name="model_picker_empty">Keine Modelle entsprechen deiner Suche</string>
<string name="model_picker_no_models">Keine Modelle verfügbar. Versuche es mit Aktualisieren.</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">Was der Agent sieht</string>
+1
View File
@@ -2551,6 +2551,7 @@
<string name="model_picker_title">Modelo</string>
<string name="model_picker_search">Buscar modelos o proveedores…</string>
<string name="model_picker_empty">Ningún modelo coincide con tu búsqueda</string>
<string name="model_picker_no_models">No hay modelos disponibles. Prueba a actualizar.</string>
<string name="context_sheet_agent_sees">Lo que ve el agente</string>
<string name="context_sheet_transparency">El contexto adicional exacto antepuesto a tu próximo turno, para mayor transparencia.</string>
<string name="context_sheet_persona">Persona/perfil</string>
+1
View File
@@ -2796,6 +2796,7 @@
<string name="model_picker_title">モデル</string>
<string name="model_picker_search">モデルまたはプロバイダーを検索&#8230;</string>
<string name="model_picker_empty">検索に一致するモデルはありません</string>
<string name="model_picker_no_models">利用可能なモデルがありません。更新してください。</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">エージェントが見ているもの</string>
+1
View File
@@ -2776,6 +2776,7 @@
<string name="model_picker_title">Модель</string>
<string name="model_picker_search">Поиск моделей или поставщиков\&amp;#8230;</string>
<string name="model_picker_empty">Нет моделей, соответствующих вашему запросу</string>
<string name="model_picker_no_models">Нет доступных моделей. Попробуйте обновить список.</string>
<string name="context_sheet_agent_sees">Что видит агент</string>
<string name="context_sheet_transparency">Точный дополнительный контекст, добавляемый к вашему следующему ходу, для прозрачности.</string>
<string name="context_sheet_persona">Персона / профиль</string>
+1
View File
@@ -3180,6 +3180,7 @@
<string name="model_picker_title">Model</string>
<string name="model_picker_search">Search models or providers&#8230;</string>
<string name="model_picker_empty">No models match your search</string>
<string name="model_picker_no_models">No models available. Try Refresh.</string>
<!-- InjectedContextSheet -->
<string name="context_sheet_agent_sees">What the agent sees</string>
@@ -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)
}
}
@@ -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>()
@@ -78,6 +78,35 @@ class RelayHttpClientDiagnosticsTest {
}
}
@Test
fun secureLinkHealthWithoutVersionStillSucceeds() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""{"status":"ok","surface":"hermes_secure_proxy","security":"pinned_tls"}""",
),
)
server.start()
try {
val configuredRelay = "ws://${server.hostName}:${server.port}/relay/ws"
val client = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { configuredRelay },
sessionTokenProvider = { null },
)
val result = client.probeHealth(configuredRelay)
assertTrue(result.isSuccess)
assertEquals("secure-link", result.getOrNull()?.version)
assertTrue(
DiagnosticsLog.recent(setOf(DiagnosticCategory.Relay))
.none { it.detail == "Missing version field" },
)
} finally {
server.shutdown()
}
}
@Test
fun dashboardIngressUsesOuterAuthClientAndSeparateRelayHeader() = runTest {
val server = MockWebServer()
@@ -73,6 +73,29 @@ class RelayVoiceClientRoutingTest {
runCatching { tailscaleServer.shutdown() }
}
@Test
fun proxyProviderOwnsBothVoiceSessionAndWebSocketRequests() = runTest {
val selected = Collections.synchronizedList(mutableListOf<String>())
val requests = Collections.synchronizedList(mutableListOf<String>())
val proxyClient = httpClient.newBuilder().addInterceptor { chain ->
requests.add(chain.request().url.encodedPath)
chain.proceed(chain.request())
}.build()
val client = RelayVoiceClient(
context = context,
okHttpClient = httpClient.newBuilder().addInterceptor {
throw IOException("generic client must not handle this route")
}.build(),
relayUrlProvider = { relayUrl(lanServer) },
sessionTokenProvider = { "session-token" },
pluginProxyHttpClientProvider = { url -> selected.add(url); proxyClient },
)
val result = client.runVoiceOutput("Pinned route") {}
assertTrue(result.exceptionOrNull()?.message, result.isSuccess)
assertEquals(listOf("/voice/output/session", "/voice/output/session-test"), requests)
assertTrue(selected.any { it.endsWith("/voice/output/session-test") })
}
@Test
fun realtimeAgentAndVoiceOutputFollowSameEffectiveRelayUrlProvider() = runTest {
var activeRelayUrl = relayUrl(lanServer)
@@ -1,6 +1,23 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.ProxyEndpoint
import io.mockk.every
import io.mockk.mockk
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import java.security.MessageDigest
import java.security.PublicKey
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Base64
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.net.ssl.X509TrustManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -9,6 +26,68 @@ import org.junit.Test
class PluginProxyTransportTest {
private val pin = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
@Test
fun `system trust never bypasses the paired leaf pin`() {
val key = mockk<PublicKey>()
every { key.encoded } returns byteArrayOf(1, 2, 3)
val leaf = mockk<X509Certificate>(relaxed = true)
every { leaf.publicKey } returns key
val matchingPin = "sha256/" + Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(key.encoded),
)
for (systemAccepted in listOf(true, false)) {
val system = mockk<X509TrustManager>(relaxed = true)
if (!systemAccepted) {
every { system.checkServerTrusted(any(), any()) } throws CertificateException("untrusted")
}
PinnedOrSystemTrustManager(system, matchingPin).checkServerTrusted(arrayOf(leaf), "RSA")
assertThrows(CertificateException::class.java) {
PinnedOrSystemTrustManager(system, pin).checkServerTrusted(arrayOf(leaf), "RSA")
}
assertThrows(CertificateException::class.java) {
PinnedOrSystemTrustManager(system, matchingPin).checkServerTrusted(emptyArray(), "RSA")
}
}
val rejectingSystem = mockk<X509TrustManager>()
every { rejectingSystem.checkServerTrusted(any(), any()) } throws CertificateException("untrusted")
every { leaf.checkValidity() } throws CertificateException("expired")
assertThrows(CertificateException::class.java) {
PinnedOrSystemTrustManager(rejectingSystem, matchingPin).checkServerTrusted(arrayOf(leaf), "RSA")
}
}
@Test
fun `authority guard rejects HTTP and WebSocket before credentials or network`() {
var credentialsRead = false
val client = buildPluginProxyClient(
OkHttpClient.Builder(),
ProxyEndpoint("https://paired.invalid:9443", pinSha256 = pin).toPluginProxyRoutesOrNull()!!,
sessionTokenProvider = { credentialsRead = true; "session-token" },
)
assertFalse(client.followRedirects)
assertFalse(client.followSslRedirects)
for (url in listOf("http://paired.invalid:9443/relay", "https://other.invalid:9443/relay", "https://paired.invalid:9444/relay")) {
val failure = assertThrows(java.io.IOException::class.java) {
client.newCall(Request.Builder().url(url).build()).execute().close()
}
assertTrue(failure.message.orEmpty().contains("paired authority"))
}
val failed = CountDownLatch(1)
var socketFailure: Throwable? = null
client.newWebSocket(Request.Builder().url("wss://paired.invalid:9444/relay/ws").build(),
object : WebSocketListener() {
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
socketFailure = t
failed.countDown()
}
},
)
assertTrue(failed.await(3, TimeUnit.SECONDS))
assertTrue(socketFailure?.message.orEmpty().contains("paired authority"))
assertFalse(credentialsRead)
client.dispatcher.executorService.shutdown()
}
@Test
fun `derives all proxy surfaces from one authority`() {
val routes = ProxyEndpoint(
@@ -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()
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
@@ -94,6 +95,37 @@ class AppearanceShapeScreenshotTest {
compose.onRoot().captureRoboImage("build/ui-evidence/appearance-shape-balanced-sheet-dark.png")
}
@Test @Config(sdk = [34]) fun coldModelPickerLoadingSurface() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
ModelPickerSheet(
options = listOf(ChatInputPickerOption("Server default", null)),
loading = true,
onRefresh = {},
onSelect = {},
onDismiss = {},
)
}
}
compose.onNodeWithText("Loading provider catalog…").assertExists()
compose.onRoot().captureRoboImage("build/ui-evidence/model-picker-cold-loading.png")
}
@Test @Config(sdk = [34]) fun coldModelPickerEmptySurface() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
ModelPickerSheet(
options = listOf(ChatInputPickerOption("Server default", null)),
onRefresh = {},
onSelect = {},
onDismiss = {},
)
}
}
compose.onNodeWithText("No models available. Try Refresh.").assertExists()
compose.onRoot().captureRoboImage("build/ui-evidence/model-picker-cold-empty.png")
}
private fun captureMode(shapeId: String, themeId: String, themePreference: String, fontScale: Float) {
compose.setContent {
HermesRelayTheme(
@@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -17,11 +18,35 @@ import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w320dp-h720dp-xxhdpi")
@Config(sdk = [35], qualifiers = "w320dp-h720dp-xxhdpi")
class EndpointsCardCompactLayoutTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `Secure Link details show derived namespaces rather than unconfigured placeholders`() {
val route = EndpointCandidate(
role = "plugin_proxy",
proxy = ProxyEndpoint(
url = "https://relay.example:9443",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay", "dashboard"),
),
)
compose.setContent {
MaterialTheme {
EndpointsCard(
endpoints = listOf(route), activeEndpoint = route,
preferredRole = null, manualOverrideRole = null,
onUseNow = {}, onCancelUseNow = {}, onPreferEndpoint = {},
onClearPreferred = {}, onProbeNow = {}, onViewPin = { null },
)
}
}
compose.onNodeWithText("https://relay.example:9443/dashboard").assertExists()
compose.onNodeWithText("wss://relay.example:9443/relay/ws").assertExists()
}
@Test
fun `long route title keeps active state on a separate visible row`() {
val title = "A very long operator-defined reverse proxy route name"
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.network.shared.EndpointSurface
import org.junit.Assert.assertEquals
@@ -56,6 +57,51 @@ class GatewayRoutesAccessPresentationTest {
assertEquals("LAN (HTTP)", presentation.label)
}
@Test
fun `Secure Link candidate presents pinned HTTPS dashboard not plain 9119`() {
val secureLink = EndpointCandidate(
role = "plugin_proxy",
priority = 0,
proxy = ProxyEndpoint(
url = "https://192.168.1.20:9443",
transportHint = "https",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay", "api", "dashboard"),
),
)
val presentation = gatewayRoutePresentation(
activeEndpoint = secureLink,
configuredDashboardUrl = "http://192.168.1.20:9119",
)
assertEquals("https://192.168.1.20:9443/dashboard", presentation.address)
assertEquals("Hermes Secure Link (HTTPS)", presentation.label)
assertFalse(presentation.publicHttpViolation)
assertTrue(presentation.configured)
}
@Test
fun `Secure Link without dashboard surface does not invent a Gateway address`() {
val relayOnlyProxy = EndpointCandidate(
role = "plugin_proxy",
proxy = ProxyEndpoint(
url = "https://192.168.1.20:9443",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay"),
),
)
val presentation = gatewayRoutePresentation(
activeEndpoint = relayOnlyProxy,
configuredDashboardUrl = "http://192.168.1.20:9119",
)
// No dashboard surface → fall back to the saved configured URL.
assertEquals("http://192.168.1.20:9119", presentation.address)
assertEquals("LAN (HTTP)", presentation.label)
}
@Test
fun `relay-only candidate leaves the Gateway route unconfigured`() {
val route = EndpointCandidate(
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import com.hermesandroid.relay.data.RelayEndpoint
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -122,6 +123,25 @@ class ConnectionDetailPresentationTest {
assertEquals("", route.address)
}
@Test
fun `Secure Link owns current route identity instead of plain LAN fallback`() {
val secureLink = EndpointCandidate(
role = "plugin_proxy",
proxy = ProxyEndpoint(
url = "https://192.168.1.20:9443",
pinSha256 = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
surfaces = listOf("relay", "api", "dashboard"),
),
)
val route = resolveDetailRoutePresentation(
activeEndpoint = secureLink,
effectiveDashboardUrl = "https://192.168.1.20:9443/dashboard",
)
assertEquals("Hermes Secure Link (HTTPS)", route.label)
assertEquals("https://192.168.1.20:9443/dashboard", route.address)
}
@Test
fun `missing route does not claim plain HTTP`() {
val route = resolveDetailRoutePresentation(
@@ -0,0 +1,100 @@
package com.hermesandroid.relay.ui.theme
import androidx.appcompat.app.AppCompatDelegate
import androidx.datastore.preferences.core.mutablePreferencesOf
import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.CustomThemePreset
import org.junit.Assert.assertEquals
import org.junit.Test
class AppearanceNightModeTest {
@Test
fun dualModeHonorsExplicitLightAndDark() {
assertEquals(
AppCompatDelegate.MODE_NIGHT_NO,
AppearanceNightMode.nightModeFor("light", ThemeMode.BOTH),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_YES,
AppearanceNightMode.nightModeFor("dark", ThemeMode.BOTH),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
AppearanceNightMode.nightModeFor("auto", ThemeMode.BOTH),
)
}
@Test
fun fixedThemesIgnorePreferenceAxis() {
assertEquals(
AppCompatDelegate.MODE_NIGHT_NO,
AppearanceNightMode.nightModeFor("dark", ThemeMode.LIGHT_ONLY),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_YES,
AppearanceNightMode.nightModeFor("light", ThemeMode.DARK_ONLY),
)
}
@Test
fun customThemeModeWinsOverPreference() {
val light = CustomThemePreset(
id = "day",
name = "Day",
mode = CustomThemePreset.MODE_LIGHT,
backgroundHex = "#F5F5F5",
surfaceHex = "#FFFFFF",
accentHex = "#0E18D6",
textHex = "#111111",
)
val dark = light.copy(id = "night", name = "Night", mode = CustomThemePreset.MODE_DARK)
assertEquals(
AppCompatDelegate.MODE_NIGHT_NO,
AppearanceNightMode.nightModeFor("dark", ThemeMode.BOTH, light),
)
assertEquals(
AppCompatDelegate.MODE_NIGHT_YES,
AppearanceNightMode.nightModeFor("light", ThemeMode.BOTH, dark),
)
}
@Test
fun normalizePreferenceFallsBackToAuto() {
assertEquals("auto", AppearanceNightMode.normalizePreference(null))
assertEquals("auto", AppearanceNightMode.normalizePreference("sepia"))
assertEquals("light", AppearanceNightMode.normalizePreference("light"))
}
@Test
fun persistedSnapshotDrivesBothPaletteAndPlatformNightMode() {
val saved = mutablePreferencesOf(
AppearancePreferences.themeKey to "light",
AppearancePreferences.appThemeKey to AppThemes.DEFAULT_ID,
)
val appearance = AppearancePreferences.decode(saved)
assertEquals("light", appearance.themePreference)
assertEquals(AppCompatDelegate.MODE_NIGHT_NO, AppearanceNightMode.nightModeFor(appearance))
saved[AppearancePreferences.themeKey] = "auto"
assertEquals(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
AppearanceNightMode.nightModeFor(AppearancePreferences.decode(saved)),
)
val custom = CustomThemePreset(
id = "night",
name = "Night",
mode = CustomThemePreset.MODE_DARK,
backgroundHex = "#0B0B0F",
surfaceHex = "#141421",
accentHex = "#5B6CFF",
textHex = "#F5F6F7",
)
saved[AppearancePreferences.customThemesKey] =
AppearancePreferences.encodeCustomThemes(listOf(custom))
saved[AppearancePreferences.appThemeKey] = custom.appThemeId
val restoredCustom = AppearancePreferences.decode(saved)
assertEquals(custom.id, restoredCustom.customTheme?.id)
assertEquals(AppCompatDelegate.MODE_NIGHT_YES, AppearanceNightMode.nightModeFor(restoredCustom))
}
}
@@ -167,6 +167,79 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("gpt-5.6-sol", viewModel.gatewayCurrentModel.value)
}
@Test
fun coldPickerLoadCanBeForceRefreshedWithoutAChatTurnAndLateResultCannotEraseIt() {
assertTrue(viewModel.modelProviders.value.isEmpty())
gatewayHarness.suppressAckMethods += "model.options"
viewModel.refreshModelOptions(catalogOnly = true)
val coldRequest = gatewayHarness.awaitPendingAck()
assertEquals("model.options", coldRequest.method)
assertTrue(viewModel.modelOptionsLoading.value)
assertFalse(viewModel.modelOptionsRefreshing.value)
assertTrue(gatewayHarness.rpcLog.none { it.first == "prompt.submit" || it.first == "session.create" })
viewModel.refreshModelOptions(refresh = true, catalogOnly = true)
val forcedRequest = gatewayHarness.awaitPendingAck()
assertEquals("model.options", forcedRequest.method)
assertTrue(viewModel.modelOptionsRefreshing.value)
assertEquals(
true,
(gatewayHarness.rpcLog.last { it.first == "model.options" }.second["refresh"] as? JsonPrimitive)?.content == "true",
)
gatewayHarness.releaseAck(forcedRequest, buildJsonObject {
put("providers", buildJsonArray {
add(buildJsonObject {
put("slug", "openai")
put("name", "OpenAI")
put("models", buildJsonArray { add(JsonPrimitive("gpt-5.5")) })
put("authenticated", true)
})
})
put("model", "gpt-5.5")
put("provider", "openai")
})
awaitCondition { viewModel.modelProviders.value.singleOrNull()?.models == listOf("gpt-5.5") }
assertFalse(viewModel.modelOptionsLoading.value)
assertFalse(viewModel.modelOptionsRefreshing.value)
gatewayHarness.releaseAck(coldRequest, buildJsonObject {
put("providers", buildJsonArray {})
})
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertEquals(listOf("gpt-5.5"), viewModel.modelProviders.value.single().models)
viewModel.refreshModelOptions(refresh = true, catalogOnly = true)
val repeatedRefresh = gatewayHarness.awaitPendingAck()
assertEquals("model.options", repeatedRefresh.method)
gatewayHarness.releaseAck(repeatedRefresh, buildJsonObject {
put("providers", buildJsonArray {})
})
awaitCondition { !viewModel.modelOptionsLoading.value }
assertTrue(viewModel.modelProviders.value.isEmpty())
}
@Test
fun retiredConnectionCannotPublishLateColdCatalogOrKeepItsLoadingState() {
gatewayHarness.suppressAckMethods += "model.options"
viewModel.refreshModelOptions(catalogOnly = true)
val staleRequest = gatewayHarness.awaitPendingAck()
assertTrue(viewModel.modelOptionsLoading.value)
viewModel.updateGatewayClient(null)
assertFalse(viewModel.modelOptionsLoading.value)
gatewayHarness.releaseAck(staleRequest, buildJsonObject {
put("providers", buildJsonArray {
add(buildJsonObject {
put("slug", "stale")
put("models", buildJsonArray { add(JsonPrimitive("wrong-model")) })
})
})
})
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertTrue(viewModel.modelProviders.value.isEmpty())
}
@Test
fun attachingReadyGatewayDoesNotHydrateControlStateAheadOfSessions() {
viewModel.updateGatewayClient(null)
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.4.0" apply false
id("com.android.library") version "9.4.0" apply false
id("com.android.application") version "9.4.1" apply false
id("com.android.library") version "9.4.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.20" apply false
}
+10
View File
@@ -16,6 +16,14 @@ subcommands provide scriptable chat, pairing, sessions, daemon management,
grants, diagnostics, and desktop-tool routing. The optional systray is only a
Windows management surface over those same commands and state files.
Secure Link is enabled on the **server**, using Dashboard/Desktop Relay setup or
the host-side `hermes relay secure-link` readiness command. This CLI+UI remains a
client: use `hermes-relay pair --pair-qr '<signed invite>'` to import its authority,
certificate, and pin. A bare address or pairing code cannot establish new Secure
Link trust. The tray and CLI use the same saved host trust; neither enables the
server, rotates its certificate, or treats transport reachability as Dashboard
sign-in. Generate a fresh invite and explicitly re-pair after identity changes.
> **What this is not:** A local Hermes install. Point it at an existing Hermes-Relay server (`ws://host:8767`). For the full TUI with Ink, see the sibling package [`ui-tui`](../../hermes-agent-tui-smoke/ui-tui) in the hermes-agent fork.
## Desktop surfaces
@@ -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
+1
View File
@@ -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
+7 -1
View File
@@ -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"]
}
+7 -3
View File
@@ -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();
}
}
}
_ => {}
+44
View File
@@ -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"])
);
}
+6 -2
View File
@@ -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>
+12
View File
@@ -180,6 +180,18 @@ redacted.
## Current-upstream conformance
The `secure_link_gateway_auth` scenario exercises query and subprotocol ticket
admission through the real Secure Link proxy, single-use rejection, a completed
turn, and live-session activation after reconnect. Run its wire regression with
`python -m unittest plugin.tests.test_secure_proxy_contract`; it also checks
compression boundaries, health coalescing, and bounded Dashboard rewrites.
Pass that scenario's JSON manifest to the conformance checker below to verify
the upstream ticket/public-protocol and live-activation seams. Android's focused
`PluginProxyTransportTest`, `GatewayChatClientTest`, and `RelayVoiceClientRoutingTest`
cover pin rejection, route replacement during an active turn or ticket mint, and
the voice HTTP/WebSocket client selection. These are protocol tests, not physical
device or OEM TLS certification.
Standard Voice receives successful unsolicited assistant answers from live Chat
admission, with a receipt captured before the new assistant placeholder exists.
The receipt belongs to the active voice generation and conversation binding;
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+13
View File
@@ -6,6 +6,19 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Secure Link managed activation
The first guided setup uses shared read-only checks, copyable startup settings,
explicit operator restart, and a re-checked pairing handoff. Add automatic
activation only for explicitly supported service-manager adapters that prove
ownership, preview the exact change and interruption, preserve the prior
configuration, verify the new listener, and roll back a failed activation.
Unknown/embedded managers must retain the guided-command path. Never infer a
service name, change an upstream bind, open firewall ports, rotate keys, or
restart Gateway merely because a user opens setup.
---
## Restore the plugin manifest v2 declaration after the Hermes installer fix ships
Hermes installers in affected stable releases reject `manifest_version: 2`
+5 -3
View File
@@ -370,6 +370,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|-------|--------|---------|
| `/ws`, `/` | GET (upgrade) | Main WebSocket endpoint. Phone connects, sends `system/auth`, then multiplexes `chat`/`terminal`/`bridge` envelopes. |
| `/health` | GET | Returns `{status, version, clients, sessions}` JSON. |
| `/secure-link/preflight` | GET | **Loopback only.** Read-only Secure Link checks for a proposed `host` and `port`, with startup instructions and restart impact. The Dashboard/Desktop setup UI and `hermes relay secure-link` share this report. No configuration or service mutations. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the SessionManager's bounded defaults are used. Client-supplied policy in the WebSocket auth envelope is never authoritative. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
@@ -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`. |
+8
View File
@@ -152,6 +152,14 @@ public `:8767`.
### Hermes Secure Link
Start with the shared read-only setup check: **Relay → Remote Access → Secure
Link** in Dashboard or the Desktop Relay pane, or
`hermes relay secure-link --host <phone-reachable-address> --port 9443` on the
host. It verifies prerequisites, previews environment/startup settings and
restart impact, and requires a re-check of the active listener before offering
the pairing handoff. It does not guess a service manager, write configuration,
open ports, or rotate keys. See the [guided user flow](../user-docs/guide/remote-access.md#optional-hermes-secure-link).
Enable Secure Link when a pairing-pinned unified route is desired. Its
default listener is `https://<host>:9443`; Relay health is
`GET /relay/health`, the authenticated Relay WebSocket is
+42
View File
@@ -15,6 +15,29 @@ must make the listener reachable.
Secure Link is not an arbitrary reverse proxy and does not replace any
service's authentication or authorization.
## Guided setup control surface
`GET /secure-link/preflight` is a loopback-only, read-only Relay operator route.
The authenticated Dashboard/Desktop plugin forwards it through
`GET /remote-access/secure-link/preflight`; `hermes relay secure-link` consumes
the same report from the running Relay. Public `/relay/*` ingress never exposes
this operator endpoint. Client input selects only the proposed listener address
and port; it cannot select a probe upstream or certificate/key file.
The report checks a usable bind address, port availability, existing certificate
identity, fixed loopback upstreams, Dashboard authentication, and restart impact.
It does not mutate configuration, generate/rotate secrets, or manage services.
Instructions preserve the existing service owner and require an explicit operator
restart. Re-checking an active matching origin enables the pairing handoff; a
proposed address alone never enables it. Report readiness is not client sign-in,
network reachability from another device, or Gateway Chat readiness.
Pairing previews derive declared namespaces from validated proxy advertisements
without displaying their certificate/pin or treating ordinary system-TLS probe
failure as proof that the paired route is broken. Signed QR import remains the
client trust ceremony. Unsupported/older Relay setup endpoints fail visibly and
never fall back to browser-side configuration writes.
## Trust boundaries
- The operator-reviewed pairing QR is the first-pair trust ceremony. It must
@@ -58,6 +81,10 @@ service's authentication or authorization.
Relay still requires its normal first-frame pairing/session authentication,
enforces expiry and grants, rate-limits failures, and binds the resulting
connection to that authenticated session.
- Relay-native `/voice/*` HTTP routes and management/session HTTP routes are
not exposed beneath `/relay`. A pinned client alone does not enable them.
Standard voice uses the independently authenticated Dashboard namespace;
native Relay voice requires a separately supported ingress.
- Client-controlled hosts, origins, absolute URLs, proxy headers, redirects,
encoded separators, and path traversal can never select an upstream.
- The external `Host` header is validated against the configured Secure Link
@@ -67,6 +94,17 @@ service's authentication or authorization.
- HTTP request and response bodies are bounded, and upstream connect/read/total
timeouts are finite. Long-lived traffic uses the separately bounded WebSocket
path rather than an unlimited HTTP proxy request.
- Dashboard login HTML and JSON landing paths are rewritten under `/dashboard`.
Rewrites request identity encoding and enforce the response limit while reading,
including chunked responses. An upstream that ignores the identity request and
sends compressed HTML/JSON receives a 502; compressed bytes are never returned
with their encoding header removed.
- Gateway query tickets and ticket subprotocols survive the WebSocket proxy.
Upstream authenticates before the outer upgrade succeeds. Only the selected
public protocol is returned; ticket-bearing protocols are never reflected.
The upstream leg disables compression independently of the downstream leg.
- Public Relay health reads version and counters from the same server instance;
it does not make a second loopback Relay health request.
- Secure Link failing to initialize must not silently advertise a
candidate. It must not make the ordinary Relay unavailable unless the
operator explicitly configured strict startup behavior.
@@ -81,6 +119,10 @@ service's authentication or authorization.
remains enabled; clients never disable certificate validation to learn a pin.
- The pin and each service credential are scoped to the exact host and port.
Redirects or retries outside that authority fail before credentials are sent.
- Android enforces the paired leaf SPKI inside its trust manager even when system
trust succeeds. HTTP and WebSocket requests use the same HTTPS authority guard;
automatic redirects are disabled. Gateway route changes replace the ticket and
socket transport together, discarding a ticket minted for a superseded route.
- A declared Secure Link route fails closed if its pinned client cannot be
built; it must not fall back to a generic TLS or TOFU client.
- UI security labels derive from the validated proxy contract, not from a
+5 -1
View File
@@ -691,6 +691,9 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
**Global unattended-access affordance (v0.4.1).** When master + unattended are both on (sideload only), `UnattendedGlobalBanner` renders as a 28dp amber strip at the top of `RelayApp`'s scaffold on every tab — pulsing dot + "Unattended access ON — agent can wake and drive this device" + chevron → tap navigates to Bridge. Theme-aware colours (amber-on-dark in dark mode, dark-amber-on-pale-amber in light). The banner handles visibility while the user is INSIDE Hermes-Relay; the existing WindowManager `BridgeStatusOverlayChip` handles visibility when the app is BACKGROUNDED. See `docs/decisions.md` §18 for the split rationale.
### Settings Tab
At startup, the first app frame uses one saved Appearance snapshot for its Compose palette, while AppCompat's activity night mode follows that same snapshot. The splash stays up until the snapshot is loaded; Auto follows the system, and fixed or custom presets keep their own light/dark mode.
- **Active agent card (v0.6.0)** — top-of-screen summary card showing the current Connection / Profile / Personality. Tap navigates to Chat and auto-opens the agent sheet via the `openAgentSheet` nav arg, giving Settings-originating users a one-tap path to change agent context without leaving the flow.
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, voice, Pair-readiness, credential-store recovery, history-failure, and rejected-Send evidence without secrets. See `docs/decisions.md` §19.
- **Connection (single-server settings)** — summary-first detail for one Hermes installation. Dashboard/Gateway health drives standard Chat, Manage, Sessions, and Voice readiness. Direct API compatibility and Relay extensions appear as independently optional capabilities. Dashboard/Gateway address and network paths are edited under Routes. Advanced retains only the optional direct API credential, explicit direct Relay endpoint override, and insecure-development controls; missing API or Relay settings never make a healthy Dashboard/Gateway connection look broken. Every Relay QR, enter-code, and show-code method uses the shared connection-scoped Pair flow. Transport security posture and paired-device grants remain visible without leading the normal setup flow with ports or bearer keys.
@@ -738,6 +741,7 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
|-------|--------|---------|
| `/ws`, `/` | GET (upgrade) | WebSocket handler — main multiplexed channel |
| `/health` | GET | Health check — returns `{status, version, clients, sessions}` |
| `/secure-link/preflight` | GET | **Loopback only.** Read-only Secure Link setup checks and startup instructions for a proposed `host` and `port`. No configuration, key, or service mutations. Dashboard/Desktop and the host CLI share this report. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard and CLI/tray pair/repair flows. Optional request field `dashboard_url` is copied into the QR payload for custom dashboard routes. |
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}`. Loopback callers receive the parsed `config.yaml` and absolute path. Remote callers require a relay session bearer and receive only the explicitly public `description` and `model.default` fields with `path: "config.yaml"`; arbitrary provider, platform, integration, and extension sections never cross the remote boundary. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
@@ -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 -1
View File
@@ -1,7 +1,7 @@
[versions]
appVersionName = "1.17.0"
appVersionCode = "57"
agp = "9.4.0"
agp = "9.4.1"
kotlin = "2.4.20"
compose-bom = "2026.09.00"
navigation-compose = "2.10.1"
+59
View File
@@ -24,6 +24,8 @@ import os
import sys
import urllib.error
import urllib.request
import urllib.parse
from argparse import Namespace
# ── hermes pair ───────────────────────────────────────────────────────────────
@@ -154,12 +156,24 @@ def register_relay_cli(subparser) -> None:
"""
sub = subparser.add_subparsers(dest="relay_cmd", required=True)
setup = sub.add_parser("secure-link", help="Check Secure Link readiness and show setup instructions (read-only)")
setup.add_argument("--host", help="LAN, VPN, or DNS address the phone will use")
setup.add_argument("--port", type=int, help="Secure Link HTTPS port (default: current configuration)")
setup.add_argument("--relay-port", type=int, default=None, help="Running Relay loopback port")
setup.add_argument("--json", action="store_true", help="Emit the same readiness report used by Dashboard and Desktop")
setup.set_defaults(func=relay_secure_link_command)
start = sub.add_parser(
"start",
help="Run the Hermes-Relay WSS server (chat + terminal + bridge)",
)
start.add_argument("--host", metavar="HOST", help="Bind address (default: 0.0.0.0)")
start.add_argument("--port", type=int, help="Listen port (default: 8767)")
import argparse
start.add_argument("--secure-link", action=argparse.BooleanOptionalAction, default=None,
help="Enable or disable the optional pinned-TLS listener")
start.add_argument("--secure-link-host", help="Secure Link bind/advertised address")
start.add_argument("--secure-link-port", type=int, help="Secure Link HTTPS port (default: 9443)")
start.add_argument(
"--no-ssl",
action="store_true",
@@ -319,6 +333,45 @@ def relay_doctor_command(args) -> None:
raise SystemExit(code)
def relay_secure_link_command(args: Namespace) -> None:
"""Read the running host's report without enabling, rotating, or restarting."""
relay_port = args.relay_port or int(os.environ.get("RELAY_PORT", "8767"))
if not 1 <= relay_port <= 65535:
raise SystemExit("Relay port must be between 1 and 65535")
query = urllib.parse.urlencode({
key: value for key, value in {"host": args.host, "port": args.port}.items() if value is not None
})
try:
with urllib.request.urlopen(f"http://127.0.0.1:{relay_port}/secure-link/preflight?{query}", timeout=15) as response:
report = json.load(response)
except (urllib.error.URLError, OSError, ValueError) as exc:
raise SystemExit("Secure Link checks are unavailable. Start/update Relay and retry; no configuration was changed.") from exc
if not isinstance(report, dict) or report.get("schema_version") != 1 or not isinstance(report.get("checks"), list):
raise SystemExit("Relay returned an unsupported setup report. Update Relay and retry; no configuration was changed.")
if args.json:
sys.stdout.write(json.dumps(report, indent=2) + "\n")
else:
lines = [f"Secure Link: {report['state'].replace('_', ' ')}"]
if report.get("url"):
lines.append(f"HTTPS origin: {report['url']}")
lines.extend(f"[{c['status']}] {c['label']}: {c['detail']}" for c in report["checks"])
lines.extend([report["restart_notice"], report.get("configuration_note", "")])
if report["ready_to_enable"] and not report["pairing_ready"]:
lines.append("Add these settings to the existing Relay environment, then restart its owner:")
lines.extend(f"{key}={value}" for key, value in report["environment"].items())
lines.extend([
"Or add to the existing Relay startup command: " + " ".join(report["start_arguments"]),
"Re-run this check after restart. Do not launch a second Relay.",
])
if report["pairing_ready"]:
lines.extend(["Create a fresh signed QR: hermes pair --png",
"Scan it, then sign into Dashboard on the client. Listener health is not Chat readiness."])
lines.append("To disable: set RELAY_SECURE_LINK_ENABLED=0 (or use --no-secure-link), then restart Relay's owner.")
sys.stdout.write("\n".join(lines) + "\n")
if not report["ready_to_enable"]:
raise SystemExit(1)
def relay_compat_command(args) -> None:
"""Manage the optional legacy compatibility startup hook."""
from .compat import compat_command
@@ -436,6 +489,12 @@ def relay_start_command(args) -> None:
config.port = args.port
if getattr(args, "webapi_url", None):
config.webapi_url = args.webapi_url
if getattr(args, "secure_link", None) is not None:
config.secure_proxy_enabled = args.secure_link
if getattr(args, "secure_link_host", None):
config.secure_proxy_host = args.secure_link_host
if getattr(args, "secure_link_port", None) is not None:
config.secure_proxy_port = args.secure_link_port
if getattr(args, "log_level", None):
config.log_level = args.log_level
if getattr(args, "shell", None):
+7
View File
@@ -12,6 +12,13 @@ independent of the Hermes-Relay service. It renders a tokenless setup QR contain
standard Dashboard/Gateway connection. Hermes-Relay pairing remains a separate,
explicit **Pair new device** flow.
**Remote Access → Hermes Secure Link → Set up Secure Link** runs the host's
read-only preflight, displays blockers and restart impact, and supplies settings
for the existing Relay owner. **Check again** must confirm the active selected
origin before **Create pairing QR** is offered. This flow does not persist settings
or restart services. The Desktop Relay pane and `hermes relay secure-link` use the
same backend report; Android imports the resulting QR and signs in separately.
## Requirements
- Node.js 18+
+7 -6
View File
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -600,6 +600,7 @@ async def _proxy_get(
path: str,
*,
params: Optional[dict[str, Any]] = None,
timeout: float = _TIMEOUT,
) -> Any:
"""Forward a GET to the relay, translating errors per this module's contract.
@@ -609,7 +610,7 @@ async def _proxy_get(
"""
url = f"{_RELAY_BASE}{path}"
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url, params=params)
except (httpx.TimeoutException, httpx.ConnectError, httpx.TransportError) as err:
raise _relay_unreachable(err) from err
@@ -1090,6 +1091,7 @@ async def get_remote_access_status() -> dict[str, Any]:
secure_link: dict[str, Any] = {
"enabled": False,
"state": "disabled",
"reason": "Hermes Secure Link is not enabled on the Relay host",
}
try:
@@ -1105,6 +1107,7 @@ async def get_remote_access_status() -> dict[str, Any]:
reach = relay_secure_link.get("reach", {}) if isinstance(relay_secure_link, dict) else {}
secure_link = {
"enabled": True,
"state": "enabled",
"role": candidate.get("role"),
"recommended": candidate.get("recommended") is True,
"security": candidate.get("security"),
@@ -1116,9 +1119,16 @@ async def get_remote_access_status() -> dict[str, Any]:
"last_error": reach.get("last_error") if isinstance(reach.get("last_error"), str) else None,
} if isinstance(reach, dict) else {"enabled": False, "state": "disabled"},
}
elif isinstance(relay_health, dict) and isinstance(relay_health.get("secure_link"), dict) and relay_health["secure_link"].get("enabled") is True:
secure_link = {
"enabled": False,
"state": "unavailable",
"reason": "Secure Link is configured but its listener is unavailable. Run setup checks before retrying.",
}
except HTTPException as exc:
secure_link = {
"enabled": False,
"state": "unknown",
"reason": f"Relay status unavailable: {exc.detail}",
}
@@ -1134,6 +1144,14 @@ async def get_remote_access_status() -> dict[str, Any]:
}
@router.get("/remote-access/secure-link/preflight")
async def get_secure_link_preflight(host: str | None = None, port: str | None = None) -> Any:
"""Use the running Relay's read-only checks, not the Dashboard's environment."""
return await _proxy_get("/secure-link/preflight", params={
key: value for key, value in {"host": host, "port": port}.items() if value is not None
}, timeout=15.0)
@router.post("/remote-access/tailscale/enable")
async def tailscale_enable(
body: dict[str, Any] = Body(default_factory=dict),
@@ -0,0 +1,136 @@
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { useState, useRef, useEffect } = SDK.hooks;
const { Input, Label } = SDK.components;
import { getSecureLinkPreflight } from "../lib/api.js";
import { Button, Badge, Alert, AlertTitle, AlertDescription } from "../lib/ui-shims.jsx";
/** Readiness and instructions only: the browser never guesses a service owner. */
export default function SecureLinkSetup({ status, onPair, onInvalidateInvite, pairingBusy = false }) {
const initial = (() => { try { return new URL(status.url); } catch { return null; } })();
const [open, setOpen] = useState(false);
const [host, setHost] = useState(initial?.hostname || "");
const [port, setPort] = useState(initial?.port || "9443");
const [report, setReport] = useState(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const sequence = useRef(0);
useEffect(() => {
sequence.current += 1;
setReport(null);
setBusy(false);
onInvalidateInvite?.();
}, [status.url]);
const change = (setter, value) => {
sequence.current += 1;
setter(value);
setReport(null);
setError("");
setCopied(false);
setBusy(false);
onInvalidateInvite?.();
};
const check = async () => {
const request = ++sequence.current;
setOpen(true);
setBusy(true);
setError("");
setCopied(false);
onInvalidateInvite?.();
try {
const next = await getSecureLinkPreflight({ host: host.trim() || undefined, port });
if (request !== sequence.current) return;
if (next?.schema_version !== 1 || !Array.isArray(next.checks)) throw new Error("Relay returned an unsupported setup report.");
setReport(next);
if (!host.trim() && next.host) setHost(next.host);
} catch (err) {
if (request !== sequence.current) return;
setReport(null);
setError(`Setup checks unavailable. Confirm Relay is running and supports Secure Link setup checks. ${err.message || ""}`);
} finally {
if (request === sequence.current) setBusy(false);
}
};
const environment = Object.entries(report?.environment || {}).map(([key, value]) => `${key}=${value}`).join("\n");
const copy = async () => {
try {
await navigator.clipboard.writeText(environment);
setCopied(true);
} catch { setError("Clipboard access is unavailable. Select and copy the settings below."); }
};
return (
<div className="space-y-3">
{!open ? <Button variant="outline" onClick={check}>{status.enabled ? "Check Secure Link" : "Set up Secure Link"}</Button> : (
<div className="space-y-4 border-t border-border pt-3">
<div className="space-y-2">
<h4 className="text-sm font-semibold">1. Check this server</h4>
<p className="text-xs text-muted-foreground">Checks are read-only. They do not change settings, create keys, or restart services.</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="secure-link-host">Address the phone will use</Label>
<Input id="secure-link-host" value={host} placeholder="192.168.1.20 or relay.example"
onChange={(event) => change(setHost, event.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="secure-link-port">HTTPS port</Label>
<Input id="secure-link-port" value={port} inputMode="numeric"
onChange={(event) => change(setPort, event.target.value)} />
</div>
</div>
<Button size="sm" variant="outline" disabled={busy} onClick={check}>{busy ? "Checking…" : "Check again"}</Button>
</div>
{error ? <Alert variant="destructive"><AlertTitle>Check needed</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
{report ? (
<>
<div role="list" className="space-y-2" aria-live="polite">
{report.checks.map((item) => (
<div role="listitem" key={item.id} className="rounded-md border border-border p-3 space-y-1">
<div className="flex items-center justify-between gap-2 text-sm">
<span>{item.label}</span><Badge variant={item.status === "blocked" ? "destructive" : "outline"}>
{item.status === "ok" ? "Checked" : item.status === "warning" ? "Optional / unavailable" : "Needs attention"}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
))}
</div>
{report.ready_to_enable && !report.pairing_ready ? (
<div className="space-y-2">
<h4 className="text-sm font-semibold">2. Review and enable</h4>
<p className="font-mono text-xs break-all">{report.url}</p>
{report.requires_repair ? <p className="text-xs text-muted-foreground">This changes the paired address or port. Existing clients must re-pair after activation; no certificate is replaced by this check.</p> : null}
<p className="text-xs text-muted-foreground">Add these settings to the existing Relay environment. Restart its owner, then choose Check again. Do not start a second Relay.</p>
<p className="text-xs text-muted-foreground">{report.connected_clients} Relay client(s) connected. {report.restart_notice}</p>
<p className="text-xs text-muted-foreground">{report.configuration_note}</p>
<pre className="rounded-md bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all select-text">{environment}</pre>
<Button size="sm" variant="outline" onClick={copy}>{copied ? "Copied" : "Copy settings"}</Button>
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">Foreground / CLI startup</summary>
<p className="mt-2">Add to your existing <code>hermes relay start</code> command, preserving its other arguments:</p>
<pre className="mt-2 whitespace-pre-wrap break-all select-text">{report.start_arguments.join(" ")}</pre>
</details>
</div>
) : null}
{report.pairing_ready ? (
<div className="space-y-2">
<h4 className="text-sm font-semibold">3. Pair a device</h4>
<p className="font-mono text-xs break-all">{report.current_url}</p>
<p className="text-xs text-muted-foreground">Secure Link is listening. Create a fresh signed QR below, scan it in Android, then sign into Dashboard. Chat becomes ready only after Gateway connects. Existing devices must re-pair to import this certificate and pin.</p>
<Button disabled={pairingBusy || busy} onClick={() => onPair(report.current_url)}>{pairingBusy ? "Creating invite…" : "Create pairing QR"}</Button>
</div>
) : null}
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">Disable or recover</summary>
<p className="mt-2">Set <code>RELAY_SECURE_LINK_ENABLED=0</code> (or replace the startup flag with <code>--no-secure-link</code>), then restart Relay using its existing manager. Keep the certificate and key if you intend to re-enable the same route. Address or certificate changes require explicit re-pairing.</p>
<p className="mt-2">This flow does not restart services or open firewall ports. If activation fails, restore the previous Relay settings and check its health before retrying.</p>
</details>
</>
) : null}
</div>
)}
</div>
);
}
+19 -3
View File
@@ -84,6 +84,13 @@ export function getRemoteAccessStatus() {
return fetchJSON("/remote-access/status");
}
export function getSecureLinkPreflight({ host, port } = {}) {
const query = new URLSearchParams();
if (host !== undefined) query.set("host", host);
if (port !== undefined) query.set("port", port);
return fetchJSON(`/remote-access/secure-link/preflight?${query}`);
}
export function enableTailscale(port) {
return fetchJSON("/remote-access/tailscale/enable", {
method: "POST",
@@ -115,12 +122,21 @@ export function putPublicUrl(url, { legacyDirectRelay = false } = {}) {
});
}
export function probeEndpoints(candidates) {
return fetchJSON("/remote-access/probe", {
export async function probeEndpoints(candidates) {
const entries = Array.isArray(candidates) ? candidates : [];
// A generic server-side TLS probe does not own the recipient's paired
// trust. Do not misreport self-signed Secure Link as broken or disable TLS.
const paired = entries.filter((item) => item.requires_paired_client).map((item) => ({
...item, reachable: null, status: null, latency_ms: null, error: null,
}));
const ordinary = entries.filter((item) => !item.requires_paired_client);
if (!ordinary.length) return { results: paired };
const result = await fetchJSON("/remote-access/probe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidates: Array.isArray(candidates) ? candidates : [] }),
body: JSON.stringify({ candidates: ordinary }),
});
return { ...result, results: [...(result.results || []), ...paired] };
}
/**
+3 -1
View File
@@ -6,6 +6,8 @@ export function pairingQrRenderOptions() {
return {
scale: 4,
margin: 4,
errorCorrectionLevel: "M",
// Match the CLI: certificate-bearing Secure Link invites can exceed the
// largest medium-correction QR even though they fit at low correction.
errorCorrectionLevel: "L",
};
}
+22 -6
View File
@@ -41,21 +41,34 @@ function normalizeRoute(endpoint, index, globalPayload, endpointCount) {
? endpoint.priority
: index;
const fallbackGlobal = endpointCount <= 1;
const issues = [];
const proxy = endpoint?.proxy;
const proxyBase = proxy ? cleanUrl(proxy.url, new Set(["https:"])) : null;
const validPin = typeof proxy?.pin_sha256 === "string" && /^sha256\/[A-Za-z0-9+/]{43}=$/.test(proxy.pin_sha256);
const validProxy = proxyBase && /^https:\/\/[^/?#]+\/?$/i.test(proxy.url.trim()) && validPin &&
typeof proxy.cert_der === "string" && proxy.cert_der.length > 0;
if (proxy && !validProxy) issues.push(`${role}: Secure Link needs a safe HTTPS origin, paired pin, and certificate`);
const protectedServices = new Set(validProxy && Array.isArray(proxy.surfaces) ? proxy.surfaces : []);
const proxyDashboard = protectedServices.has("dashboard") ? `${proxyBase}/dashboard` : null;
const proxyRelay = protectedServices.has("relay") ? `${proxyBase.replace(/^https:/, "wss:")}/relay/ws` : null;
const proxyApi = protectedServices.has("api") ? `${proxyBase}/api` : null;
const dashboardRaw = endpoint && endpoint.dashboard && endpoint.dashboard.url
? endpoint.dashboard.url
: fallbackGlobal ? globalPayload.dashboard_url : null;
: proxyDashboard || (fallbackGlobal ? globalPayload.dashboard_url : null);
const relayRaw = endpoint && endpoint.relay && endpoint.relay.url
? endpoint.relay.url
: fallbackGlobal && globalPayload.relay ? globalPayload.relay.url : null;
: proxyRelay || (fallbackGlobal && globalPayload.relay ? globalPayload.relay.url : null);
const dashboard = cleanUrl(dashboardRaw, HTTP_SCHEMES);
const relay = cleanUrl(relayRaw, RELAY_SCHEMES);
const api = apiUrl(endpoint && endpoint.api ? endpoint.api : fallbackGlobal ? globalPayload : null);
const api = endpoint?.api ? apiUrl(endpoint.api) : proxyApi || apiUrl(fallbackGlobal ? globalPayload : null);
const surfaces = [
dashboard ? { surface: "dashboard", label: "Dashboard", url: dashboard } : null,
relay ? { surface: "relay", label: "Relay", url: relay } : null,
api ? { surface: "api", label: "Direct API", url: api } : null,
].filter(Boolean);
const issues = [];
].filter(Boolean).map((surface) => ({
...surface,
...(validProxy && [proxyDashboard, proxyRelay, proxyApi].includes(surface.url) ? { requiresPairedClient: true } : {}),
}));
if (dashboardRaw && !dashboard) issues.push(`${role}: invalid Dashboard URL`);
if (relayRaw && !relay) issues.push(`${role}: invalid Relay URL`);
@@ -85,7 +98,8 @@ function normalizeRoute(endpoint, index, globalPayload, endpointCount) {
role,
priority,
surfaces,
protection: routeProtection(role, surfaces),
protection: surfaces.length && surfaces.every((s) => s.requiresPairedClient)
? "Secure Link · paired TLS" : routeProtection(role, surfaces),
issues,
};
}
@@ -129,6 +143,7 @@ export function pairingSurfaceProbes(receipt) {
priority: route.priority,
surface: surface.surface,
url: surface.url,
...(surface.requiresPairedClient ? { requires_paired_client: true } : {}),
})),
);
}
@@ -140,6 +155,7 @@ export function pairingProbeKey(entry) {
/** Convert a surface probe into honest, product-facing reachability. */
export function pairingProbeStatus(result) {
if (!result) return { healthy: null, label: "Not checked" };
if (result.requires_paired_client) return { healthy: null, label: "Import QR to verify" };
if (
result.surface === "relay" &&
(result.status === 401 || result.status === 403) &&
+31 -8
View File
@@ -15,6 +15,7 @@ import {
import { relativeTime } from "../lib/formatters.js";
import { canonicalDashboardOrigin } from "../lib/mobile-setup.mjs";
import { pairingQrRenderOptions } from "../lib/pairing-qr.mjs";
import SecureLinkSetup from "../components/SecureLinkSetup.jsx";
import {
classifyPublicRouteInput,
dashboardServeState,
@@ -262,7 +263,7 @@ function TailscaleCard({ status, onEnable, onDisable, busy, resultMessage }) {
);
}
function SecureLinkCard({ status }) {
function SecureLinkCard({ status, onPair, onInvalidateInvite, pairingBusy }) {
const enabled = !!(status && status.enabled);
const url = status && status.url;
const surfaces = Array.isArray(status && status.surfaces) ? status.surfaces : [];
@@ -284,7 +285,7 @@ function SecureLinkCard({ status }) {
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Dot tone={enabled ? "ok" : "muted"} />
<span>{enabled ? "Enabled" : "Not enabled"}</span>
<span>{enabled ? "Enabled" : status?.state === "unavailable" ? "Needs attention" : status?.state === "unknown" ? "Not checked" : "Not enabled"}</span>
{enabled ? <Badge variant="outline">Pinned TLS</Badge> : null}
</div>
{url ? (
@@ -311,6 +312,7 @@ function SecureLinkCard({ status }) {
When enabled, new pairing invites include Secure Link alongside other
reachable candidates. Existing devices must re-pair to trust its certificate pin.
</p>
<SecureLinkSetup status={status || {}} onPair={onPair} onInvalidateInvite={onInvalidateInvite} pairingBusy={pairingBusy} />
</CardContent>
</Card>
);
@@ -663,6 +665,19 @@ export default function RemoteAccess({ autoRefresh }) {
const [reachability, setReachability] = useState([]);
const [publicUrl, setPublicUrl] = useState(null);
const [preferRole, setPreferRole] = useState(null);
const mintSequence = useRef(0);
const inviteRef = useRef(null);
useEffect(() => {
if (mintResult?.qr_payload) {
inviteRef.current?.scrollIntoView({ block: "start" });
inviteRef.current?.focus({ preventScroll: true });
}
}, [mintResult]);
const invalidateInvite = useCallback(() => {
mintSequence.current += 1;
setMintResult(null);
setBusy((current) => current === "mint" ? null : current);
}, []);
const load = useCallback(async () => {
setError(null);
@@ -724,26 +739,32 @@ export default function RemoteAccess({ autoRefresh }) {
// surface the preview by inspecting the endpoints echoed in the
// response shape (``qr_payload`` is the string to scan; we parse it
// to render the preview table).
const onRegenerate = useCallback(async () => {
const onRegenerate = useCallback(async (secureLinkUrl = null) => {
const generation = ++mintSequence.current;
setBusy("mint");
setMintResult(null);
try {
const dashboardUrl = canonicalDashboardOrigin(window.location);
const secureLink = typeof secureLinkUrl === "string";
const dashboardUrl = secureLink ? `${secureLinkUrl}/dashboard` : canonicalDashboardOrigin(window.location);
if (!dashboardUrl) {
throw new Error("This Dashboard does not have a valid HTTP(S) origin for pairing.");
}
const data = await mintPairingWithMode({
mode: "auto",
prefer: preferRole || undefined,
prefer: secureLink ? "plugin_proxy" : preferRole || undefined,
dashboard_url: dashboardUrl,
legacy_direct_relay:
classifyPublicRouteInput(publicUrl || "").kind === "legacy-relay-path",
});
if (generation !== mintSequence.current) return;
if (secureLink && !JSON.parse(data.qr_payload).endpoints?.some((endpoint) => endpoint.proxy?.url === secureLinkUrl)) {
throw new Error("Secure Link changed. Check it again before pairing.");
}
setMintResult(data || null);
} catch (err) {
setMintResult({ error: err && err.message ? err.message : String(err) });
if (generation === mintSequence.current) setMintResult({ error: err && err.message ? err.message : String(err) });
} finally {
setBusy(null);
if (generation === mintSequence.current) setBusy(null);
}
}, [preferRole, publicUrl]);
@@ -830,7 +851,7 @@ export default function RemoteAccess({ autoRefresh }) {
resultMessage={helperMessage}
/>
<SecureLinkCard status={secureLink} />
<SecureLinkCard status={secureLink} onPair={onRegenerate} onInvalidateInvite={invalidateInvite} pairingBusy={busy === "mint"} />
<ExperimentalReachCard status={secureLink} />
@@ -842,6 +863,7 @@ export default function RemoteAccess({ autoRefresh }) {
}}
/>
<div ref={inviteRef} role="region" aria-label="Pairing invite" tabIndex={-1}>
<EndpointPreviewCard
endpoints={previewEndpoints}
reachability={reachability}
@@ -854,6 +876,7 @@ export default function RemoteAccess({ autoRefresh }) {
onPreferChange={setPreferRole}
blockingIssues={previewReceipt && previewReceipt.blockingIssues ? previewReceipt.blockingIssues : []}
/>
</div>
{mintResult && mintResult.error ? (
<Alert variant="destructive">
+46 -1
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import QRCode from "qrcode";
import { pairingQrRenderOptions } from "../src/lib/pairing-qr.mjs";
@@ -15,11 +16,55 @@ test("pairing QR keeps integer modules and a four-module quiet zone", () => {
assert.deepEqual(pairingQrRenderOptions(), {
scale: 4,
margin: 4,
errorCorrectionLevel: "M",
errorCorrectionLevel: "L",
});
assert.equal(Object.hasOwn(pairingQrRenderOptions(), "width"), false);
});
test("certificate-bearing pairing invite fits the Dashboard QR renderer", () => {
const payload = JSON.stringify({
hermes: 2,
endpoints: [{ role: "plugin_proxy", proxy: {
url: "https://relay.example:9443",
cert_der: "a".repeat(2500),
pin_sha256: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
} }],
});
assert.throws(() => QRCode.create(payload, { errorCorrectionLevel: "M" }), /too big/i);
const qr = QRCode.create(payload, pairingQrRenderOptions());
assert.ok(qr.version <= 40);
});
test("Secure Link receipt derives only its advertised namespaces without exposing trust material", () => {
const payload = { endpoints: [{ role: "plugin_proxy", proxy: {
url: "https://relay.example:9443",
pin_sha256: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
cert_der: "public-certificate",
surfaces: ["relay", "dashboard"],
} }] };
const receipt = pairingEndpointReceipt(payload);
assert.deepEqual(receipt.blockingIssues, []);
assert.equal(receipt.routes[0].protection, "Secure Link · paired TLS");
assert.deepEqual(receipt.routes[0].surfaces.map(s => s.url), [
"https://relay.example:9443/dashboard", "wss://relay.example:9443/relay/ws",
]);
const probes = pairingSurfaceProbes(receipt);
assert.ok(probes.every(probe => probe.requires_paired_client));
assert.deepEqual(pairingProbeStatus(probes[0]), { healthy: null, label: "Import QR to verify" });
assert.equal(JSON.stringify(receipt).includes("public-certificate"), false);
assert.equal(JSON.stringify(receipt).includes("sha256/"), false);
payload.endpoints[0].proxy.pin_sha256 = "wrong";
assert.ok(pairingEndpointReceipt(payload).blockingIssues.length);
});
test("Secure Link receipt rejects path traversal rather than normalizing it into an origin", () => {
const receipt = pairingEndpointReceipt({ endpoints: [{ role: "plugin_proxy", proxy: {
url: "https://relay.example/a/..", pin_sha256: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
cert_der: "public-certificate", surfaces: ["relay"],
} }] });
assert.ok(receipt.blockingIssues.length);
});
test("only Dashboard ingress Relay auth challenges are healthy", () => {
assert.deepEqual(
pairingProbeStatus({
@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
const calls = [];
globalThis.window = { __HERMES_PLUGIN_SDK__: {
fetchJSON: async (path, opts) => {
calls.push({ path, opts });
return path.includes("preflight") ? { schema_version: 1 } : { results: [{ surface: "api", reachable: true }] };
},
} };
const { getSecureLinkPreflight, probeEndpoints } = await import("../src/lib/api.js");
test("setup input stays inside a read-only preflight query", async () => {
calls.length = 0;
assert.deepEqual(await getSecureLinkPreflight({ host: "relay.example&port=22", port: "9443" }), { schema_version: 1 });
assert.equal(calls[0].path, "/api/plugins/hermes-relay/remote-access/secure-link/preflight?host=relay.example%26port%3D22&port=9443");
assert.equal(calls[0].opts, undefined);
});
test("pinned routes are not sent to an unpaired system-TLS probe", async () => {
calls.length = 0;
const pinned = { surface: "dashboard", url: "https://relay.example:9443/dashboard", requires_paired_client: true };
const result = await probeEndpoints([pinned]);
assert.equal(calls.length, 0);
assert.equal(result.results[0].reachable, null);
assert.equal(result.results[0].requires_paired_client, true);
const mixed = await probeEndpoints([pinned, { surface: "api", url: "http://192.168.1.20:8642" }]);
assert.equal(JSON.parse(calls[0].opts.body).candidates.length, 1);
assert.equal(mixed.results.length, 2);
});
+20
View File
@@ -1071,6 +1071,26 @@ class RemoteAccessProbeTests(PluginApiTestCase):
class RemoteAccessStatusTests(PluginApiTestCase):
def test_configured_but_failed_secure_link_is_not_reported_as_disabled(self) -> None:
with patch.object(plugin_api, "_proxy_get", new=AsyncMock(return_value={
"secure_link": {"enabled": True, "status": "unavailable"},
})), patch.object(plugin_api, "_tailscale_status_dict", return_value={}), patch.object(plugin_api, "_canonical_upstream_present", return_value=False):
response = self.client.get("/remote-access/status")
self.assertEqual(response.json()["secure_link"]["state"], "unavailable")
self.assertFalse(response.json()["secure_link"]["enabled"])
def test_secure_link_preflight_uses_running_relay_and_preserves_report(self) -> None:
report = {"schema_version": 1, "read_only": True, "state": "needs_attention", "checks": []}
captured = _install_mock_transport(self, lambda request: httpx.Response(200, json=report))
response = self.client.get("/remote-access/secure-link/preflight", params={"host": "relay.example", "port": "9443"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), report)
self.assertEqual(captured[0].url.host, "127.0.0.1")
self.assertEqual(captured[0].url.path, "/secure-link/preflight")
self.assertEqual(captured[0].url.params["host"], "relay.example")
self.assertEqual(captured[0].url.params["port"], "9443")
self.assertEqual(captured[0].extensions["timeout"]["read"], 15.0)
def test_status_surfaces_tailscale_dict_and_public_pin(self) -> None:
# Monkey-patch the tailscale helper so the test doesn't shell out.
from plugin.relay import tailscale as ts_mod
+7
View File
@@ -14,6 +14,13 @@ All backend calls use the official profile-aware `ctx.rest()` namespace and the
existing `dashboard/plugin_api.py` routes. The pane keeps no server state, does
not poll, does not notify, and does not perform network work while closed.
**Remote access → Secure Link setup** uses the running Relay's shared read-only
preflight. It shows address, listener, certificate, upstream-authentication, and
restart checks; provides copyable settings; and creates a signed pairing invite
only after the selected listener is active. It does not manage services or rotate
keys. The Dashboard flow and host-side `hermes relay secure-link` expose the same
report. Use Dashboard or `hermes pair --png` when a phone needs a scannable QR.
## SDK baseline
Implemented against upstream Hermes Desktop source
+107 -2
View File
@@ -18,7 +18,7 @@ import {
useQueryClient,
useValue
} from '@hermes/plugin-sdk'
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
const PLUGIN_ID = 'hermes-relay'
@@ -383,7 +383,111 @@ function createMedia(ctx) {
}
}
export function secureLinkPreflightPath(address, port) {
const query = new URLSearchParams()
if (address.trim()) query.set('host', address.trim())
if (port) query.set('port', String(port))
return `/remote-access/secure-link/preflight?${query}`
}
export function secureLinkPairingBody(report) {
if (!report?.pairing_ready || !report.current_url) throw new Error('Check Secure Link before creating an invite.')
return { mode: 'auto', prefer: 'plugin_proxy', dashboard_url: `${report.current_url}/dashboard` }
}
export function createSecureLinkSetup(ctx) {
return function SecureLinkSetup({ status }) {
const current = (() => { try { return new URL(status?.url) } catch { return null } })()
const [address, setAddress] = useState(current?.hostname || '')
const [port, setPort] = useState(current?.port || '9443')
const [report, setReport] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [invite, setInvite] = useState('')
const [copied, setCopied] = useState(false)
const sequence = useRef(0)
useEffect(() => {
sequence.current += 1
setReport(null); setInvite(''); setError(''); setBusy(false); setCopied(false)
}, [status?.url])
const edit = (setter, value) => {
sequence.current += 1
setter(value); setReport(null); setInvite(''); setError(''); setBusy(false); setCopied(false)
}
const check = async () => {
const request = ++sequence.current
setBusy('check'); setError(''); setInvite(''); setCopied(false)
try {
const value = await ctx.rest(secureLinkPreflightPath(address, port))
if (request !== sequence.current) return
if (value?.schema_version !== 1 || !Array.isArray(value.checks)) throw new Error('Update Relay to use Secure Link setup checks.')
setReport(value)
if (!address && value.host) setAddress(value.host)
} catch (e) { if (request === sequence.current) { setReport(null); setError(friendlyError(e)) } }
finally { if (request === sequence.current) setBusy(false) }
}
const pair = async () => {
const request = ++sequence.current
setBusy('pair'); setError(''); setInvite(''); setCopied(false)
try {
const value = await ctx.rest('/pairing', { method: 'POST', body: secureLinkPairingBody(report) })
if (request !== sequence.current) return
const payload = JSON.parse(value.qr_payload)
if (!payload.endpoints?.some(endpoint => endpoint.proxy?.url === report.current_url)) {
throw new Error('Secure Link changed. Check it again before pairing.')
}
setInvite(value.pairing_url || value.qr_payload)
} catch (e) { if (request === sequence.current) setError(friendlyError(e)) }
finally { if (request === sequence.current) setBusy(false) }
}
const environment = Object.entries(report?.environment || {}).map(([key, value]) => `${key}=${value}`).join('\n')
const copy = async value => {
if (await ctx.os.writeClipboard(value)) setCopied(true)
else setError('Clipboard unavailable. Select and copy the text below.')
}
return jsxs('div', {
className: 'mt-4 space-y-3 rounded-md border border-(--ui-stroke-tertiary) p-3',
children: [
jsx('h3', { className: 'text-sm font-semibold', children: 'Secure Link setup' }),
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: 'Read-only checks and startup instructions. No service is restarted and no certificate is created here.' }),
jsx('label', { htmlFor: 'relay-secure-host', className: 'text-xs', children: 'Address the phone will use' }),
jsx(Input, { id: 'relay-secure-host', value: address, placeholder: '192.168.1.20 or relay.example', onChange: event => edit(setAddress, event.target.value) }),
jsx('label', { htmlFor: 'relay-secure-port', className: 'text-xs', children: 'HTTPS port' }),
jsx(Input, { id: 'relay-secure-port', value: port, inputMode: 'numeric', onChange: event => edit(setPort, event.target.value) }),
jsx(Button, { size: 'sm', variant: 'outline', disabled: !!busy, onClick: check, children: busy === 'check' ? 'Checking…' : report || error ? 'Check again' : 'Check this server' }),
error ? jsx(ErrorState, { title: 'Secure Link check needed', description: error }) : null,
...(report?.checks || []).map(item => jsx(Field, { label: `${item.label} · ${item.status}`, value: item.detail }, item.id)),
report ? jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: `${report.connected_clients} Relay client(s) connected. ${report.restart_notice}` }) : null,
report?.ready_to_enable && !report.pairing_ready ? jsxs('div', { className: 'space-y-2', children: [
jsx(Field, { label: 'Proposed HTTPS origin', value: report.url }),
report.requires_repair ? jsx('p', { className: 'text-xs', children: 'The paired address or port changes. Existing clients must re-pair after activation; this check does not replace certificates.' }) : null,
jsx('p', { className: 'text-xs', children: 'Add these settings to the existing Relay environment, restart its owner, then check again. Do not start a second Relay.' }),
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: report.configuration_note }),
jsx('pre', { className: 'whitespace-pre-wrap break-all select-text text-xs', children: environment }),
jsx(Button, { size: 'sm', variant: 'outline', onClick: () => copy(environment), children: copied ? 'Copied' : 'Copy settings' })
] }) : null,
report?.pairing_ready ? jsxs('div', { className: 'space-y-2', children: [
jsx(Field, { label: 'Listening', value: report.current_url }),
jsx('p', { className: 'text-xs', children: 'Create a fresh signed invite, then sign into Dashboard on the client. A healthy listener does not mean Chat is ready.' }),
jsx(Button, { size: 'sm', disabled: !!busy, onClick: pair, children: busy === 'pair' ? 'Creating invite…' : 'Create pairing invite' }),
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: 'For a scannable phone QR, use the Dashboard setup flow or hermes pair --png on the host.' })
] }) : null,
invite ? jsxs('div', { className: 'space-y-2', children: [
jsx('p', { className: 'text-xs', children: 'One-time invite. Keep it private and pair before it expires.' }),
jsx('textarea', { readOnly: true, rows: 3, value: invite, 'aria-label': 'Secure Link pairing invite', className: 'w-full rounded border border-(--ui-stroke-tertiary) bg-transparent p-2 font-mono text-xs' }),
jsx(Button, { size: 'sm', onClick: () => copy(invite), children: copied ? 'Copied' : 'Copy invite' })
] }) : null,
jsxs('details', { className: 'text-xs text-(--ui-text-tertiary)', children: [
jsx('summary', { children: 'Disable or recover' }),
jsx('p', { children: 'Set RELAY_SECURE_LINK_ENABLED=0 (or use --no-secure-link), then restart the existing Relay owner. Keep its certificate/key to reuse the same route. Certificate or address changes require re-pairing.' })
] })
]
})
}
}
function createRemoteAccess(ctx) {
const SecureLinkSetup = createSecureLinkSetup(ctx)
return function RemoteAccess() {
const t = usePluginI18n(PLUGIN_ID)
const profile = useValue(host.state.profile)
@@ -422,7 +526,7 @@ function createRemoteAccess(ctx) {
children: [
jsx(SectionHeader, {
title: t('remote.title'),
description: 'All changes require a labeled action and a second confirmation.'
description: 'Service changes require confirmation. Secure Link setup checks are read-only.'
}),
jsx(QueryState, {
query: status,
@@ -438,6 +542,7 @@ function createRemoteAccess(ctx) {
jsx(Field, { label: 'Upstream helper', value: data.upstream_canonical ? 'available' : 'not detected' })
]
}),
jsx(SecureLinkSetup, { status: data.secure_link }, profile || 'default'),
jsxs('div', {
className: 'mt-4 flex flex-wrap gap-2',
children: [
+1 -1
View File
@@ -19,7 +19,7 @@ export const useQueryClient = () => ({ invalidateQueries: () => {} })
export const useValue = atom => atom.get()
`
const react = `export const useState = initial => [initial, () => {}]`
const react = `export const useState = initial => [initial, () => {}]; export const useRef = initial => ({ current: initial }); export const useEffect = () => {}`
const jsx = `
export const Fragment = Symbol.for('fixture.fragment')
export const jsx = (type, props) => ({ type, props: props || {} })
+10 -1
View File
@@ -4,10 +4,19 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import plugin, { pairingMintBody, profileQueryKey } from '../plugin.js'
import plugin, { pairingMintBody, profileQueryKey, secureLinkPreflightPath, secureLinkPairingBody } from '../plugin.js'
const here = dirname(fileURLToPath(import.meta.url))
test('Secure Link checks encode inputs and pairing requires verified listener state', () => {
assert.equal(secureLinkPreflightPath('relay.example', 9443), '/remote-access/secure-link/preflight?host=relay.example&port=9443')
assert.equal(secureLinkPreflightPath('relay.example&port=22', 9443), '/remote-access/secure-link/preflight?host=relay.example%26port%3D22&port=9443')
assert.throws(() => secureLinkPairingBody({ pairing_ready: false }), /Check Secure Link/)
assert.deepEqual(secureLinkPairingBody({ pairing_ready: true, current_url: 'https://relay.example:9443' }), {
mode: 'auto', prefer: 'plugin_proxy', dashboard_url: 'https://relay.example:9443/dashboard'
})
})
function harness() {
const contributions = []
const disposed = []
+10 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+249 -2
View File
@@ -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,
+213
View File
@@ -0,0 +1,213 @@
"""Read-only Secure Link setup checks shared by the host CLI and control UIs."""
from __future__ import annotations
import asyncio
import ipaddress
import os
import re
import shutil
import socket
import ssl
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from .secure_proxy import _api_available, _dashboard_gate_enabled, _loopback_http_base
def setup_address(host: str, port: str | int) -> tuple[str, int]:
"""Accept an address, never a URL, command fragment, or unspecified bind."""
host = host.strip()
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
if not host or len(host) > 253:
raise ValueError("Enter the LAN, VPN, or DNS address the phone will use.")
if "%" in host:
raise ValueError("Use an unscoped server address; interface-scoped IPv6 cannot be paired across devices.")
try:
address = ipaddress.ip_address(host)
except ValueError:
if all(label.isdigit() for label in host.split(".")):
raise ValueError("Use a complete IPv4 address or a DNS hostname.") from None
if not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in host.split(".")):
raise ValueError("Enter a hostname or IP address without a scheme, path, or credentials.") from None
host = host.lower()
else:
if address.is_unspecified or address.is_multicast:
raise ValueError("Use a reachable server address, not a wildcard or multicast address.")
host = str(address)
if isinstance(port, bool) or not str(port).isdigit() or not 1 <= int(port) <= 65535:
raise ValueError("Port must be between 1 and 65535.")
return host, int(port)
async def _can_bind(host: str, port: int) -> bool:
try:
addresses = await asyncio.wait_for(
asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM), 2,
)
for family, kind, protocol, _, address in addresses:
try:
if ipaddress.ip_address(address[0]).is_loopback:
continue
with socket.socket(family, kind, protocol) as listener:
listener.bind(address)
return True
except OSError:
continue
except (OSError, asyncio.TimeoutError):
pass
return False
async def _certificate_matches(cert: Path, host: str) -> bool:
try:
ipaddress.ip_address(host)
check = "-checkip"
except ValueError:
check = "-checkhost"
async def inspect(*arguments: str) -> tuple[int | None, bytes]:
process = await asyncio.create_subprocess_exec(
"openssl", "x509", "-in", str(cert), "-noout", *arguments,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
try:
output, _ = await asyncio.wait_for(process.communicate(), 3)
return process.returncode, output
except asyncio.TimeoutError:
process.kill()
await process.wait()
return None, b""
except asyncio.CancelledError:
if process.returncode is None:
process.kill()
await process.wait()
raise
expiry, _ = await inspect("-checkend", "0")
if expiry != 0:
return False
result, message = await inspect(check, host)
# Some supported OpenSSL versions return zero for a hostname mismatch.
return result == 0 and b"does match certificate" in message
async def secure_link_preflight(server: Any, host: str | None = None, port: str | int | None = None) -> dict[str, Any]:
"""Inspect the running Relay's configuration; never write keys or restart it."""
config = server.config
candidate = getattr(server, "secure_proxy_candidate", None)
current_url = candidate.get("proxy", {}).get("url") if isinstance(candidate, dict) else None
current = urlsplit(current_url or "")
proposed_host = host if host is not None else current.hostname or config.secure_proxy_host
if proposed_host in {"0.0.0.0", "::"}:
# Ask for the advertised address instead of guessing which interface a
# phone can reach or turning a bind wildcard into an unusable QR.
proposed_host = ""
checks: list[dict[str, str]] = []
def add(key: str, label: str, status: str, detail: str) -> None:
checks.append({"id": key, "label": label, "status": status, "detail": detail})
try:
address, selected_port = setup_address(proposed_host, port if port is not None else config.secure_proxy_port)
except ValueError as exc:
add("address", "Server address", "blocked", str(exc))
address, selected_port = "", config.secure_proxy_port
url_host = f"[{address}]" if ":" in address else address
proposed_url = f"https://{url_host}:{selected_port}" if address else None
try:
current_address, current_port = setup_address(current.hostname or "", current.port or 443)
except ValueError:
current_address, current_port = "", 0
active = bool(current_url and current.scheme == "https" and current_address == address
and current_port == selected_port and current.path in {"", "/"}
and not any((current.username, current.password, current.query, current.fragment)))
if address:
loopback = address == "localhost"
try:
loopback = loopback or ipaddress.ip_address(address).is_loopback
except ValueError:
pass
add("address", "Server address", "blocked" if loopback else "ok",
"A phone cannot use the server's loopback address. Choose its LAN, VPN, or DNS address."
if loopback else "This is the address the client will pair with. LAN/VPN/public reachability is still required.")
available = active or await _can_bind(address, selected_port)
add("listener", "HTTPS listener", "ok" if available else "blocked",
"The existing Secure Link listener owns this address." if active else
"The address and port are available to this Relay process." if available else
"Relay cannot bind this address and port. Check the local interface, port owner, and permissions.")
async def upstream(raw: str, label: str, key: str, dashboard: bool) -> None:
try:
base = _loopback_http_base(raw, label)
except (ValueError, AttributeError):
add(key, label, "blocked",
f"{label} must use a loopback HTTP upstream for Secure Link. Configure a working local listener first; "
"do not replace a LAN-only address with localhost unless that listener actually accepts loopback connections.")
return
ready = await (_dashboard_gate_enabled(base) if dashboard else _api_available(base))
add(key, label, "ok" if ready else "blocked" if dashboard else "warning",
("Password/OAuth protection is enabled. Clients still sign in separately." if dashboard else "The optional API upstream is reachable.")
if ready else
("Dashboard is unavailable or password/OAuth protection is disabled. Enable its authentication before exposing it."
if dashboard else "The optional API is unavailable; this does not prove Chat or voice is unavailable."))
await asyncio.gather(
upstream(config.webapi_url, "API upstream", "api", False),
upstream(config.secure_proxy_dashboard_url, "Dashboard protection", "dashboard", True),
)
identity = Path(config.hermes_config_path).expanduser().parent / "relay-secure-proxy"
cert = Path(config.secure_proxy_cert).expanduser() if config.secure_proxy_cert else identity / "cert.pem"
key = Path(config.secure_proxy_key).expanduser() if config.secure_proxy_key else identity / "key.pem"
openssl = shutil.which("openssl") is not None
if not openssl:
add("certificate", "Certificate and pin", "blocked", "Install OpenSSL on the Relay host before enabling Secure Link.")
elif cert.exists() or key.exists():
try:
valid = bool(address and cert.is_file() and key.is_file() and os.access(key, os.R_OK)
and await _certificate_matches(cert, address))
if os.name == "posix" and key.is_file() and key.stat().st_mode & 0o077:
valid = False
if valid:
# Also reject mismatched or encrypted keys without prompting.
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(cert, key, password="")
except OSError:
valid = False
add("certificate", "Certificate and pin", "ok" if valid else "blocked",
"Existing certificate matches this address and is not expired. No identity was changed." if valid else
"Check the existing certificate/key pair: it must be readable, match this address, be unexpired, and keep the private key owner-only. Review rotation and re-pairing if the identity must change; this check never replaces keys.")
else:
parents = [cert.parent, key.parent]
for index, parent in enumerate(parents):
while not parent.exists() and parent != parent.parent:
parent = parent.parent
parents[index] = parent
writable = all(os.access(parent, os.W_OK) for parent in parents)
add("certificate", "Certificate and pin", "ok" if writable else "blocked",
"Relay will create a local certificate and pin on enablement. Nothing has been generated by this check."
if writable else "Relay cannot write its certificate directory. Fix its service-user permissions first.")
ready = not any(check["status"] == "blocked" for check in checks)
return {
"schema_version": 1,
"state": "enabled" if active and ready else "ready_to_configure" if ready else "needs_attention",
"read_only": True,
"url": proposed_url,
"current_url": current_url,
"host": address,
"port": selected_port,
"checks": checks,
"ready_to_enable": ready,
"pairing_ready": active and ready,
"requires_repair": bool(current_url and not active),
"connected_clients": server.client_count,
"activation_mode": "instructions",
"restart_notice": "Restarting Relay briefly disconnects its clients. Use the manager that already owns Relay; embedded installations may also require a Gateway restart.",
"configuration_note": "Startup flags override environment settings. Update any existing --secure-link, --no-secure-link, --secure-link-host, and --secure-link-port flags to match the intended settings.",
"environment": {
"RELAY_SECURE_LINK_ENABLED": "1", "RELAY_SECURE_LINK_HOST": address,
"RELAY_SECURE_LINK_PORT": str(selected_port),
} if ready else {},
"start_arguments": ["--secure-link", "--secure-link-host", address, "--secure-link-port", str(selected_port)] if ready else [],
"disable_environment": {"RELAY_SECURE_LINK_ENABLED": "0"},
"pair_command": "hermes pair --png" if active and ready else None,
}
+208 -18
View File
@@ -12,6 +12,7 @@ import asyncio
import base64
import hashlib
import ipaddress
import json
import os
import ssl
import subprocess
@@ -24,6 +25,8 @@ from urllib.parse import urlsplit
import aiohttp
from aiohttp import web
from . import __version__
if TYPE_CHECKING:
from .server import RelayServer
@@ -234,9 +237,21 @@ def _scope_dashboard_cookie(value: str) -> str:
def _rewrite_dashboard_location(value: str, upstream_base: str) -> str | None:
"""Map same-Dashboard redirects under /dashboard; reject unsafe HTTP hops."""
"""Map same-Dashboard redirects under /dashboard; reject unsafe HTTP hops.
Upstream may already emit `/dashboard/...` when it honors
`X-Forwarded-Prefix: /dashboard`. Prefix only bare same-origin paths so we
never produce `/dashboard/dashboard/...`.
"""
def _under_dashboard(path_and_query: str) -> str:
if path_and_query == "/dashboard" or path_and_query.startswith("/dashboard/"):
return path_and_query
if not path_and_query.startswith("/"):
path_and_query = "/" + path_and_query
return "/dashboard" + path_and_query
if value.startswith("/") and not value.startswith("//"):
return "/dashboard" + value
return _under_dashboard(value)
target = urlsplit(value)
upstream = urlsplit(upstream_base)
if (
@@ -247,13 +262,81 @@ def _rewrite_dashboard_location(value: str, upstream_base: str) -> str | None:
suffix = target.path or "/"
if target.query:
suffix += "?" + target.query
return "/dashboard" + suffix
return _under_dashboard(suffix)
if target.scheme == "https" and target.hostname:
# OAuth providers intentionally leave the Hermes origin.
return value
return None
# Login HTML hard-codes root-absolute paths (fetch('/auth/password-login'),
# href="/auth/login", assign('/')). Behind Secure Link the public origin is
# /dashboard/*, so those hit bare /auth/* → 404 and the form shows
# "Sign-in failed. Please try again." Rewrite only known auth roots.
_DASHBOARD_HTML_ROOT_REWRITES: tuple[tuple[bytes, bytes], ...] = (
(b"fetch('/auth/", b"fetch('/dashboard/auth/"),
(b'fetch("/auth/', b'fetch("/dashboard/auth/'),
(b'href="/auth/', b'href="/dashboard/auth/'),
(b"href='/auth/", b"href='/dashboard/auth/"),
(b'href="/login', b'href="/dashboard/login'),
(b"href='/login", b"href='/dashboard/login"),
(b"url('/fonts/", b"url('/dashboard/fonts/"),
(b'url("/fonts/', b'url("/dashboard/fonts/'),
# password-login success fallback when JSON next is missing
(
b"window.location.assign((data && data.next) || '/');",
b"window.location.assign((data && data.next) || '/dashboard/');",
),
(
b'window.location.assign((data && data.next) || "/");',
b'window.location.assign((data && data.next) || "/dashboard/");',
),
)
def _under_dashboard_path(path: str) -> str:
if path == "/dashboard" or path == "/dashboard/" or path.startswith("/dashboard/"):
return path if path != "/dashboard" else "/dashboard/"
if not path.startswith("/"):
return path
if path == "/":
return "/dashboard/"
return "/dashboard" + path
def _rewrite_dashboard_json_next(body: bytes) -> bytes:
"""Prefix JSON ``next`` landing paths for password-login responses."""
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
return body
if not isinstance(payload, dict) or "next" not in payload:
return body
nxt = payload.get("next")
if not isinstance(nxt, str) or not nxt.startswith("/") or nxt.startswith("//"):
return body
# Native loopback redirects stay absolute http://127.0.0.1 — untouched above.
rewritten = _under_dashboard_path(nxt)
if rewritten == nxt:
return body
payload["next"] = rewritten
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
def _rewrite_dashboard_body(content_type: str, body: bytes) -> bytes:
"""Fix root-absolute dashboard auth URLs for the /dashboard Secure Link mount."""
lowered = content_type.lower()
if "application/json" in lowered:
return _rewrite_dashboard_json_next(body)
if "text/html" not in lowered:
return body
out = body
for old, new in _DASHBOARD_HTML_ROOT_REWRITES:
if old in out:
out = out.replace(old, new)
return out
async def _dashboard_gate_enabled(base: str) -> bool:
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as client:
@@ -305,6 +388,14 @@ async def _proxy_http(
dashboard=dashboard,
forwarded_host=forwarded_host,
)
# Plain bodies so we can rewrite login HTML/JSON under /dashboard.
if dashboard:
headers = {
name: value
for name, value in headers.items()
if name.lower() != "accept-encoding"
}
headers["Accept-Encoding"] = "identity"
timeout = aiohttp.ClientTimeout(
total=PROXY_HTTP_TOTAL_TIMEOUT_SECONDS,
connect=5,
@@ -323,6 +414,25 @@ async def _proxy_http(
and response.content_length > MAX_PROXY_RESPONSE_BYTES
):
raise web.HTTPBadGateway(text="upstream response exceeds secure limit")
content_type = response.headers.get("Content-Type", "")
needs_body_rewrite = dashboard and (
"text/html" in content_type.lower()
or "application/json" in content_type.lower()
)
upstream_body = b""
if needs_body_rewrite:
# The rewrite requires identity bytes. Never remove an encoding
# header from a body the upstream compressed despite our request.
if response.headers.get("Content-Encoding", "identity").lower() != "identity":
raise web.HTTPBadGateway(text="unexpected encoded Dashboard response")
buffered = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
if len(buffered) + len(chunk) > MAX_PROXY_RESPONSE_BYTES:
raise web.HTTPBadGateway(text="upstream response exceeds secure limit")
buffered.extend(chunk)
upstream_body = _rewrite_dashboard_body(content_type, bytes(buffered))
if len(upstream_body) > MAX_PROXY_RESPONSE_BYTES:
raise web.HTTPBadGateway(text="upstream response exceeds secure limit")
forwarded: list[tuple[str, str]] = []
for raw_name, raw_value in response.raw_headers:
name = raw_name.decode("latin1")
@@ -330,6 +440,15 @@ async def _proxy_http(
lowered = name.lower()
if lowered in _HOP_HEADERS or (not dashboard and lowered == "set-cookie"):
continue
if needs_body_rewrite and lowered in {
"content-length",
"content-encoding",
"transfer-encoding",
"etag",
"content-md5",
"digest",
}:
continue
if dashboard and lowered == "set-cookie":
value = _scope_dashboard_cookie(value)
elif dashboard and lowered == "location":
@@ -338,6 +457,14 @@ async def _proxy_http(
continue
value = rewritten
forwarded.append((name, value))
if needs_body_rewrite:
forwarded.append(("Content-Length", str(len(upstream_body))))
downstream = web.Response(
status=response.status,
headers=forwarded,
body=upstream_body,
)
return downstream
downstream = web.StreamResponse(status=response.status, headers=forwarded)
await downstream.prepare(request)
response_size = 0
@@ -352,32 +479,79 @@ async def _proxy_http(
async def _proxy_websocket(
request: web.Request, upstream_url: str, headers: dict[str, str]
request: web.Request, upstream_url: str, headers: dict[str, str],
) -> web.StreamResponse:
downstream = web.WebSocketResponse(heartbeat=30, max_msg_size=4 * 1024 * 1024)
await downstream.prepare(request)
# aiohttp's client handshake must own Sec-WebSocket-*; forwarding the
# caller's key/extensions confuses the upstream upgrade.
safe_headers = {
name: value
for name, value in headers.items()
if not name.lower().startswith("sec-websocket-")
and name.lower() not in {
"content-length",
"content-type",
"content-encoding",
}
}
protocols = [
protocol.strip()
for value in request.headers.getall("Sec-WebSocket-Protocol", [])
for protocol in value.split(",")
if protocol.strip()
]
downstream = web.WebSocketResponse(heartbeat=30, max_msg_size=4 * 1024 * 1024, protocols=protocols)
if not downstream.can_prepare(request).ok:
raise web.HTTPBadRequest(text="WebSocket upgrade required")
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=None, connect=5)
timeout=aiohttp.ClientTimeout(total=None, connect=5),
) as client:
async with client.ws_connect(
upstream_url, heartbeat=30, max_msg_size=4 * 1024 * 1024,
headers=headers,
upstream_url,
heartbeat=30,
max_msg_size=4 * 1024 * 1024,
headers=safe_headers,
protocols=protocols,
# Disable permessage-deflate on the upstream leg. Negotiating
# compression independently on phone↔proxy and proxy↔dashboard
# produced WS close 1002 (protocol error) right after
# gateway.ready, so chat stayed on "checking gateway".
compress=0,
) as upstream:
# Finish authentication/negotiation upstream first. Echo only
# its selected public protocol, never a ticket credential.
selected = upstream.protocol
if selected and selected.startswith("hermes-gateway-ticket."):
raise web.HTTPBadGateway(text="upstream selected a credential protocol")
downstream = web.WebSocketResponse(
heartbeat=30,
max_msg_size=4 * 1024 * 1024,
protocols=[selected] if selected else [],
)
await downstream.prepare(request)
async def forward(source, target) -> None:
async for message in source:
if message.type == aiohttp.WSMsgType.TEXT:
await target.send_str(message.data)
elif message.type == aiohttp.WSMsgType.BINARY:
await target.send_bytes(message.data)
tasks = [asyncio.create_task(forward(downstream, upstream)),
asyncio.create_task(forward(upstream, downstream))]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
tasks = [
asyncio.create_task(forward(downstream, upstream)),
asyncio.create_task(forward(upstream, downstream)),
]
done, pending = await asyncio.wait(
tasks, return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*done, *pending, return_exceptions=True)
await downstream.close()
except (aiohttp.ClientError, asyncio.TimeoutError):
await downstream.close(code=1011, message=b"upstream unavailable")
if not downstream.prepared:
raise web.HTTPBadGateway(text="upstream unavailable")
if not downstream.closed:
await downstream.close(code=1011, message=b"upstream unavailable")
return downstream
@@ -447,7 +621,11 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
services["api"]["available"] = api_available
services["dashboard"]["available"] = dashboard_available
return web.json_response({
"status": "ok", "surface": "hermes_secure_proxy",
"status": "ok",
"version": __version__,
"clients": server.client_count,
"sessions": server.sessions.active_count(),
"surface": "hermes_secure_proxy",
"display_name": SECURE_LINK_NAME,
"description": SECURE_LINK_DESCRIPTION,
"security": "pinned_tls",
@@ -470,8 +648,9 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
tail = _safe_tail(request, "/api")
if tail is None:
raise web.HTTPBadRequest(text="unsafe proxy path")
# Empty tail is bare /api — same as dashboard, land on upstream "/".
try:
return await _proxy_http(request, f"{api_base}{tail}")
return await _proxy_http(request, f"{api_base}{tail or '/'}")
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise web.HTTPBadGateway(text="API upstream unavailable") from exc
@@ -491,9 +670,15 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
forwarded_host=secure_link_authority,
)
if request.headers.get("Upgrade", "").lower() == "websocket":
return await _proxy_websocket(
request, upstream.replace("http://", "ws://", 1), headers
)
# Gateway chat WS carries auth as ?ticket=… (and optional profile).
# Dropping the query string makes upstream reject the upgrade and the
# proxy surfaces that as close 1011 "upstream unavailable".
qs = request.query_string
ws_url = upstream.replace("http://", "ws://", 1)
if qs:
sep = "&" if "?" in ws_url else "?"
ws_url = f"{ws_url}{sep}{qs}"
return await _proxy_websocket(request, ws_url, headers)
try:
return await _proxy_http(
request,
@@ -506,7 +691,12 @@ def create_secure_proxy_app(server: "RelayServer") -> web.Application:
app.router.add_get("/relay/health", health, allow_head=True)
app.router.add_get("/relay/ws", relay_ws)
# Bare /api and /dashboard (no trailing slash) must resolve — browsers and
# clients often omit the slash; aiohttp's /{tail:.*} pattern does not match
# the prefix alone and previously returned a plain 404.
app.router.add_route("*", "/api", api_proxy)
app.router.add_route("*", "/api/{tail:.*}", api_proxy)
app.router.add_route("*", "/dashboard", dashboard_proxy)
app.router.add_route("*", "/dashboard/{tail:.*}", dashboard_proxy)
return app
+75 -41
View File
@@ -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
)
+21
View File
@@ -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)
+61 -6
View File
@@ -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:
+59
View File
@@ -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"])
+13
View File
@@ -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"])
+87
View File
@@ -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:
+198 -4
View File
@@ -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:
+45 -1
View File
@@ -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(
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import argparse
import io
import json
import tempfile
import shutil
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import web
from plugin import cli
from plugin.relay.config import RelayConfig
from plugin.relay.secure_link_setup import secure_link_preflight, setup_address, _certificate_matches
from plugin.relay.secure_proxy import ensure_tls_identity
from plugin.relay.server import _on_secure_proxy_startup, handle_secure_link_preflight
class SetupAddressTests(unittest.TestCase):
def test_address_is_not_a_command_url_or_unspecified_bind(self) -> None:
for host in ["", "0.0.0.0", "::", "224.0.0.1", "127.1", "https://relay.example", "a..example", "a;id", "a\nb", "user@host", "fe80::1%eth0;id", "relay.example]"]:
with self.subTest(host=host), self.assertRaises(ValueError):
setup_address(host, 9443)
for port in [0, 65536, True, "12;id", "3.5"]:
with self.subTest(port=port), self.assertRaises(ValueError):
setup_address("relay.example", port)
self.assertEqual(setup_address("[2001:db8::1]", "9443"), ("2001:db8::1", 9443))
self.assertEqual(setup_address("Relay.Example", 9443), ("relay.example", 9443))
class SecureLinkSetupTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.config = RelayConfig(hermes_config_path=str(Path(self.temp.name) / "config.yaml"))
self.server = SimpleNamespace(config=self.config, client_count=2, secure_proxy_candidate=None)
self.api = self.enterContext(patch("plugin.relay.secure_link_setup._api_available", new=AsyncMock(return_value=True)))
self.gate = self.enterContext(patch("plugin.relay.secure_link_setup._dashboard_gate_enabled", new=AsyncMock(return_value=True)))
self.bind = self.enterContext(patch("plugin.relay.secure_link_setup._can_bind", new=AsyncMock(return_value=True)))
self.enterContext(patch("plugin.relay.secure_link_setup.shutil.which", return_value="openssl"))
async def report(self, **kwargs):
return await secure_link_preflight(self.server, **{"host": "192.0.2.10", **kwargs})
async def test_readiness_does_not_enable_write_keys_or_claim_chat_ready(self) -> None:
before = vars(self.config).copy()
result = await self.report()
self.assertEqual(result["state"], "ready_to_configure")
self.assertTrue(result["read_only"])
self.assertFalse(result["pairing_ready"])
self.assertEqual(result["connected_clients"], 2)
self.assertEqual(result["activation_mode"], "instructions")
self.assertEqual(vars(self.config), before)
self.assertEqual(list(Path(self.temp.name).iterdir()), [])
self.assertNotIn("key.pem", json.dumps(result))
self.assertNotIn("certificate_pin", json.dumps(result))
async def test_wildcard_configuration_requires_an_advertised_address(self) -> None:
result = await secure_link_preflight(self.server)
self.assertEqual(result["state"], "needs_attention")
self.assertEqual(result["environment"], {})
self.bind.assert_not_awaited()
async def test_non_loopback_upstream_is_not_probed_or_silently_rewritten(self) -> None:
self.config.webapi_url = "http://192.0.2.10:8642"
result = await self.report()
self.assertFalse(result["ready_to_enable"])
self.api.assert_not_awaited()
self.assertEqual(self.config.webapi_url, "http://192.0.2.10:8642")
self.assertIn("unless that listener actually accepts loopback", next(c["detail"] for c in result["checks"] if c["id"] == "api"))
async def test_dashboard_auth_required_and_optional_api_failure_are_distinct(self) -> None:
self.api.return_value = False
result = await self.report()
self.assertTrue(result["ready_to_enable"])
self.assertEqual(next(c["status"] for c in result["checks"] if c["id"] == "api"), "warning")
self.gate.return_value = False
result = await self.report()
self.assertFalse(result["ready_to_enable"])
async def test_port_ownership_and_active_address_gate_qr_handoff(self) -> None:
self.bind.return_value = False
self.assertFalse((await self.report())["pairing_ready"])
self.server.secure_proxy_candidate = {"proxy": {"url": "https://192.0.2.10:9443"}}
result = await self.report()
self.assertEqual(result["state"], "enabled")
self.assertTrue(result["pairing_ready"])
changed = await self.report(port=9444)
self.assertFalse(changed["pairing_ready"])
async def test_incomplete_or_wrong_host_identity_never_rotates_keys(self) -> None:
cert = Path(self.temp.name) / "cert.pem"
key = Path(self.temp.name) / "key.pem"
cert.write_text("existing public certificate")
self.config.secure_proxy_cert, self.config.secure_proxy_key = str(cert), str(key)
self.assertFalse((await self.report())["ready_to_enable"])
key.write_text("test private material")
with patch("plugin.relay.secure_link_setup._certificate_matches", new=AsyncMock(return_value=False)):
report = await self.report()
self.assertFalse(report["ready_to_enable"])
self.assertNotIn("test private material", json.dumps(report))
self.assertEqual(key.read_text(), "test private material")
async def test_public_relay_caller_cannot_inspect_operator_configuration(self) -> None:
request = SimpleNamespace(remote="192.0.2.44")
with self.assertRaises(web.HTTPForbidden):
await handle_secure_link_preflight(request)
async def test_bad_secure_link_configuration_does_not_abort_ordinary_relay_startup(self) -> None:
self.config.webapi_url = "http://192.0.2.10:8642"
self.config.secure_proxy_cert = "unused-cert"
self.config.secure_proxy_key = "unused-key"
self.server.secure_proxy_candidate = {"proxy": {"url": "https://192.0.2.10:9443"}}
await _on_secure_proxy_startup({"server": self.server})
self.assertIsNone(self.server.secure_proxy_candidate)
@unittest.skipUnless(shutil.which("openssl"), "OpenSSL required")
class SetupCertificateTests(unittest.IsolatedAsyncioTestCase):
async def test_existing_certificate_must_match_selected_address(self) -> None:
with tempfile.TemporaryDirectory() as directory:
cert, key = Path(directory) / "cert.pem", Path(directory) / "key.pem"
ensure_tls_identity(cert, key, "192.0.2.10")
before = cert.read_bytes(), key.read_bytes()
self.assertTrue(await _certificate_matches(cert, "192.0.2.10"))
self.assertFalse(await _certificate_matches(cert, "192.0.2.11"))
self.assertEqual((cert.read_bytes(), key.read_bytes()), before)
class SecureLinkCliTests(unittest.TestCase):
def test_cli_reads_shared_report_and_registers_start_flags(self) -> None:
parser = argparse.ArgumentParser()
cli.register_relay_cli(parser)
args = parser.parse_args(["secure-link", "--host", "relay.example", "--port", "9443", "--json"])
response = MagicMock()
report = {"schema_version": 1, "checks": [], "ready_to_enable": True, "state": "ready_to_configure", "read_only": True}
response.__enter__.return_value = io.StringIO(json.dumps(report))
with patch("urllib.request.urlopen", return_value=response) as request, redirect_stdout(io.StringIO()) as out:
args.func(args)
self.assertEqual(json.loads(out.getvalue()), report)
self.assertEqual(request.call_args.args[0], "http://127.0.0.1:8767/secure-link/preflight?host=relay.example&port=9443")
start = parser.parse_args(["start", "--secure-link", "--secure-link-host", "relay.example", "--secure-link-port", "9443"])
self.assertTrue(start.secure_link)
self.assertEqual(start.secure_link_host, "relay.example")
+45 -1
View File
@@ -21,6 +21,7 @@ from plugin.relay.secure_proxy import (
spki_pin_sha256,
_forward_headers,
_scope_dashboard_cookie,
_rewrite_dashboard_body,
_rewrite_dashboard_location,
)
from plugin.relay.server import (
@@ -35,6 +36,8 @@ from plugin.relay.server import (
class SecureProxyRouteTests(AioHTTPTestCase):
async def get_application(self):
self.server_state = RelayServer(RelayConfig())
self.enterContext(patch("plugin.relay.secure_proxy._api_available", new=AsyncMock(return_value=False)))
self.enterContext(patch("plugin.relay.secure_proxy._dashboard_gate_enabled", new=AsyncMock(return_value=False)))
return create_secure_proxy_app(self.server_state)
async def asyncTearDown(self) -> None:
@@ -48,6 +51,8 @@ class SecureProxyRouteTests(AioHTTPTestCase):
self.assertEqual(body["surface"], "hermes_secure_proxy")
self.assertEqual(body["display_name"], SECURE_LINK_NAME)
self.assertEqual(body["security"], "pinned_tls")
self.assertIn("version", body)
self.assertTrue(str(body["version"]).strip())
self.assertEqual(body["capabilities"], ["relay", "api", "dashboard"])
self.assertEqual(body["namespaces"], ["relay", "api", "dashboard"])
self.assertEqual(body["services"]["relay"]["websocket_path"], "/relay/ws")
@@ -59,12 +64,17 @@ class SecureProxyRouteTests(AioHTTPTestCase):
for path in (
"/relay/sessions",
"/relay/voice/config",
"/relay/desktop/_ping", "/relay/pairing/register", "/health",
):
response = await self.client.get(path)
self.assertEqual(response.status, 404, path)
self.assertEqual((await self.client.get("/api/health")).status, 502)
# Deterministic failures; never depend on a developer's local services.
from aiohttp import ClientConnectionError
with patch("plugin.relay.secure_proxy._proxy_http", new=AsyncMock(side_effect=ClientConnectionError)):
self.assertEqual((await self.client.get("/api/health")).status, 502)
self.assertEqual((await self.client.get("/dashboard")).status, 503)
self.assertEqual((await self.client.get("/dashboard/")).status, 503)
async def test_mutating_health_is_rejected(self) -> None:
@@ -327,6 +337,18 @@ class SecureProxyAdvertisementTests(unittest.TestCase):
),
"/dashboard/auth/callback?code=x",
)
# Upstream that already honored X-Forwarded-Prefix must not double-prefix.
self.assertEqual(
_rewrite_dashboard_location("/dashboard/login", upstream),
"/dashboard/login",
)
self.assertEqual(
_rewrite_dashboard_location(
"http://127.0.0.1:9119/dashboard/auth/callback?code=x",
upstream,
),
"/dashboard/auth/callback?code=x",
)
self.assertEqual(
_rewrite_dashboard_location("https://idp.example/authorize", upstream),
"https://idp.example/authorize",
@@ -335,6 +357,28 @@ class SecureProxyAdvertisementTests(unittest.TestCase):
_rewrite_dashboard_location("http://attacker.example/", upstream)
)
def test_dashboard_html_and_json_auth_paths_are_scoped(self) -> None:
html = (
b"<script>fetch('/auth/password-login');"
b"window.location.assign((data && data.next) || '/');</script>"
)
rewritten = _rewrite_dashboard_body("text/html; charset=utf-8", html)
self.assertIn(b"fetch('/dashboard/auth/password-login')", rewritten)
self.assertIn(
b"window.location.assign((data && data.next) || '/dashboard/');",
rewritten,
)
payload = json.dumps({"ok": True, "next": "/"}).encode("utf-8")
out = _rewrite_dashboard_body("application/json", payload)
self.assertEqual(json.loads(out.decode("utf-8"))["next"], "/dashboard/")
already = json.dumps({"ok": True, "next": "/dashboard/sessions"}).encode(
"utf-8"
)
self.assertEqual(
_rewrite_dashboard_body("application/json", already),
already,
)
def test_auth_ok_does_not_replace_operator_reviewed_endpoints(self) -> None:
server = RelayServer(RelayConfig())
server.secure_proxy_candidate = advertised_candidate(
+214
View File
@@ -0,0 +1,214 @@
"""Loopback wire tests for the Secure Link Dashboard/Gateway boundary."""
from __future__ import annotations
import asyncio
import gzip
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from aiohttp import WSMsgType, WSServerHandshakeError, web
from aiohttp.test_utils import TestClient, TestServer
from plugin.relay import __version__, secure_proxy
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "test-fixtures" / "vanilla-gateway"))
from vanilla_gateway import GatewayFixture, load_scenario # noqa: E402
class SecureProxyContractTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.seen: list[dict[str, object]] = []
self.inner_health_calls = 0
self.gate_calls = 0
self.stream_release = asyncio.Event()
self.stream_bytes = 0
self.tickets = {"query-ticket", "protocol-ticket"}
app = web.Application()
app.router.add_get("/api/health", self.gate)
app.router.add_get("/health", self.inner_health)
app.router.add_get("/api/ws", self.gateway)
app.router.add_get("/chunked", self.chunked)
app.router.add_get("/compressed", self.compressed)
app.router.add_post("/auth/password-login", self.login)
self.upstream = TestServer(app)
await self.upstream.start_server()
base = str(self.upstream.make_url("/")).rstrip("/")
self.state = SimpleNamespace(
config=SimpleNamespace(
webapi_url=base,
secure_proxy_dashboard_url=base,
secure_proxy_host="localhost",
secure_proxy_port=9443,
port=self.upstream.port,
),
secure_proxy_candidate=None,
client_count=4,
sessions=SimpleNamespace(active_count=lambda: 7),
)
self.client = TestClient(TestServer(secure_proxy.create_secure_proxy_app(self.state)))
await self.client.start_server()
async def asyncTearDown(self) -> None:
await self.client.close()
await self.upstream.close()
async def gate(self, request: web.Request) -> web.Response:
self.gate_calls += 1
return web.json_response({"auth_required": True})
async def inner_health(self, request: web.Request) -> web.Response:
self.inner_health_calls += 1
return web.json_response({"status": "ok"})
async def gateway(self, request: web.Request) -> web.WebSocketResponse:
# Current upstream requires the public protocol alongside exactly one
# ticket protocol, and selects only the public protocol on admission.
protocols = [p.strip() for p in request.headers.get("Sec-WebSocket-Protocol", "").split(",")]
credentials = [p.removeprefix("hermes-gateway-ticket.") for p in protocols
if p.startswith("hermes-gateway-ticket.")]
if credentials and (len(credentials) != 1 or "hermes-gateway-v1" not in protocols):
raise web.HTTPForbidden()
ticket = credentials[0] if credentials else request.query.get("ticket")
if ticket not in self.tickets:
raise web.HTTPForbidden()
self.tickets.remove(ticket)
self.seen.append({
"profile": request.query.get("profile"),
"extensions": request.headers.get("Sec-WebSocket-Extensions"),
"protocols": protocols,
})
ws = web.WebSocketResponse(protocols=["hermes-gateway-v1"] if credentials else [])
await ws.prepare(request)
await ws.send_str("gateway.ready")
async for message in ws:
if message.type == WSMsgType.TEXT:
await ws.send_str(message.data)
elif message.type == WSMsgType.BINARY:
await ws.send_bytes(message.data)
return ws
async def chunked(self, request: web.Request) -> web.StreamResponse:
response = web.StreamResponse(headers={"Content-Type": "application/json"})
await response.prepare(request)
try:
# Hold EOF until the client receives the size-limit rejection.
for _ in range(3):
await response.write(b" " * 1024)
self.stream_bytes += 1024
await self.stream_release.wait()
await response.write_eof()
except ConnectionResetError:
pass
return response
async def compressed(self, request: web.Request) -> web.Response:
self.assertEqual(request.headers.get("Accept-Encoding"), "identity")
return web.Response(body=gzip.compress(b'{"next":"/"}'), headers={
"Content-Type": "application/json", "Content-Encoding": "gzip",
})
async def login(self, request: web.Request) -> web.Response:
return web.json_response({"next": "/"}, headers={
"ETag": '"before-rewrite"', "Set-Cookie": "session=value; Path=/; HttpOnly",
})
async def test_query_ticket_profile_and_compressed_downstream_frames(self) -> None:
async with self.client.ws_connect(
"/dashboard/api/ws?ticket=query-ticket&profile=work", compress=15,
) as ws:
self.assertEqual((await ws.receive(timeout=2)).data, "gateway.ready")
await ws.send_str("message" * 100)
self.assertEqual((await ws.receive(timeout=2)).data, "message" * 100)
await ws.send_bytes(b"audio")
self.assertEqual((await ws.receive(timeout=2)).data, b"audio")
self.assertEqual(self.seen[0]["profile"], "work")
self.assertIsNone(self.seen[0]["extensions"])
async def test_ticket_subprotocol_selects_only_public_protocol_and_is_single_use(self) -> None:
protocols = ["hermes-gateway-v1", "hermes-gateway-ticket.protocol-ticket"]
async with self.client.ws_connect("/dashboard/api/ws?profile=work", protocols=protocols) as ws:
self.assertEqual(ws.protocol, "hermes-gateway-v1")
self.assertEqual((await ws.receive(timeout=2)).data, "gateway.ready")
self.assertEqual(self.seen[0]["protocols"], protocols)
with self.assertRaises(WSServerHandshakeError) as rejected:
await self.client.ws_connect("/dashboard/api/ws", protocols=protocols)
self.assertEqual(rejected.exception.status, 502)
async def test_missing_or_ambiguous_credentials_never_upgrade(self) -> None:
for protocols in ([], ["hermes-gateway-ticket.protocol-ticket"], [
"hermes-gateway-v1", "hermes-gateway-ticket.protocol-ticket", "hermes-gateway-ticket.extra",
]):
with self.assertRaises(WSServerHandshakeError):
await self.client.ws_connect("/dashboard/api/ws", protocols=protocols)
self.assertEqual(self.seen, [])
async def test_public_health_uses_local_counts_and_coalesces_availability(self) -> None:
async def probe() -> dict:
async with self.client.get("/relay/health") as response:
return await response.json()
with patch.object(secure_proxy, "_api_available", new=AsyncMock(return_value=True)) as api:
results = await asyncio.gather(*(probe() for _ in range(20)))
api.assert_awaited_once()
self.assertEqual(self.gate_calls, 1)
self.assertEqual(self.inner_health_calls, 0)
for result in results:
self.assertEqual((result["version"], result["clients"], result["sessions"]),
(__version__, 4, 7))
async def test_chunked_rewrite_stops_at_limit_without_waiting_for_eof(self) -> None:
with patch.object(secure_proxy, "MAX_PROXY_RESPONSE_BYTES", 2048):
try:
response = await asyncio.wait_for(self.client.get("/dashboard/chunked"), timeout=2)
self.assertEqual(response.status, 502)
await response.read()
finally:
self.stream_release.set()
async def test_unexpected_compression_fails_closed(self) -> None:
response = await self.client.get("/dashboard/compressed")
self.assertEqual(response.status, 502)
self.assertIn("encoded Dashboard response", await response.text())
async def test_rewritten_login_has_scoped_cookie_and_fresh_length(self) -> None:
response = await self.client.post("/dashboard/auth/password-login")
body = await response.read()
self.assertEqual(await response.json(), {"next": "/dashboard/"})
self.assertEqual(int(response.headers["Content-Length"]), len(body))
self.assertNotIn("ETag", response.headers)
self.assertIn("Path=/dashboard", response.headers["Set-Cookie"])
async def test_declarative_gateway_scenario_through_proxy_reconnects_to_same_session(self) -> None:
fixture = GatewayFixture(load_scenario("secure_link_gateway_auth"))
fixture.app.router.add_get("/api/health", self.gate)
async with TestServer(fixture.app) as upstream:
self.state.config.secure_proxy_dashboard_url = str(upstream.make_url("/")).rstrip("/")
async with TestClient(TestServer(secure_proxy.create_secure_proxy_app(self.state))) as proxy:
for use_protocol in (False, True):
response = await proxy.post("/dashboard/api/auth/ws-ticket")
ticket = (await response.json())["ticket"]
url = "/dashboard/api/ws"
protocols = ["hermes-gateway-v1", f"hermes-gateway-ticket.{ticket}"] if use_protocol else []
if not use_protocol:
url += f"?ticket={ticket}"
async with proxy.ws_connect(url, protocols=protocols, compress=15) as ws:
self.assertEqual((await ws.receive_json(timeout=2))["params"]["type"], "gateway.ready")
self.assertEqual(ws.protocol, "hermes-gateway-v1" if use_protocol else None)
await ws.send_json({"jsonrpc": "2.0", "id": 1, "method": "session.activate",
"params": {"session_id": fixture.scenario.live_session_id}})
while True:
frame = await ws.receive_json(timeout=2)
if frame.get("id") == 1:
self.assertEqual(frame["result"]["session_id"], fixture.scenario.live_session_id)
break
if not use_protocol:
await ws.send_json({"jsonrpc": "2.0", "id": 2, "method": "prompt.submit", "params": {}})
while True:
frame = await ws.receive_json(timeout=2)
if frame.get("params", {}).get("type") == "message.complete":
self.assertEqual(frame["params"]["payload"]["text"], "Secure Link Gateway response.")
break
with self.assertRaises(WSServerHandshakeError):
await proxy.ws_connect(url, protocols=protocols)
+9 -1
View File
@@ -8,6 +8,7 @@ from types import SimpleNamespace
from unittest import mock
from aiohttp.test_utils import TestClient, TestServer
from aiohttp import ClientConnectionError
from plugin.relay.secure_proxy import (
PROXY_HTTP_IDLE_TIMEOUT_SECONDS,
@@ -18,7 +19,11 @@ from plugin.relay.secure_proxy import (
class SecureProxySecurityTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.enterContext(mock.patch("plugin.relay.secure_proxy._api_available", new=mock.AsyncMock(return_value=False)))
self.enterContext(mock.patch("plugin.relay.secure_proxy._dashboard_gate_enabled", new=mock.AsyncMock(return_value=False)))
relay = SimpleNamespace(
client_count=0,
sessions=SimpleNamespace(active_count=lambda: 0),
config=SimpleNamespace(
port=8767,
webapi_url="http://127.0.0.1:8642",
@@ -54,6 +59,8 @@ class SecureProxySecurityTests(unittest.IsolatedAsyncioTestCase):
"/relay/security",
"/bridge/status",
"/pairing/mint",
"/secure-link/preflight",
"/relay/secure-link/preflight",
"/media/inspect",
"/relay/ws/../sessions",
"/relay/%2e%2e/sessions",
@@ -65,7 +72,8 @@ class SecureProxySecurityTests(unittest.IsolatedAsyncioTestCase):
# Fixed API/Dashboard namespaces exist, but never expose arbitrary
# Relay/operator routes or an unauthenticated loopback Dashboard.
self.assertEqual((await self.client.get("/api/health")).status, 502)
with mock.patch("plugin.relay.secure_proxy._proxy_http", new=mock.AsyncMock(side_effect=ClientConnectionError)):
self.assertEqual((await self.client.get("/api/health")).status, 502)
self.assertEqual((await self.client.get("/dashboard/api/auth/me")).status, 503)
async def test_health_is_read_only_and_bounded(self) -> None:
+13 -28
View File
@@ -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