feat: add local-first gateway routing

This commit is contained in:
Hermes Agent
2026-07-24 11:26:52 +00:00
parent 25a83e0b6f
commit e5cc27ebc7
12 changed files with 720 additions and 402 deletions
+11
View File
@@ -5,6 +5,9 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
## Unreleased
### Added
- Added one securely stored Hermes gateway profile with optional Local HTTPS and required Remote HTTPS routes, bearer/no-auth configuration, and visible active-route diagnostics.
- Added bounded, redirect-disabled `GET /health` probes plus authenticated `GET /v1/models` compatibility checks and deterministic route, retry, connection-state, and HTTP-header tests.
- Added foreground, explicit reconnect, connectivity-change, and failed-request re-evaluation hooks backed by immutable per-request route leases.
- Added `docs/DIRECT_GATEWAY_ARCHITECTURE.md` to define the direct upstream Hermes gateway architecture, documented endpoints, unverified assumptions, and legacy companion compatibility policy.
- Added transport DTOs and domain repository contracts for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts.
- Added DTO-to-domain/UI mappers, stream reducer behavior, and a fake Hermes repository with unit coverage for mappings, stream reduction, and repository fixtures.
@@ -17,6 +20,9 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
### Changed
- Changed Android gateway routing to prefer a compatible Local route and deterministically fall back to Remote without switching an in-flight lease.
- Changed the Android shell to stop calling undocumented legacy companion `/api/*` routes; unavailable workspace/chat utilities now remain gated until direct upstream contracts are verified.
- Changed Android networking to reject cleartext, credential-bearing, path-bearing, duplicate, or missing gateway URLs and disabled application cleartext traffic.
- Pivoted planning and Android connection/domain terminology toward direct gateway auth, sessions, streaming, and later legacy companion removal.
- Changed Android transport/domain capability models and fake repository data to describe direct gateway support first, with legacy companion compatibility explicitly flagged.
- Changed the fake chat seed path to reuse the fake Hermes repository preview state.
@@ -27,9 +33,14 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts.
### Fixed
- Fixed bearer handling so `/health` is checked without credentials and the token is sent only to the documented authenticated `/v1/models` compatibility route.
- Fixed legacy stored HTTP URLs so they cannot become active routes, and sanitized connection/request diagnostics so tokens and internal URLs are not emitted.
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
### Verification
- Direct Kotlin/JUnit execution of `ConnectionStateTest` and `GatewayHttpProbeTest` — passed, 12 tests.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugUnitTestKotlin :app:assembleDebug` from `apps/mobile/android` — passed with temporary restored sandbox-only Gradle socket shims; 37 tasks, 6 executed.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest` — test sources compiled, but Gradle's forked test worker could not open its sandbox-prohibited TCP control socket; the same compiled test classes passed through direct JUnit execution.
- `git diff --check` — passed for direct gateway planning/contract milestone.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` — passed for direct gateway planning/contract milestone.
- Parent preflight immediately before B1 completion — script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed.
+6 -4
View File
@@ -26,10 +26,12 @@ Complete the Android/client contract pivot before implementing backend behavior.
## Beta path — upstream-connected MVP
- [ ] **B2 — Direct gateway connection and authentication UX**
- Server connection screen with validation, `GET /health`, bearer/no-auth configuration, optional `GET /v1/models` auth check, offline/unauthorized/retry states.
- Store sensitive values using Android-appropriate secure storage; never log secrets.
- Verification: connection-state tests and manual unhappy-path walkthrough.
- [ ] **B2 — Direct gateway connection, authentication, and local-first routing UX**
- One logical Hermes gateway profile with optional local URL + required remote fallback URL.
- Validate URLs, store bearer/no-auth configuration securely, probe `GET /health`, and use optional `GET /v1/models` auth check.
- Automatically prefer the local URL when reachable; immediately fail over to remote when it is not. Re-evaluate on foreground, explicit reconnect, connectivity change, and request failure; never switch a stream mid-flight.
- Show active Local/Remote route and clear offline/unauthorized/retry state without logging tokens or silently sending credentials to untrusted LAN hosts.
- Verification: deterministic route-selection/retry tests, connection-state tests, and manual unhappy-path walkthrough.
- [ ] **B3 — Direct gateway session contract and durable chat state**
- Fetch/list/search/select/create sessions through repository contracts after upstream session routes are verified.
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -2,6 +2,9 @@ package cloud.molberg.hermesmobile
import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
@@ -85,6 +88,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -103,26 +107,22 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cloud.molberg.hermesmobile.connection.ConnectionAuthMode
import cloud.molberg.hermesmobile.connection.ConnectionCheckResult
import cloud.molberg.hermesmobile.connection.ConnectionForm
import cloud.molberg.hermesmobile.connection.ConnectionReevaluationTrigger
import cloud.molberg.hermesmobile.connection.ConnectionStateReducer
import cloud.molberg.hermesmobile.connection.ConnectionStatus
import cloud.molberg.hermesmobile.connection.ConnectionUiState
import cloud.molberg.hermesmobile.connection.ConnectionValidator
import cloud.molberg.hermesmobile.connection.GatewayHttpProbe
import cloud.molberg.hermesmobile.connection.GatewayRouteCoordinator
import cloud.molberg.hermesmobile.connection.GatewayRouteSelector
import cloud.molberg.hermesmobile.connection.SecureConnectionStorage
import cloud.molberg.hermesmobile.connection.StoredConnectionRepository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
enum class Screen(val label: String, val icon: ImageVector, val title: String) {
Inbox("Chat", Icons.Filled.ChatBubble, "Chat"),
@@ -173,21 +173,18 @@ data class HealthState(
class CompanionApi(context: Context) {
val connectionStorage = SecureConnectionStorage(context)
private val connectionRepository = StoredConnectionRepository(connectionStorage) { checkConnection() }
private val routeCoordinator = GatewayRouteCoordinator(GatewayRouteSelector(GatewayHttpProbe()))
private val connectionRepository = StoredConnectionRepository(connectionStorage, routeCoordinator)
private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
init {
migrateLegacyConnectionPrefs()
}
var baseUrl: String
get() = connectionStorage.serverUrl
get() = connectionStorage.remoteUrl
set(value) {
connectionStorage.serverUrl = value
connectionStorage.remoteUrl = value
}
var accessKey: String
@@ -227,149 +224,48 @@ class CompanionApi(context: Context) {
fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> =
connectionRepository.save(form)
suspend fun checkConnectionState(): ConnectionUiState =
connectionRepository.check()
suspend fun checkConnectionState(
trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
): ConnectionUiState = connectionRepository.check(trigger)
private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject {
val normalizedUrl = ConnectionValidator.normalizeServerUrl(baseUrl)
.getOrElse { throw IllegalStateException(it.message ?: "Enter a valid server URL in Settings.") }
val mediaType = "application/json; charset=utf-8".toMediaType()
val builder = Request.Builder()
.url(normalizedUrl + path)
.header("Accept", "application/json")
if (connectionStorage.authMode == ConnectionAuthMode.BearerToken && accessKey.isNotBlank()) {
builder.header("Authorization", "Bearer $accessKey")
}
val request = when (method) {
"POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
"PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
else -> builder.get().build()
}
http.newCall(request).execute().use { response ->
val text = cleanServerText(response.body?.string().orEmpty())
val json = runCatching { if (text.isBlank()) JSONObject() else JSONObject(text) }.getOrNull()
if (!response.isSuccessful) {
if (response.code == 401 || response.code == 403) {
throw UnauthorizedConnectionException("Authentication was rejected. Check the access key and retry.")
}
val message = json?.optString("reply")?.takeIf { it.isNotBlank() }
?: json?.optString("message")?.takeIf { it.isNotBlank() }
?: json?.optString("error")?.takeIf { it.isNotBlank() }
?: text.take(600).ifBlank { "Server returned HTTP ${response.code}." }
throw IllegalStateException(message)
}
return json ?: throw IllegalStateException("Server returned a non-JSON response: ${text.take(220).ifBlank { "empty body" }}")
}
}
private fun requestFirst(paths: List<String>): JSONObject {
var lastError: Throwable? = null
for (path in paths) {
val result = runCatching { request(path) }
result.onSuccess { return it }
val error = result.exceptionOrNull()
if (error is UnauthorizedConnectionException) throw error
lastError = error
}
throw lastError ?: IllegalStateException("Server is offline or unreachable.")
}
suspend fun health(): HealthState = withContext(Dispatchers.IO) {
val json = requestFirst(listOf("/health", "/api/health"))
val checks = json.optJSONObject("checks")?.optJSONObject("hermes")
val config = json.optJSONObject("config")
HealthState(
reachable = true,
status = json.optString("status", "Gateway is reachable.").ifBlank { "Gateway is reachable." },
hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Hermes CLI was not found on PATH.",
workspace = config?.optString("workspaceRoot") ?: "Unknown",
uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s",
mode = json.optString("mode", "-")
suspend fun health(trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect): HealthState {
val state = checkConnectionState(trigger)
return HealthState(
reachable = state.connected,
status = state.noticeText,
hermesCli = "Managed by upstream Hermes",
workspace = "Not exposed by documented gateway routes",
mode = state.activeRouteLabel
)
}
suspend fun validate(): String = withContext(Dispatchers.IO) {
requestFirst(listOf("/v1/models", "/api/auth/validate")).optString("message", "Connection works.")
}
suspend fun validate(): String = checkConnectionState().message
suspend fun checkConnection(): ConnectionCheckResult = withContext(Dispatchers.IO) {
runCatching {
val health = health()
val authMessage = if (connectionStorage.authMode == ConnectionAuthMode.BearerToken) {
validate()
} else {
""
}
ConnectionCheckResult.Healthy(authMessage.ifBlank { health.status })
}.getOrElse { error ->
if (error is UnauthorizedConnectionException) {
ConnectionCheckResult.Unauthorized(error.message ?: "Authentication was rejected.")
} else {
ConnectionCheckResult.Offline(error.message ?: "Server is offline or unreachable.")
}
}
}
suspend fun checkConnection(
trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
): ConnectionUiState = checkConnectionState(trigger)
suspend fun chat(prompt: String): ChatResult = withContext(Dispatchers.IO) {
val json = request("/api/chat", "POST", JSONObject().put("prompt", prompt))
ChatResult(
reply = json.optString("reply", ""),
hermesAvailable = json.optBoolean("hermesAvailable", false),
exitCode = if (json.isNull("exitCode")) null else json.optInt("exitCode"),
stderr = json.optString("stderr", "")
)
}
suspend fun chat(prompt: String): ChatResult =
throw UnsupportedOperationException("Direct gateway chat integration is scheduled after the session contract is verified.")
suspend fun testHermes(): String {
val result = chat("ping")
return if (result.hermesAvailable) {
"Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}."
} else {
result.reply.ifBlank { "Hermes is not available through this server." }
}
}
suspend fun testHermes(): String = validate()
suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = withContext(Dispatchers.IO) {
val encoded = URLEncoder.encode(path, "UTF-8")
val json = request("/api/files?path=$encoded")
val entries = json.optJSONArray("entries") ?: JSONArray()
json.optString("path", path) to List(entries.length()) { i ->
val item = entries.getJSONObject(i)
FileEntry(
name = item.optString("name"),
path = item.optString("path"),
type = item.optString("type"),
size = item.optLong("size", 0)
)
}
}
suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = legacyRouteUnavailable()
suspend fun readFile(path: String): Pair<String, String> = withContext(Dispatchers.IO) {
val encoded = URLEncoder.encode(path, "UTF-8")
val json = request("/api/files/read?path=$encoded")
json.optString("path", path) to json.optString("content", "")
}
suspend fun readFile(path: String): Pair<String, String> = legacyRouteUnavailable()
suspend fun writeFile(path: String, content: String): String = withContext(Dispatchers.IO) {
val json = request("/api/files/write", "POST", JSONObject().put("path", path).put("content", content))
"Saved ${json.optLong("bytesWritten")} bytes"
}
suspend fun writeFile(path: String, content: String): String = legacyRouteUnavailable()
suspend fun runCommand(command: String, cwd: String): String = withContext(Dispatchers.IO) {
val json = request("/api/terminal/run", "POST", JSONObject().put("command", command).put("cwd", cwd))
listOf(
"$ ${json.optString("command", command)}",
json.optString("stdout"),
json.optString("stderr").takeIf { it.isNotBlank() }?.let { "stderr:\n$it" }.orEmpty(),
"exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}"
).filter { it.isNotBlank() }.joinToString("\n")
}
suspend fun runCommand(command: String, cwd: String): String = legacyRouteUnavailable()
private fun <T> legacyRouteUnavailable(): T =
throw UnsupportedOperationException("This legacy companion route is not part of the direct upstream gateway contract.")
private fun migrateLegacyConnectionPrefs() {
val legacyUrl = prefs.getString("baseUrl", null).orEmpty()
val legacyAccessKey = prefs.getString("accessKey", null).orEmpty()
if (connectionStorage.serverUrl.isBlank() && legacyUrl.isNotBlank()) {
connectionStorage.serverUrl = legacyUrl
if (connectionStorage.remoteUrl.isBlank() && legacyUrl.isNotBlank()) {
connectionStorage.remoteUrl = legacyUrl
}
if (connectionStorage.bearerToken.isBlank() && legacyAccessKey.isNotBlank()) {
connectionStorage.bearerToken = legacyAccessKey
@@ -380,8 +276,6 @@ class CompanionApi(context: Context) {
}
}
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
fun cleanServerText(text: String): String =
text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "")
.replace("\u0000", "")
@@ -402,23 +296,24 @@ fun HermesNativeApp() {
val snackbar = remember { SnackbarHostState() }
val scope = remember { CoroutineScope(Dispatchers.Main) }
var screen by remember { mutableStateOf(Screen.Inbox) }
var online by remember { mutableStateOf(false) }
var connectionState by remember { mutableStateOf(ConnectionStateReducer.initial(api.connectionConfig())) }
var resetInboxToken by remember { mutableStateOf(0) }
fun notify(message: String) {
scope.launch { snackbar.showSnackbar(message) }
}
LaunchedEffect(screen) {
api.checkConnection().let { online = it is ConnectionCheckResult.Healthy }
LaunchedEffect(Unit) {
connectionState = api.checkConnection(ConnectionReevaluationTrigger.Foreground)
}
GatewayReevaluationHooks(api) { connectionState = it }
HermesMobileTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = HermesTheme.colors.background,
topBar = {
NativeTopBar(screen, online, onAction = {
NativeTopBar(screen, connectionState.connected, onAction = {
if (screen == Screen.Inbox) {
resetInboxToken++
notify("New chat started.")
@@ -451,13 +346,44 @@ fun HermesNativeApp() {
screen = Screen.Inbox
notify("New chat started.")
})
Screen.Settings -> SettingsScreen(api, ::notify)
Screen.Settings -> SettingsScreen(api, connectionState, { connectionState = it }, ::notify)
}
}
}
}
}
@Composable
private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = remember { CoroutineScope(Dispatchers.Main) }
DisposableEffect(api, lifecycleOwner) {
fun reevaluate(trigger: ConnectionReevaluationTrigger) {
scope.launch { onState(api.checkConnection(trigger)) }
}
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) reevaluate(ConnectionReevaluationTrigger.Foreground)
}
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
reevaluate(ConnectionReevaluationTrigger.ConnectivityChange)
}
override fun onLost(network: Network) {
reevaluate(ConnectionReevaluationTrigger.ConnectivityChange)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
}
}
}
fun slideFor(from: Int, to: Int): ContentTransform {
val direction = if (to > from) 1 else -1
return slideInHorizontally(animationSpec = tween(260)) { width -> direction * width } togetherWith
@@ -867,12 +793,17 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
}
@Composable
fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) {
fun SettingsScreen(
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
notify: (String) -> Unit
) {
var panel by remember { mutableStateOf(SettingsPanel.Home) }
BackHandler(panel != SettingsPanel.Home) { panel = SettingsPanel.Home }
when (panel) {
SettingsPanel.Home -> SettingsHome(api, notify, onPanel = { panel = it })
SettingsPanel.Home -> SettingsHome(api, connectionState, onConnectionState, notify, onPanel = { panel = it })
SettingsPanel.Appearance -> AppearanceSettings(api, notify, onBack = { panel = SettingsPanel.Home })
SettingsPanel.Defaults -> AgentDefaultSettings(api, notify, onBack = { panel = SettingsPanel.Home })
SettingsPanel.Health -> HealthSettings(api, notify, onBack = { panel = SettingsPanel.Home })
@@ -881,29 +812,34 @@ fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) {
}
@Composable
fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) {
fun SettingsHome(
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
notify: (String) -> Unit,
onPanel: (SettingsPanel) -> Unit
) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
val initialConfig = remember { api.connectionConfig() }
var baseUrl by remember { mutableStateOf(initialConfig.serverUrl) }
var localUrl by remember { mutableStateOf(initialConfig.localUrl) }
var remoteUrl by remember { mutableStateOf(initialConfig.remoteUrl) }
var authMode by remember { mutableStateOf(initialConfig.authMode) }
var accessKey by remember { mutableStateOf("") }
var sessionLabel by remember { mutableStateOf(initialConfig.sessionLabel) }
var connectionState by remember {
mutableStateOf(ConnectionStateReducer.initial(initialConfig))
}
fun saveAndCheck() {
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
val saved = api.saveConnection(form).getOrElse {
connectionState = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
notify(connectionState.message)
val invalid = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
onConnectionState(invalid)
notify(invalid.message)
return
}
connectionState = saved
onConnectionState(saved)
accessKey = ""
scope.launch {
val checked = api.checkConnectionState()
connectionState = checked
onConnectionState(checked)
notify(checked.message)
}
}
@@ -912,8 +848,10 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.")
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Hermes gateway URL")
AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri)
FieldLabel("Remote HTTPS URL")
AppTextField(remoteUrl, { remoteUrl = it }, "https://hermes.example.com", keyboardType = KeyboardType.Uri)
FieldLabel("Local HTTPS URL (optional)")
AppTextField(localUrl, { localUrl = it }, "https://hermes.home.arpa", keyboardType = KeyboardType.Uri)
FieldLabel("Authentication")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Bearer", authMode == ConnectionAuthMode.BearerToken, Modifier.weight(1f)) {
@@ -936,15 +874,16 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
AppTextField(sessionLabel, { sessionLabel = it }, "Default session")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
connectionState = api.saveConnection(form).getOrElse {
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
var saved = api.saveConnection(form).getOrElse {
ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
}
if (connectionState.status == ConnectionStatus.Checking) {
connectionState = ConnectionStateReducer.savedForManualCheck(connectionState.config)
if (saved.status == ConnectionStatus.Checking) {
saved = ConnectionStateReducer.savedForManualCheck(saved.config)
accessKey = ""
}
notify(connectionState.message)
onConnectionState(saved)
notify(saved.message)
}
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
saveAndCheck()
@@ -953,6 +892,7 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
}
}
ConnectionNotice(connectionState)
MetricRow("Active route", connectionState.activeRouteLabel)
SectionHeader("Gateway", "Inspect server health and authentication.")
ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health and compatibility status") { onPanel(SettingsPanel.Health) }
ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Provider credentials stay upstream, outside the app") { onPanel(SettingsPanel.Accounts) }
@@ -968,7 +908,8 @@ fun ConnectionNotice(state: ConnectionUiState) {
state.noticeText,
danger = state.status == ConnectionStatus.Offline ||
state.status == ConnectionStatus.Unauthorized ||
state.urlError != null
state.localUrlError != null ||
state.remoteUrlError != null
)
}
@@ -1032,7 +973,7 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un
DetailList("Hermes", onBack) {
NoticeCard(health.status, danger = !health.reachable)
MetricRow("Gateway URL", api.baseUrl.ifBlank { "Not set" })
MetricRow("Active route", health.mode)
MetricRow("Hermes CLI", health.hermesCli)
MetricRow("Workspace", health.workspace)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
@@ -2,20 +2,28 @@ package cloud.molberg.hermesmobile.connection
import java.net.URI
import java.net.URISyntaxException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class ConnectionAuthMode { None, BearerToken }
enum class ConnectionRoute { Local, Remote }
enum class ConnectionStatus { Unconfigured, Offline, Checking, Connected, Unauthorized }
enum class ConnectionReevaluationTrigger { Foreground, ExplicitReconnect, ConnectivityChange, RequestFailure }
data class ConnectionConfig(
val serverUrl: String = "",
val localUrl: String = "",
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val hasBearerToken: Boolean = false,
val sessionLabel: String = "Default session"
)
data class ConnectionForm(
val serverUrl: String = "",
val localUrl: String = "",
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val bearerToken: String = "",
val sessionLabel: String = "Default session"
@@ -24,17 +32,20 @@ data class ConnectionForm(
data class ConnectionUiState(
val config: ConnectionConfig = ConnectionConfig(),
val status: ConnectionStatus = ConnectionStatus.Unconfigured,
val message: String = "Enter a server URL to connect.",
val urlError: String? = null,
val activeRoute: ConnectionRoute? = null,
val message: String = "Enter a remote gateway URL to connect.",
val localUrlError: String? = null,
val remoteUrlError: String? = null,
val canRetry: Boolean = false
) {
val connected: Boolean get() = status == ConnectionStatus.Connected
val activeRouteLabel: String get() = activeRoute?.name ?: "None"
val noticeText: String
get() {
val label = when (status) {
ConnectionStatus.Unconfigured -> "Not configured"
ConnectionStatus.Checking -> "Checking"
ConnectionStatus.Connected -> "Connected"
ConnectionStatus.Connected -> "Connected · $activeRouteLabel"
ConnectionStatus.Offline -> "Offline"
ConnectionStatus.Unauthorized -> "Unauthorized"
}
@@ -43,137 +54,234 @@ data class ConnectionUiState(
}
}
sealed interface ConnectionCheckResult {
data class Healthy(val message: String) : ConnectionCheckResult
data class Offline(val message: String) : ConnectionCheckResult
data class Unauthorized(val message: String) : ConnectionCheckResult
sealed interface GatewayProbeResult {
data class Healthy(val message: String = "") : GatewayProbeResult
data class Offline(val message: String) : GatewayProbeResult
data class Unauthorized(val message: String) : GatewayProbeResult
data class Incompatible(val message: String) : GatewayProbeResult
}
fun interface GatewayProbe {
suspend fun probe(baseUrl: String, authMode: ConnectionAuthMode, bearerToken: String): GatewayProbeResult
}
sealed interface RouteSelectionResult {
data class Connected(
val route: ConnectionRoute,
val baseUrl: String,
val message: String
) : RouteSelectionResult
data class Offline(val message: String) : RouteSelectionResult
data class Unauthorized(val message: String) : RouteSelectionResult
}
data class GatewayRouteLease(
val route: ConnectionRoute,
val baseUrl: String,
val generation: Long
)
object ConnectionValidator {
fun normalizeServerUrl(input: String): Result<String> {
fun normalizeGatewayUrl(input: String, required: Boolean): Result<String> {
val trimmed = input.trim().trimEnd('/')
if (trimmed.isBlank()) return Result.failure(IllegalArgumentException("Server URL is required."))
if (trimmed.isBlank()) {
return if (required) {
Result.failure(IllegalArgumentException("Remote gateway URL is required."))
} else {
Result.success("")
}
}
val uri = try {
URI(trimmed)
} catch (_: URISyntaxException) {
return Result.failure(IllegalArgumentException("Enter a valid http or https URL."))
return Result.failure(IllegalArgumentException("Enter a valid HTTPS URL."))
}
if (uri.scheme !in listOf("http", "https") || uri.host.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Enter a valid http or https URL."))
if (uri.scheme != "https" || uri.host.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Gateway URLs must use HTTPS."))
}
if (uri.rawUserInfo != null) {
return Result.failure(IllegalArgumentException("Do not include credentials in the server URL."))
return Result.failure(IllegalArgumentException("Do not include credentials in a gateway URL."))
}
if (uri.rawQuery != null || uri.rawFragment != null || uri.path.orEmpty() !in listOf("", "/")) {
return Result.failure(IllegalArgumentException("Gateway URLs cannot include a path, query, or fragment."))
}
return Result.success(trimmed)
}
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> =
normalizeServerUrl(form.serverUrl).map { normalized ->
val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> {
val local = normalizeGatewayUrl(form.localUrl, required = false).getOrElse { return Result.failure(it) }
val remote = normalizeGatewayUrl(form.remoteUrl, required = true).getOrElse { return Result.failure(it) }
if (local.isNotBlank() && local == remote) {
return Result.failure(IllegalArgumentException("Local and remote gateway URLs must be different."))
}
val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken
if (form.authMode == ConnectionAuthMode.BearerToken && !hasBearerToken) {
return Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
}
return Result.success(
ConnectionConfig(
serverUrl = normalized,
localUrl = local,
remoteUrl = remote,
authMode = form.authMode,
hasBearerToken = form.authMode == ConnectionAuthMode.BearerToken && hasBearerToken,
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
)
}.fold(
onSuccess = { config ->
if (form.authMode == ConnectionAuthMode.BearerToken && !config.hasBearerToken) {
Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
} else {
Result.success(config)
}
},
onFailure = { Result.failure(it) }
)
}
}
class GatewayRouteSelector(private val probe: GatewayProbe) {
suspend fun select(config: ConnectionConfig, bearerToken: String): RouteSelectionResult {
val failures = mutableListOf<GatewayProbeResult>()
if (config.localUrl.isNotBlank()) {
when (val local = probe.probe(config.localUrl, config.authMode, bearerToken)) {
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
ConnectionRoute.Local,
config.localUrl,
local.message.ifBlank { "Local gateway is reachable and compatible." }
)
else -> failures += local
}
}
when (val remote = probe.probe(config.remoteUrl, config.authMode, bearerToken)) {
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
ConnectionRoute.Remote,
config.remoteUrl,
remote.message.ifBlank { "Remote gateway is reachable and compatible." }
)
else -> failures += remote
}
val unauthorized = failures.filterIsInstance<GatewayProbeResult.Unauthorized>().lastOrNull()
return if (unauthorized != null) {
RouteSelectionResult.Unauthorized(unauthorized.message.ifBlank { "Authentication was rejected." })
} else {
RouteSelectionResult.Offline("Neither configured gateway route is reachable and compatible.")
}
}
}
class GatewayRouteCoordinator(private val selector: GatewayRouteSelector) {
private val mutex = Mutex()
@Volatile
private var activeLease: GatewayRouteLease? = null
private var generation = 0L
suspend fun reevaluate(config: ConnectionConfig, bearerToken: String): RouteSelectionResult = mutex.withLock {
val result = selector.select(config, bearerToken)
activeLease = if (result is RouteSelectionResult.Connected) {
GatewayRouteLease(result.route, result.baseUrl, ++generation)
} else {
null
}
result
}
suspend fun routeForRequest(config: ConnectionConfig, bearerToken: String): GatewayRouteLease {
activeLease?.takeIf { lease ->
lease.baseUrl == config.localUrl || lease.baseUrl == config.remoteUrl
}?.let { return it }
return when (val selected = reevaluate(config, bearerToken)) {
is RouteSelectionResult.Connected -> activeLease!!
is RouteSelectionResult.Unauthorized -> throw UnauthorizedConnectionException(selected.message)
is RouteSelectionResult.Offline -> throw IllegalStateException(selected.message)
}
}
fun activeRoute(): ConnectionRoute? = activeLease?.route
fun reportFailure(lease: GatewayRouteLease) {
if (activeLease?.generation == lease.generation) activeLease = null
}
fun clear() {
activeLease = null
}
}
object ConnectionStateReducer {
fun initial(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
config = config,
status = if (config.serverUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
message = if (config.serverUrl.isBlank()) {
"Server URL is blank until you enter your Hermes gateway address."
} else {
"Saved connection is ready to check."
},
canRetry = config.serverUrl.isNotBlank()
)
fun initial(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
config = config,
status = if (config.remoteUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
message = if (config.remoteUrl.isBlank()) {
"Remote gateway URL is blank until you configure this Hermes profile."
} else {
"Saved connection is ready to check."
},
canRetry = config.remoteUrl.isNotBlank()
)
fun saved(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
fun saved(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
config = config,
status = ConnectionStatus.Checking,
message = "Checking local then remote gateway routes..."
)
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = "Settings saved. Run Test to select the active route.",
canRetry = true
)
fun result(config: ConnectionConfig, result: RouteSelectionResult): ConnectionUiState = when (result) {
is RouteSelectionResult.Connected -> ConnectionUiState(
config = config,
status = ConnectionStatus.Checking,
message = "Checking server health...",
status = ConnectionStatus.Connected,
activeRoute = result.route,
message = result.message,
canRetry = false
)
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
is RouteSelectionResult.Offline -> ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = "Settings saved. Run Test to check the server.",
message = result.message,
canRetry = true
)
fun result(config: ConnectionConfig, result: ConnectionCheckResult): ConnectionUiState =
when (result) {
is ConnectionCheckResult.Healthy -> ConnectionUiState(
config = config,
status = ConnectionStatus.Connected,
message = result.message.ifBlank { "Server is reachable." },
canRetry = false
)
is ConnectionCheckResult.Offline -> ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = result.message.ifBlank { "Server is offline or unreachable." },
canRetry = true
)
is ConnectionCheckResult.Unauthorized -> ConnectionUiState(
config = config,
status = ConnectionStatus.Unauthorized,
message = result.message.ifBlank { "Authentication was rejected." },
canRetry = true
)
}
fun invalid(form: ConnectionForm, message: String): ConnectionUiState =
ConnectionUiState(
config = ConnectionConfig(
serverUrl = form.serverUrl.trim(),
authMode = form.authMode,
hasBearerToken = form.bearerToken.isNotBlank(),
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
),
status = ConnectionStatus.Unconfigured,
message = message,
urlError = message,
canRetry = false
is RouteSelectionResult.Unauthorized -> ConnectionUiState(
config = config,
status = ConnectionStatus.Unauthorized,
message = result.message,
canRetry = true
)
}
fun invalid(form: ConnectionForm, message: String): ConnectionUiState = ConnectionUiState(
config = ConnectionConfig(
localUrl = form.localUrl.trim(),
remoteUrl = form.remoteUrl.trim(),
authMode = form.authMode,
hasBearerToken = form.bearerToken.isNotBlank(),
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
),
status = ConnectionStatus.Unconfigured,
message = message,
localUrlError = message.takeIf { form.localUrl.isNotBlank() },
remoteUrlError = message,
canRetry = false
)
}
interface ConnectionStore {
var serverUrl: String
var localUrl: String
var remoteUrl: String
var sessionLabel: String
var authMode: ConnectionAuthMode
var bearerToken: String
fun config(): ConnectionConfig =
ConnectionConfig(
serverUrl = serverUrl,
authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(),
sessionLabel = sessionLabel
)
fun config(): ConnectionConfig = ConnectionConfig(
localUrl = ConnectionValidator.normalizeGatewayUrl(localUrl, required = false).getOrDefault(""),
remoteUrl = ConnectionValidator.normalizeGatewayUrl(remoteUrl, required = true).getOrDefault(""),
authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(),
sessionLabel = sessionLabel
)
fun save(form: ConnectionForm, normalizedUrl: String) {
serverUrl = normalizedUrl
fun save(form: ConnectionForm, config: ConnectionConfig) {
localUrl = config.localUrl
remoteUrl = config.remoteUrl
authMode = form.authMode
sessionLabel = form.sessionLabel
sessionLabel = config.sessionLabel
bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else ""
}
}
@@ -181,12 +289,14 @@ interface ConnectionStore {
interface ConnectionRepository {
fun config(): ConnectionConfig
fun save(form: ConnectionForm): Result<ConnectionUiState>
suspend fun check(): ConnectionUiState
suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState
suspend fun routeForRequest(): GatewayRouteLease
fun reportRequestFailure(lease: GatewayRouteLease)
}
class StoredConnectionRepository(
private val store: ConnectionStore,
private val healthCheck: suspend () -> ConnectionCheckResult
private val coordinator: GatewayRouteCoordinator
) : ConnectionRepository {
override fun config(): ConnectionConfig = store.config()
@@ -200,14 +310,30 @@ class StoredConnectionRepository(
} else {
form
}
return ConnectionValidator.validateForm(effectiveForm).mapCatching { config ->
store.save(effectiveForm, config.serverUrl)
return ConnectionValidator.validateForm(effectiveForm, store.bearerToken.isNotBlank()).mapCatching { config ->
store.save(effectiveForm, config)
coordinator.clear()
ConnectionStateReducer.saved(store.config())
}.recoverCatching {
throw IllegalArgumentException(it.message ?: "Connection settings are invalid.")
}
}
override suspend fun check(): ConnectionUiState =
ConnectionStateReducer.result(store.config(), healthCheck())
override suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState =
store.config().let { config ->
if (config.remoteUrl.isBlank()) {
ConnectionStateReducer.initial(config)
} else {
ConnectionStateReducer.result(config, coordinator.reevaluate(config, store.bearerToken))
}
}
override suspend fun routeForRequest(): GatewayRouteLease =
coordinator.routeForRequest(store.config(), store.bearerToken)
override fun reportRequestFailure(lease: GatewayRouteLease) {
coordinator.reportFailure(lease)
}
}
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
@@ -0,0 +1,72 @@
package cloud.molberg.hermesmobile.connection
import java.io.IOException
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
class GatewayHttpProbe(
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.callTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
) : GatewayProbe {
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult {
val health = getJson("$baseUrl/health")
if (health != HttpProbeResult.Success) return health.toGatewayResult("Health probe")
if (authMode == ConnectionAuthMode.None) {
return GatewayProbeResult.Healthy("Gateway health check passed.")
}
val compatibility = getJson("$baseUrl/v1/models", bearerToken)
return when (compatibility) {
HttpProbeResult.Success -> GatewayProbeResult.Healthy("Gateway health and bearer authentication checks passed.")
else -> compatibility.toGatewayResult("Authenticated compatibility check")
}
}
private fun getJson(url: String, bearerToken: String = ""): HttpProbeResult {
val builder = Request.Builder().url(url).get().header("Accept", "application/json")
if (bearerToken.isNotBlank()) builder.header("Authorization", "Bearer $bearerToken")
return try {
client.newCall(builder.build()).execute().use { response ->
val body = response.body?.string().orEmpty()
when {
response.code == 401 || response.code == 403 -> HttpProbeResult.Unauthorized
!response.isSuccessful -> HttpProbeResult.Unavailable
body.isBlank() || !isJson(body) -> HttpProbeResult.Incompatible
else -> HttpProbeResult.Success
}
}
} catch (_: IOException) {
HttpProbeResult.Unavailable
} catch (_: IllegalArgumentException) {
HttpProbeResult.Incompatible
}
}
private fun isJson(body: String): Boolean =
runCatching { JSONObject(body) }.isSuccess || runCatching { JSONArray(body) }.isSuccess
private fun HttpProbeResult.toGatewayResult(check: String): GatewayProbeResult = when (this) {
HttpProbeResult.Success -> GatewayProbeResult.Healthy()
HttpProbeResult.Unauthorized -> GatewayProbeResult.Unauthorized("$check was unauthorized.")
HttpProbeResult.Incompatible -> GatewayProbeResult.Incompatible("$check returned an incompatible response.")
HttpProbeResult.Unavailable -> GatewayProbeResult.Offline("$check could not reach the gateway.")
}
private enum class HttpProbeResult { Success, Unauthorized, Incompatible, Unavailable }
private companion object {
const val PROBE_TIMEOUT_SECONDS = 3L
}
}
@@ -15,10 +15,16 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
private val prefs: SharedPreferences =
context.getSharedPreferences("connection", Context.MODE_PRIVATE)
override var serverUrl: String
get() = prefs.getString(KEY_URL, "") ?: ""
override var localUrl: String
get() = prefs.getString(KEY_LOCAL_URL, "") ?: ""
set(value) {
prefs.edit().putString(KEY_URL, value.trim().trimEnd('/')).apply()
prefs.edit().putString(KEY_LOCAL_URL, value.trim().trimEnd('/')).apply()
}
override var remoteUrl: String
get() = prefs.getString(KEY_REMOTE_URL, prefs.getString(KEY_LEGACY_URL, "")) ?: ""
set(value) {
prefs.edit().putString(KEY_REMOTE_URL, value.trim().trimEnd('/')).remove(KEY_LEGACY_URL).apply()
}
override var sessionLabel: String
@@ -84,7 +90,9 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
const val KEY_ALIAS = "hermes_mobile_connection"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val GCM_TAG_BITS = 128
const val KEY_URL = "serverUrl"
const val KEY_LOCAL_URL = "localUrl"
const val KEY_REMOTE_URL = "remoteUrl"
const val KEY_LEGACY_URL = "serverUrl"
const val KEY_SESSION = "sessionLabel"
const val KEY_AUTH_MODE = "authMode"
const val KEY_BEARER_TOKEN = "bearerToken"
@@ -1,163 +1,209 @@
package cloud.molberg.hermesmobile.connection
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionStateTest {
@Test
fun validatorNormalizesHttpUrlsAndRejectsUnsafeCredentials() {
val normalized = ConnectionValidator.normalizeServerUrl(" https://hermes.example.test/ ").getOrThrow()
val embeddedCredentials = ConnectionValidator.normalizeServerUrl("https://user:secret@hermes.example.test")
assertEquals("https://hermes.example.test", normalized)
assertTrue(embeddedCredentials.isFailure)
}
@Test
fun validatorRequiresTokenForBearerAuthentication() {
val missingToken = ConnectionValidator.validateForm(
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.BearerToken)
)
val blankToken = ConnectionValidator.validateForm(
fun validatorRequiresHttpsRemoteAndRejectsEmbeddedCredentialsOrPaths() {
val valid = ConnectionValidator.validateForm(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = " "
localUrl = " https://hermes.home.arpa/ ",
remoteUrl = " https://hermes.example.test/ ",
authMode = ConnectionAuthMode.None
)
)
val anonymous = ConnectionValidator.validateForm(
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.None)
)
assertTrue(missingToken.isFailure)
assertTrue(blankToken.isFailure)
assertTrue(anonymous.isSuccess)
assertFalse(anonymous.getOrThrow().hasBearerToken)
}
@Test
fun validatorAcceptsStoredBearerTokenWithoutExposingItInConfig() {
val config = ConnectionValidator.validateForm(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = ""
),
hasStoredBearerToken = true
).getOrThrow()
assertTrue(config.hasBearerToken)
assertEquals("http://10.0.2.2:8787", config.serverUrl)
assertEquals("https://hermes.home.arpa", valid.localUrl)
assertEquals("https://hermes.example.test", valid.remoteUrl)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "http://example.test", authMode = ConnectionAuthMode.None)).isFailure)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "https://user:secret@example.test", authMode = ConnectionAuthMode.None)).isFailure)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "https://example.test/base", authMode = ConnectionAuthMode.None)).isFailure)
}
@Test
fun validatorRequiresRemoteUrlTokenAndDistinctRoutes() {
assertTrue(ConnectionValidator.validateForm(ConnectionForm(authMode = ConnectionAuthMode.None)).isFailure)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = REMOTE)).isFailure)
assertTrue(
ConnectionValidator.validateForm(
ConnectionForm(localUrl = REMOTE, remoteUrl = REMOTE, authMode = ConnectionAuthMode.None)
).isFailure
)
assertTrue(
ConnectionValidator.validateForm(ConnectionForm(remoteUrl = REMOTE), hasStoredBearerToken = true).isSuccess
)
}
@Test
fun reducerSurfacesOfflineUnauthorizedRetryAndConnectedStates() {
val config = ConnectionConfig(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true
fun selectorPrefersCompatibleLocalWithoutProbingRemote() = runBlocking {
val probe = RecordingProbe(mapOf(LOCAL to GatewayProbeResult.Healthy("local ok")))
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
assertEquals(ConnectionRoute.Local, (result as RouteSelectionResult.Connected).route)
assertEquals(listOf(LOCAL), probe.urls)
}
@Test
fun selectorFallsBackToRemoteAfterLocalFailure() = runBlocking {
val probe = RecordingProbe(
mapOf(
LOCAL to GatewayProbeResult.Offline("local offline"),
REMOTE to GatewayProbeResult.Healthy("remote ok")
)
)
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
val offline = ConnectionStateReducer.result(config, ConnectionCheckResult.Offline("No route to host."))
val unauthorized = ConnectionStateReducer.result(config, ConnectionCheckResult.Unauthorized("Rejected."))
val connected = ConnectionStateReducer.result(config, ConnectionCheckResult.Healthy("Server is reachable."))
assertEquals(ConnectionRoute.Remote, (result as RouteSelectionResult.Connected).route)
assertEquals(listOf(LOCAL, REMOTE), probe.urls)
}
@Test
fun selectorSurfacesUnauthorizedAfterBothRoutesFail() = runBlocking {
val probe = RecordingProbe(
mapOf(
LOCAL to GatewayProbeResult.Unauthorized("local rejected"),
REMOTE to GatewayProbeResult.Offline("remote offline")
)
)
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
assertTrue(result is RouteSelectionResult.Unauthorized)
assertEquals(listOf(LOCAL, REMOTE), probe.urls)
}
@Test
fun coordinatorKeepsInflightLeaseStableAndReevaluatesNextRequest() = runBlocking {
val probe = SequencedProbe(
mutableListOf(
mapOf(LOCAL to GatewayProbeResult.Healthy("local ok")),
mapOf(
LOCAL to GatewayProbeResult.Offline("local offline"),
REMOTE to GatewayProbeResult.Healthy("remote ok")
)
)
)
val coordinator = GatewayRouteCoordinator(GatewayRouteSelector(probe))
val first = coordinator.routeForRequest(config(), TOKEN)
coordinator.reportFailure(first)
val second = coordinator.routeForRequest(config(), TOKEN)
assertEquals(ConnectionRoute.Local, first.route)
assertEquals(LOCAL, first.baseUrl)
assertEquals(ConnectionRoute.Remote, second.route)
assertEquals(REMOTE, second.baseUrl)
assertNotEquals(first.generation, second.generation)
}
@Test
fun reducerReportsRouteOfflineUnauthorizedAndRetryState() {
val connected = ConnectionStateReducer.result(
config(),
RouteSelectionResult.Connected(ConnectionRoute.Local, LOCAL, "ready")
)
val offline = ConnectionStateReducer.result(config(), RouteSelectionResult.Offline("offline"))
val unauthorized = ConnectionStateReducer.result(config(), RouteSelectionResult.Unauthorized("rejected"))
assertEquals(ConnectionStatus.Connected, connected.status)
assertEquals("Local", connected.activeRouteLabel)
assertFalse(connected.canRetry)
assertEquals(ConnectionStatus.Offline, offline.status)
assertTrue(offline.canRetry)
assertEquals(ConnectionStatus.Unauthorized, unauthorized.status)
assertTrue(unauthorized.canRetry)
assertEquals(ConnectionStatus.Connected, connected.status)
assertFalse(connected.canRetry)
}
@Test
fun invalidFormKeepsDraftValuesWithoutClaimingConnection() {
val state = ConnectionStateReducer.invalid(
ConnectionForm(serverUrl = "ftp://example.test", bearerToken = "redacted-test-token"),
"Enter a valid http or https URL."
)
assertEquals(ConnectionStatus.Unconfigured, state.status)
assertEquals("ftp://example.test", state.config.serverUrl)
assertTrue(state.config.hasBearerToken)
assertEquals("Enter a valid http or https URL.", state.urlError)
}
@Test
fun repositoryPreservesExistingBearerTokenWhenSavingMetadataOnly() {
val store = InMemoryConnectionStore().apply {
serverUrl = "https://old.example.test"
authMode = ConnectionAuthMode.BearerToken
bearerToken = "stored-token"
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Healthy("ok")
}
val saved = repository.save(
ConnectionForm(
serverUrl = " https://hermes.example.test/ ",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = "",
sessionLabel = "Mobile"
)
).getOrThrow()
assertEquals(ConnectionStatus.Checking, saved.status)
assertEquals("https://hermes.example.test", store.serverUrl)
assertEquals("stored-token", store.bearerToken)
assertEquals("Mobile", store.sessionLabel)
assertTrue(saved.config.hasBearerToken)
}
@Test
fun repositoryClearsBearerTokenForAnonymousConnection() {
val store = InMemoryConnectionStore().apply {
bearerToken = "stored-token"
authMode = ConnectionAuthMode.BearerToken
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Healthy("ok")
}
fun repositoryStoresOneProfileAndPreservesOrClearsToken() {
val store = InMemoryConnectionStore().apply { bearerToken = TOKEN }
val coordinator = GatewayRouteCoordinator(GatewayRouteSelector(RecordingProbe(emptyMap())))
val repository = StoredConnectionRepository(store, coordinator)
repository.save(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.None,
bearerToken = "ignored-token"
localUrl = LOCAL,
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken,
sessionLabel = " Mobile "
)
).getOrThrow()
assertEquals(ConnectionAuthMode.None, store.authMode)
assertEquals(LOCAL, store.localUrl)
assertEquals(REMOTE, store.remoteUrl)
assertEquals(TOKEN, store.bearerToken)
assertEquals("Mobile", store.sessionLabel)
repository.save(ConnectionForm(remoteUrl = REMOTE, authMode = ConnectionAuthMode.None)).getOrThrow()
assertEquals("", store.bearerToken)
assertFalse(repository.config().hasBearerToken)
}
@Test
fun repositoryCheckMapsHealthResultToUiState() {
fun storedLegacyCleartextUrlIsNotReturnedAsAUsableRoute() {
val store = InMemoryConnectionStore().apply {
serverUrl = "http://10.0.2.2:8787"
authMode = ConnectionAuthMode.BearerToken
bearerToken = "stored-token"
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Unauthorized("Authentication was rejected.")
remoteUrl = "http://legacy.example.test"
authMode = ConnectionAuthMode.None
}
val state = kotlinx.coroutines.runBlocking { repository.check() }
assertEquals("", store.config().remoteUrl)
assertEquals(ConnectionStatus.Unconfigured, ConnectionStateReducer.initial(store.config()).status)
}
assertEquals(ConnectionStatus.Unauthorized, state.status)
assertTrue(state.canRetry)
assertTrue(state.config.hasBearerToken)
private fun config() = ConnectionConfig(
localUrl = LOCAL,
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true
)
private class RecordingProbe(private val results: Map<String, GatewayProbeResult>) : GatewayProbe {
val urls = mutableListOf<String>()
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult {
urls += baseUrl
assertEquals(ConnectionAuthMode.BearerToken, authMode)
assertEquals(TOKEN, bearerToken)
return results[baseUrl] ?: GatewayProbeResult.Offline("offline")
}
}
private class SequencedProbe(
private val attempts: MutableList<Map<String, GatewayProbeResult>>
) : GatewayProbe {
private var attemptIndex = 0
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult {
val attempt = attempts[attemptIndex]
val result = attempt[baseUrl] ?: GatewayProbeResult.Offline("offline")
if (baseUrl == REMOTE || result is GatewayProbeResult.Healthy) attemptIndex++
return result
}
}
private class InMemoryConnectionStore : ConnectionStore {
override var serverUrl: String = ""
override var localUrl: String = ""
override var remoteUrl: String = ""
override var sessionLabel: String = "Default session"
override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken
override var bearerToken: String = ""
}
private companion object {
const val LOCAL = "https://hermes.home.arpa"
const val REMOTE = "https://hermes.example.test"
const val TOKEN = "test-token"
}
}
@@ -0,0 +1,77 @@
package cloud.molberg.hermesmobile.connection
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayHttpProbeTest {
@Test
fun bearerProbeChecksHealthWithoutCredentialsBeforeModelsWithBearer() = runBlocking {
val requests = mutableListOf<Pair<String, String?>>()
val probe = GatewayHttpProbe(client { chain ->
requests += chain.request().url.encodedPath to chain.request().header("Authorization")
jsonResponse(chain, 200)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.BearerToken, TOKEN)
assertTrue(result is GatewayProbeResult.Healthy)
assertEquals(listOf("/health" to null, "/v1/models" to "Bearer $TOKEN"), requests)
assertFalse((result as GatewayProbeResult.Healthy).message.contains(TOKEN))
}
@Test
fun anonymousProbeStopsAfterHealth() = runBlocking {
val paths = mutableListOf<String>()
val probe = GatewayHttpProbe(client { chain ->
paths += chain.request().url.encodedPath
jsonResponse(chain, 200)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.None, "")
assertTrue(result is GatewayProbeResult.Healthy)
assertEquals(listOf("/health"), paths)
}
@Test
fun authenticatedCompatibilityRejectionIsUnauthorized() = runBlocking {
val probe = GatewayHttpProbe(client { chain ->
if (chain.request().url.encodedPath == "/health") jsonResponse(chain, 200) else jsonResponse(chain, 401)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.BearerToken, TOKEN)
assertTrue(result is GatewayProbeResult.Unauthorized)
assertFalse((result as GatewayProbeResult.Unauthorized).message.contains(BASE_URL))
assertFalse(result.message.contains(TOKEN))
}
private fun client(interceptor: (Interceptor.Chain) -> Response): OkHttpClient =
OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.addInterceptor(Interceptor { chain -> interceptor(chain) })
.build()
private fun jsonResponse(chain: Interceptor.Chain, code: Int): Response = Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("test")
.body("{}".toResponseBody("application/json".toMediaType()))
.build()
private companion object {
const val BASE_URL = "https://hermes.example.test"
const val TOKEN = "secret-test-token"
}
}
+9
View File
@@ -0,0 +1,9 @@
# Hermes Mobile Blockers
## Stable gateway identity binding
The documented direct gateway routes currently available to Hermes Mobile (`GET /health` and authenticated `GET /v1/models`) do not expose a verified stable gateway instance identifier.
B2 therefore protects local-first routing by requiring explicit HTTPS URLs, disabling redirects and cleartext traffic, probing `/health` without credentials, and sending a bearer token only for the documented `/v1/models` compatibility check. This confirms TLS reachability and authenticated API compatibility, but it cannot cryptographically prove that separately configured Local and Remote URLs terminate at the same Hermes gateway identity.
Upstream compatibility needed for stronger binding: a documented stable gateway instance ID signed or authenticated consistently across both routes, or an upstream-supported certificate/public-key pin that the Android client can bind to the single logical profile. Until then, Local and Remote pairing remains an explicit user trust decision and diagnostics identify only the active route, not internal URLs.
+25 -2
View File
@@ -57,9 +57,32 @@ Android contracts may model these needs, but implementation must keep them capab
- The transport/domain model exposes direct gateway capabilities first. `legacyCompanionAdapter` exists only to identify old adapters during migration.
- Backend behavior belongs upstream in Hermes Agent. Hermes Mobile may add client adapters and mappers, not server-side replacements.
## Local-first gateway routing
A server profile can contain two URLs for the **same Hermes gateway identity**:
- **Local URL** — preferred when the phone can safely reach the gateway on the home/LAN (for example, `https://hermes.home.arpa`).
- **Remote URL** — fallback when local reachability is unavailable (for example, a configured public HTTPS hostname).
The client must choose automatically per connection attempt:
1. If a local URL is configured, probe its documented `GET /health` endpoint using a short, bounded timeout.
2. Use the local URL when the probe succeeds and the gateway identity/auth contract is compatible.
3. Otherwise immediately use the configured remote URL; never leave chat blocked waiting for LAN discovery.
4. Re-evaluate on app foreground, explicit reconnect, connectivity changes, and after a failed active request. Do not switch a request mid-stream.
5. Show the active route (`Local` or `Remote`) in connection diagnostics, but do not expose bearer tokens or internal host details in message content/logs.
Security and reliability rules:
- Local and remote URLs are explicit user configuration, not guessed IP ranges or silently discovered devices in the MVP.
- Both routes must use HTTPS by default and must be treated as the same intended gateway only after an authenticated compatibility check. The app must not silently send a bearer token to an arbitrary captive portal or unrelated LAN host.
- Remote is a fallback, not a duplicate profile. User preferences, selected session state, and credentials stay associated with one logical gateway profile.
- A later milestone may add opt-in mDNS/QR setup, certificate pinning/identity binding, and richer connectivity observation only after direct-gateway basics are verified.
- The current upstream identity-binding limitation and the stronger contract needed are tracked in `docs/BLOCKERS.md`.
## Android Contract Milestones
1. Auth and connection: validate URL, store bearer tokens securely, probe `GET /health`, and use `GET /v1/models` as the first authenticated check.
1. Auth and connection: validate local + remote URLs, store bearer tokens securely, probe `GET /health`, use `GET /v1/models` as the first authenticated check, and make local-first fallback observable/retryable.
2. Sessions: keep repository interfaces for list/create/select/continue, but mark route mapping provisional until upstream session docs are verified.
3. Streaming: support final response and token delta reduction now; gate structured tool/artifact/session events behind server capabilities.
3. Streaming: support final response and token delta reduction now; gate structured tool/artifact/session events behind server capabilities. Keep each stream bound to its selected local or remote route.
4. Companion removal: after direct gateway chat/session/streaming works end to end, remove legacy companion UI routes and then deprecate install/docs around `apps/companion`.
+5 -3
View File
@@ -6,7 +6,9 @@ Status: in progress. Android foundation work is complete, and the beta path now
Done:
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
- Android connection flow stores a gateway URL and bearer token locally, validates URL/auth state, and surfaces offline/unauthorized/retry states in-app.
- Android connection flow stores one logical gateway profile with optional Local HTTPS, required Remote HTTPS, Android-keystore-protected bearer/no-auth configuration, and active Local/Remote diagnostics.
- Local-first route selection uses bounded `GET /health` probes, authenticated `GET /v1/models` compatibility checks, deterministic Remote fallback, and foreground/connectivity/request-failure re-evaluation without switching in-flight leases.
- Android no longer calls undocumented legacy companion `/api/*` routes from the direct-gateway client path.
- Chat UI supports conversation list/thread navigation, native composer, busy state, selectable replies, error bubbles, and new-chat reset.
- Direct gateway compatibility contract is documented in `docs/DIRECT_GATEWAY_ARCHITECTURE.md`.
- Legacy companion chat, terminal, and file utilities remain in the repo during migration but are not the target architecture.
@@ -14,9 +16,9 @@ Done:
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
Remaining for beta:
- Android debug build validation passed on 2026-07-24: `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`.
- Exercise the connection/settings unhappy paths on a real device or emulator; this sandbox cannot start ADB because local control sockets are prohibited.
- Exercise a real device or emulator against a running upstream Hermes gateway/API server.
- Verify direct gateway behavior end to end: bearer auth, `GET /health`, `GET /v1/models`, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
- Verify direct gateway behavior end to end beyond B2: chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
- Verify or revise provisional session, streaming, tool-event, artifact, and cancellation route assumptions against official upstream docs.
- Add or run focused Android UI validation for chat/settings flows on small and large screens.
- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance.