diff --git a/CHANGELOG.md b/CHANGELOG.md index 09be943..21b7124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index e78f86d..f670ccd 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -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. diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index f1128cc..4ef4b77 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,12 @@ + = 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): 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> = 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> = legacyRouteUnavailable() - suspend fun readFile(path: String): Pair = 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 = 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 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()) { diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/ConnectionModels.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/ConnectionModels.kt index 4fac2cb..fc85337 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/ConnectionModels.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/ConnectionModels.kt @@ -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 { + fun normalizeGatewayUrl(input: String, required: Boolean): Result { 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 = - normalizeServerUrl(form.serverUrl).map { normalized -> - val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken + fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result { + 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() + 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().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 - 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) diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/GatewayHttpProbe.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/GatewayHttpProbe.kt new file mode 100644 index 0000000..dab680c --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/GatewayHttpProbe.kt @@ -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 + } +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/SecureConnectionStorage.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/SecureConnectionStorage.kt index ec7f9da..42cf2b2 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/SecureConnectionStorage.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/SecureConnectionStorage.kt @@ -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" diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/ConnectionStateTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/ConnectionStateTest.kt index ce5f513..6528ea6 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/ConnectionStateTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/ConnectionStateTest.kt @@ -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) : GatewayProbe { + val urls = mutableListOf() + + 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> + ) : 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" + } } diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/GatewayHttpProbeTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/GatewayHttpProbeTest.kt new file mode 100644 index 0000000..3ee0363 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/GatewayHttpProbeTest.kt @@ -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>() + 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() + 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" + } +} diff --git a/docs/BLOCKERS.md b/docs/BLOCKERS.md new file mode 100644 index 0000000..0a946cb --- /dev/null +++ b/docs/BLOCKERS.md @@ -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. diff --git a/docs/DIRECT_GATEWAY_ARCHITECTURE.md b/docs/DIRECT_GATEWAY_ARCHITECTURE.md index 6b83b58..a9609e6 100644 --- a/docs/DIRECT_GATEWAY_ARCHITECTURE.md +++ b/docs/DIRECT_GATEWAY_ARCHITECTURE.md @@ -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`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1bce4fa..edb224a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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.