From 25a83e0b6ffbf628e08bad738e1f33548fa3b90a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 24 Jul 2026 10:49:48 +0000 Subject: [PATCH] feat: pivot mobile client to direct gateway --- CHANGELOG.md | 5 + PROJECT_PLAN.md | 27 ++- .../molberg/hermesmobile/MainActivity.kt | 219 ++++++++++++++---- .../connection/ConnectionModels.kt | 213 +++++++++++++++++ .../connection/SecureConnectionStorage.kt | 92 ++++++++ .../hermesmobile/domain/HermesDomainModels.kt | 6 +- .../hermesmobile/domain/HermesMappers.kt | 2 +- .../hermesmobile/fake/FakeHermesRepository.kt | 20 +- .../transport/HermesTransportDtos.kt | 6 +- .../connection/ConnectionStateTest.kt | 163 +++++++++++++ .../hermesmobile/domain/HermesMapperTest.kt | 24 +- .../fake/FakeHermesRepositoryTest.kt | 11 +- docs/DIRECT_GATEWAY_ARCHITECTURE.md | 65 ++++++ docs/ROADMAP.md | 22 +- 14 files changed, 793 insertions(+), 82 deletions(-) create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/ConnectionModels.kt create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/SecureConnectionStorage.kt create mode 100644 apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/ConnectionStateTest.kt create mode 100644 docs/DIRECT_GATEWAY_ARCHITECTURE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd5a31..09be943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du ## Unreleased ### Added +- 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. - Added a fake-data Compose chat proving ground with local session rail/list, context controls, keyboard-safe composer, send/cancel/retry, and seeded empty/loading/reconnecting/error states. @@ -16,6 +17,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du - Added `PROJECT_PLAN.md` as the project-controlled milestone plan. ### Changed +- 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. - Extracted shared Compose cards, rows, buttons, text fields, notices, message bubbles, list scaffolds, and code surfaces out of `MainActivity.kt`. - Updated the native Appearance screen to describe system light/dark theming and design tokens. @@ -27,6 +30,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du - Fixed companion/mobile lint issues from missing Node globals and an unused React settings value. ### Verification +- `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. - Parent preflight immediately before B1 completion — `git diff --check` passed. - `git diff --check` — passed diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 6fe0119..e78f86d 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -20,26 +20,26 @@ --- -## Now — finish the foundation already in progress +## Now — direct gateway pivot -_Foundation milestones complete. Continue with B1._ +Complete the Android/client contract pivot before implementing backend behavior. The next release path targets a remote upstream Hermes Agent gateway/API server directly; `apps/companion` is legacy compatibility only. ## Beta path — upstream-connected MVP -- [ ] **B2 — Connection and authentication UX** - - Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states. +- [ ] **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. -- [ ] **B3 — Session list and durable chat state** - - Fetch/list/search/select/create sessions through repository contracts. +- [ ] **B3 — Direct gateway session contract and durable chat state** + - Fetch/list/search/select/create sessions through repository contracts after upstream session routes are verified. - Restore selected session and protect against duplicate sends / lost drafts. - - Verification: fake-repository UI tests; companion/integration checks where available. + - Verification: fake-repository UI tests; direct gateway compatibility checks where available. -- [ ] **B4 — Streaming, reconnects, and resilient long-running work** - - Stream event reducer: token deltas, tool lifecycle, final/error/cancel, reconnect/backoff. + - [ ] **B4 — Direct gateway streaming, reconnects, and resilient long-running work** + - Stream event reducer: final response/token deltas first; tool lifecycle, session events, artifacts, cancel, and reconnect/backoff only where upstream capability is verified. - Preserve partial output and make recovery obvious and safe. - - Verification: deterministic fake stream tests and real upstream-compatible exercise. + - Verification: deterministic fake stream tests and real direct gateway exercise. - [ ] **B5 — Tool, artifact, and output experience** - Compact tool cards with lifecycle/status, expandable output, copy/share/save affordances. @@ -66,6 +66,7 @@ _Foundation milestones complete. Continue with B1._ - [ ] Notifications for task completion. - [ ] Mature tablet/two-pane session + optional terminal/log pane. - [ ] Offline cache/search and diagnostic export with privacy controls. +- [ ] Remove legacy companion Android routes and deprecate `apps/companion` install/runtime docs after direct gateway chat/session/streaming is verified. --- @@ -99,6 +100,12 @@ _Move finished items here with date, commit, and verification. Keep this section - Kept the companion path as an optional adapter capability without changing upstream Hermes semantics. - Verification: parent preflight immediately before B1 completion passed: script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed. Parent also verified `git diff --check` passed. +- [x] **B2a — Direct gateway planning and compatibility contract** — 2026-07-24, commit `docs: pivot mobile contract to direct gateway`. + - Added `docs/DIRECT_GATEWAY_ARCHITECTURE.md` with documented upstream endpoints separated from unverified assumptions. + - Reprioritized the beta path around direct gateway auth, sessions, streaming, and later companion removal. + - Updated Android transport/domain/fake capability contracts and connection copy so new code no longer assumes a companion server. + - Verification: `git diff --check` passed; Android `:app:testDebugUnitTest :app:assembleDebug` passed from `apps/mobile/android`. + ## Work-loop checklist Before coding: diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt index a65f27d..dd322e8 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt @@ -102,6 +102,15 @@ import androidx.compose.ui.text.input.KeyboardType 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.ConnectionStateReducer +import cloud.molberg.hermesmobile.connection.ConnectionStatus +import cloud.molberg.hermesmobile.connection.ConnectionUiState +import cloud.molberg.hermesmobile.connection.ConnectionValidator +import cloud.molberg.hermesmobile.connection.SecureConnectionStorage +import cloud.molberg.hermesmobile.connection.StoredConnectionRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -155,30 +164,36 @@ data class AppearancePrefs( data class HealthState( val reachable: Boolean = false, - val status: String = "Tap refresh to check companion status.", + val status: String = "Tap refresh to check gateway status.", val hermesCli: String = "Unknown / unavailable", val workspace: String = "Unknown", val uptime: String = "-", val mode: String = "-" ) -class CompanionApi(private val context: Context) { +class CompanionApi(context: Context) { + val connectionStorage = SecureConnectionStorage(context) + private val connectionRepository = StoredConnectionRepository(connectionStorage) { checkConnection() } 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() = prefs.getString("baseUrl", "") ?: "" + get() = connectionStorage.serverUrl set(value) { - prefs.edit().putString("baseUrl", value.trim()).apply() + connectionStorage.serverUrl = value } var accessKey: String - get() = prefs.getString("accessKey", "") ?: "" + get() = connectionStorage.bearerToken set(value) { - prefs.edit().putString("accessKey", value.trim()).apply() + connectionStorage.bearerToken = value } var defaults: AgentDefaults @@ -207,15 +222,24 @@ class CompanionApi(private val context: Context) { .apply() } - private fun endpoint(path: String): String = baseUrl.trimEnd('/') + path + fun connectionConfig() = connectionRepository.config() + + fun saveConnection(form: ConnectionForm): Result = + connectionRepository.save(form) + + suspend fun checkConnectionState(): ConnectionUiState = + connectionRepository.check() private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject { - if (baseUrl.isBlank()) throw IllegalStateException("Enter your companion server URL in Settings.") + 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(endpoint(path)) + .url(normalizedUrl + path) .header("Accept", "application/json") - if (accessKey.isNotBlank()) builder.header("Authorization", "Bearer $accessKey") + 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() @@ -225,23 +249,38 @@ class CompanionApi(private val context: Context) { 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 { "Companion returned HTTP ${response.code}." } + ?: text.take(600).ifBlank { "Server returned HTTP ${response.code}." } throw IllegalStateException(message) } - return json ?: throw IllegalStateException("Companion returned a non-JSON response: ${text.take(220).ifBlank { "empty body" }}") + 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 = request("/api/health") + val json = requestFirst(listOf("/health", "/api/health")) val checks = json.optJSONObject("checks")?.optJSONObject("hermes") val config = json.optJSONObject("config") HealthState( reachable = true, - status = "Companion is reachable.", + 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", @@ -250,7 +289,25 @@ class CompanionApi(private val context: Context) { } suspend fun validate(): String = withContext(Dispatchers.IO) { - request("/api/auth/validate").optString("message", "Connection works.") + requestFirst(listOf("/v1/models", "/api/auth/validate")).optString("message", "Connection works.") + } + + 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 chat(prompt: String): ChatResult = withContext(Dispatchers.IO) { @@ -268,7 +325,7 @@ class CompanionApi(private val context: Context) { return if (result.hermesAvailable) { "Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}." } else { - result.reply.ifBlank { "Hermes CLI is not available on the companion machine." } + result.reply.ifBlank { "Hermes is not available through this server." } } } @@ -307,8 +364,24 @@ class CompanionApi(private val context: Context) { "exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}" ).filter { it.isNotBlank() }.joinToString("\n") } + + 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.bearerToken.isBlank() && legacyAccessKey.isNotBlank()) { + connectionStorage.bearerToken = legacyAccessKey + } + if (legacyUrl.isNotBlank() || legacyAccessKey.isNotBlank()) { + prefs.edit().remove("baseUrl").remove("accessKey").apply() + } + } } +class UnauthorizedConnectionException(message: String) : IllegalStateException(message) + fun cleanServerText(text: String): String = text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "") .replace("\u0000", "") @@ -336,8 +409,8 @@ fun HermesNativeApp() { scope.launch { snackbar.showSnackbar(message) } } - LaunchedEffect(screen, api.baseUrl, api.accessKey) { - runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false } + LaunchedEffect(screen) { + api.checkConnection().let { online = it is ConnectionCheckResult.Healthy } } HermesMobileTheme { @@ -667,7 +740,7 @@ fun TerminalHome(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> U var output by remember { mutableStateOf("Command output appears here.") } var busy by remember { mutableStateOf(false) } ScreenList { - SectionHeader("Command", "Run a shell command on the companion workspace.") + SectionHeader("Command", "Legacy companion command runner.") FlatCard { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { FieldLabel("Working directory") @@ -689,9 +762,9 @@ fun TerminalHome(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> U } } CodeCard(output) - SectionHeader("Workspace", "Browse files or inspect the companion host.") - ListRow(icon = Icons.Filled.Folder, title = "Files", subtitle = "Browse, read, and edit workspace files", onClick = { onPanel(TerminalPanel.Files) }) - ListRow(icon = Icons.Filled.Terminal, title = "Machine", subtitle = "Companion health, workspace, and CLI status") { onPanel(TerminalPanel.Machine) } + SectionHeader("Workspace", "Legacy companion utilities kept for compatibility.") + ListRow(icon = Icons.Filled.Folder, title = "Files", subtitle = "Browse legacy workspace files", onClick = { onPanel(TerminalPanel.Files) }) + ListRow(icon = Icons.Filled.Terminal, title = "Machine", subtitle = "Gateway health and legacy host details") { onPanel(TerminalPanel.Machine) } SectionHeader("Chat", "Start over in the messenger view.") ListRow(icon = Icons.Filled.ChatBubble, title = "New Inbox chat", subtitle = "Reset the chat session and start fresh", onClick = onNewChat) } @@ -743,7 +816,7 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) LaunchedEffect(Unit) { load(".") } DetailList("Files", onBack) { - SectionHeader("Workspace files", "Navigate the companion workspace and edit one file at a time.") + SectionHeader("Workspace files", "Legacy companion file access.") Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { SecondaryButton("Up", Icons.Filled.ArrowBack, Modifier.weight(0.8f), enabled = path != ".") { load(parentPath(path)) } PrimaryButton(if (loading) "Loading..." else "Refresh", Icons.Filled.Refresh, Modifier.weight(1f), enabled = !loading) { load() } @@ -810,47 +883,95 @@ fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) { @Composable fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) { val scope = remember { CoroutineScope(Dispatchers.Main) } - var baseUrl by remember { mutableStateOf(api.baseUrl) } - var accessKey by remember { mutableStateOf(api.accessKey) } - var status by remember { mutableStateOf("Server URL is blank until you enter your companion address.") } + val initialConfig = remember { api.connectionConfig() } + var baseUrl by remember { mutableStateOf(initialConfig.serverUrl) } + 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 saved = api.saveConnection(form).getOrElse { + connectionState = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.") + notify(connectionState.message) + return + } + connectionState = saved + accessKey = "" + scope.launch { + val checked = api.checkConnectionState() + connectionState = checked + notify(checked.message) + } + } ScreenList { - SectionHeader("Companion", "Connect this device before using Inbox, terminal, or files.") + SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.") FlatCard { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - FieldLabel("Companion server URL") + FieldLabel("Hermes gateway URL") AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri) - FieldLabel("Access key") - AppTextField(accessKey, { accessKey = it }, "hm_...", password = true) + FieldLabel("Authentication") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + ModeChip("Bearer", authMode == ConnectionAuthMode.BearerToken, Modifier.weight(1f)) { + authMode = ConnectionAuthMode.BearerToken + } + ModeChip("None", authMode == ConnectionAuthMode.None, Modifier.weight(1f)) { + authMode = ConnectionAuthMode.None + } + } + if (authMode == ConnectionAuthMode.BearerToken) { + FieldLabel("Access key") + AppTextField( + accessKey, + { accessKey = it }, + if (initialConfig.hasBearerToken) "Stored token unchanged" else "hm_...", + password = true + ) + } + FieldLabel("Session label") + AppTextField(sessionLabel, { sessionLabel = it }, "Default session") Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) { - api.baseUrl = baseUrl - api.accessKey = accessKey - status = "Settings saved." - notify(status) + val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel) + connectionState = api.saveConnection(form).getOrElse { + ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.") + } + if (connectionState.status == ConnectionStatus.Checking) { + connectionState = ConnectionStateReducer.savedForManualCheck(connectionState.config) + accessKey = "" + } + notify(connectionState.message) } PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) { - api.baseUrl = baseUrl - api.accessKey = accessKey - status = "Testing..." - scope.launch { - status = runCatching { api.validate() }.getOrElse { it.message ?: "Connection failed" } - notify(status) - } + saveAndCheck() } } } } - NoticeCard(status, danger = status.contains("failed", true) || status.contains("Unauthorized", true)) - SectionHeader("Machine", "Inspect companion health and credentials.") - ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health, workspace, and CLI status") { onPanel(SettingsPanel.Health) } - ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Use companion environment and Hermes CLI auth") { onPanel(SettingsPanel.Accounts) } + ConnectionNotice(connectionState) + 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) } SectionHeader("Preferences", "Local defaults stored on this Android device.") ListRow(icon = Icons.Filled.Tune, title = "Agent Defaults", subtitle = api.defaults.run { "$model - $effort" }) { onPanel(SettingsPanel.Defaults) } ListRow(icon = Icons.Filled.Palette, title = "Appearance", subtitle = "Dark native theme settings") { onPanel(SettingsPanel.Appearance) } } } +@Composable +fun ConnectionNotice(state: ConnectionUiState) { + NoticeCard( + state.noticeText, + danger = state.status == ConnectionStatus.Offline || + state.status == ConnectionStatus.Unauthorized || + state.urlError != null + ) +} + @Composable fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) { var density by remember { mutableStateOf(api.appearance.density) } @@ -911,7 +1032,7 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un DetailList("Hermes", onBack) { NoticeCard(health.status, danger = !health.reachable) - MetricRow("Companion URL", api.baseUrl.ifBlank { "Not set" }) + MetricRow("Gateway URL", api.baseUrl.ifBlank { "Not set" }) MetricRow("Hermes CLI", health.hermesCli) MetricRow("Workspace", health.workspace) Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { @@ -931,9 +1052,9 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un @Composable fun ConnectedAccountsSettings(onBack: () -> Unit) { DetailList("Accounts", onBack) { - MetricRow("Hermes CLI", "Managed on companion machine") - MetricRow("API keys", "Read from companion environment") - NoticeCard("The mobile app does not store provider account tokens. Configure Hermes and provider credentials on the companion host, then use Settings > Hermes to test the CLI.") + MetricRow("Hermes runtime", "Managed by the upstream server") + MetricRow("API keys", "Read from upstream Hermes configuration") + NoticeCard("The mobile app does not store provider account tokens. Configure Hermes and provider credentials upstream, then use Settings > Hermes to test connectivity.") } } 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 new file mode 100644 index 0000000..4fac2cb --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/ConnectionModels.kt @@ -0,0 +1,213 @@ +package cloud.molberg.hermesmobile.connection + +import java.net.URI +import java.net.URISyntaxException + +enum class ConnectionAuthMode { None, BearerToken } + +enum class ConnectionStatus { Unconfigured, Offline, Checking, Connected, Unauthorized } + +data class ConnectionConfig( + val serverUrl: String = "", + val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken, + val hasBearerToken: Boolean = false, + val sessionLabel: String = "Default session" +) + +data class ConnectionForm( + val serverUrl: String = "", + val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken, + val bearerToken: String = "", + val sessionLabel: String = "Default session" +) + +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 canRetry: Boolean = false +) { + val connected: Boolean get() = status == ConnectionStatus.Connected + val noticeText: String + get() { + val label = when (status) { + ConnectionStatus.Unconfigured -> "Not configured" + ConnectionStatus.Checking -> "Checking" + ConnectionStatus.Connected -> "Connected" + ConnectionStatus.Offline -> "Offline" + ConnectionStatus.Unauthorized -> "Unauthorized" + } + val retry = if (canRetry) " Retry after updating the URL or authentication." else "" + return "$label: $message$retry" + } +} + +sealed interface ConnectionCheckResult { + data class Healthy(val message: String) : ConnectionCheckResult + data class Offline(val message: String) : ConnectionCheckResult + data class Unauthorized(val message: String) : ConnectionCheckResult +} + +object ConnectionValidator { + fun normalizeServerUrl(input: String): Result { + val trimmed = input.trim().trimEnd('/') + if (trimmed.isBlank()) return Result.failure(IllegalArgumentException("Server URL is required.")) + val uri = try { + URI(trimmed) + } catch (_: URISyntaxException) { + return Result.failure(IllegalArgumentException("Enter a valid http or 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.rawUserInfo != null) { + return Result.failure(IllegalArgumentException("Do not include credentials in the server URL.")) + } + return Result.success(trimmed) + } + + fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result = + normalizeServerUrl(form.serverUrl).map { normalized -> + val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken + ConnectionConfig( + serverUrl = normalized, + 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) } + ) +} + +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 saved(config: ConnectionConfig): ConnectionUiState = + ConnectionUiState( + config = config, + status = ConnectionStatus.Checking, + message = "Checking server health...", + canRetry = false + ) + + fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState = + ConnectionUiState( + config = config, + status = ConnectionStatus.Offline, + message = "Settings saved. Run Test to check the server.", + 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 + ) +} + +interface ConnectionStore { + var serverUrl: String + var sessionLabel: String + var authMode: ConnectionAuthMode + var bearerToken: String + + fun config(): ConnectionConfig = + ConnectionConfig( + serverUrl = serverUrl, + authMode = authMode, + hasBearerToken = bearerToken.isNotBlank(), + sessionLabel = sessionLabel + ) + + fun save(form: ConnectionForm, normalizedUrl: String) { + serverUrl = normalizedUrl + authMode = form.authMode + sessionLabel = form.sessionLabel + bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else "" + } +} + +interface ConnectionRepository { + fun config(): ConnectionConfig + fun save(form: ConnectionForm): Result + suspend fun check(): ConnectionUiState +} + +class StoredConnectionRepository( + private val store: ConnectionStore, + private val healthCheck: suspend () -> ConnectionCheckResult +) : ConnectionRepository { + override fun config(): ConnectionConfig = store.config() + + override fun save(form: ConnectionForm): Result { + val effectiveForm = if ( + form.authMode == ConnectionAuthMode.BearerToken && + form.bearerToken.isBlank() && + store.bearerToken.isNotBlank() + ) { + form.copy(bearerToken = store.bearerToken) + } else { + form + } + return ConnectionValidator.validateForm(effectiveForm).mapCatching { config -> + store.save(effectiveForm, config.serverUrl) + ConnectionStateReducer.saved(store.config()) + }.recoverCatching { + throw IllegalArgumentException(it.message ?: "Connection settings are invalid.") + } + } + + override suspend fun check(): ConnectionUiState = + ConnectionStateReducer.result(store.config(), healthCheck()) +} 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 new file mode 100644 index 0000000..ec7f9da --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/connection/SecureConnectionStorage.kt @@ -0,0 +1,92 @@ +package cloud.molberg.hermesmobile.connection + +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import android.util.Base64 + +class SecureConnectionStorage(context: Context) : ConnectionStore { + private val prefs: SharedPreferences = + context.getSharedPreferences("connection", Context.MODE_PRIVATE) + + override var serverUrl: String + get() = prefs.getString(KEY_URL, "") ?: "" + set(value) { + prefs.edit().putString(KEY_URL, value.trim().trimEnd('/')).apply() + } + + override var sessionLabel: String + get() = prefs.getString(KEY_SESSION, "Default session") ?: "Default session" + set(value) { + prefs.edit().putString(KEY_SESSION, value.trim().ifBlank { "Default session" }).apply() + } + + override var authMode: ConnectionAuthMode + get() = runCatching { + ConnectionAuthMode.valueOf(prefs.getString(KEY_AUTH_MODE, ConnectionAuthMode.BearerToken.name)!!) + }.getOrDefault(ConnectionAuthMode.BearerToken) + set(value) { + prefs.edit().putString(KEY_AUTH_MODE, value.name).apply() + } + + override var bearerToken: String + get() = decrypt(prefs.getString(KEY_BEARER_TOKEN, null)) + set(value) { + prefs.edit().apply { + if (value.isBlank()) remove(KEY_BEARER_TOKEN) else putString(KEY_BEARER_TOKEN, encrypt(value.trim())) + }.apply() + } + + private fun encrypt(value: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + return listOf(cipher.iv, encrypted).joinToString(":") { + Base64.encodeToString(it, Base64.NO_WRAP) + } + } + + private fun decrypt(value: String?): String { + if (value.isNullOrBlank()) return "" + return runCatching { + val parts = value.split(":") + if (parts.size != 2) return "" + val iv = Base64.decode(parts[0], Base64.NO_WRAP) + val encrypted = Base64.decode(parts[1], Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + String(cipher.doFinal(encrypted), Charsets.UTF_8) + }.getOrDefault("") + } + + private fun getOrCreateKey(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + ) + return generator.generateKey() + } + + private companion object { + const val KEYSTORE = "AndroidKeyStore" + 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_SESSION = "sessionLabel" + const val KEY_AUTH_MODE = "authMode" + const val KEY_BEARER_TOKEN = "bearerToken" + } +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt index 8ca4049..9162e23 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt @@ -26,11 +26,15 @@ data class ServerProfile( ) data class ServerCapabilities( + val health: Boolean, + val models: Boolean, + val chatCompletions: Boolean, + val responses: Boolean, val sessions: Boolean, val streaming: Boolean, val toolEvents: Boolean, val artifacts: Boolean, - val companionAdapter: Boolean + val legacyCompanionAdapter: Boolean ) data class AuthSession( diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt index c524153..d32b878 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt @@ -42,7 +42,7 @@ fun ServerProfileDto.toDomain(): ServerProfile = } fun ServerCapabilitiesDto.toDomain(): ServerCapabilities = - ServerCapabilities(sessions, streaming, toolEvents, artifacts, companionAdapter) + ServerCapabilities(health, models, chatCompletions, responses, sessions, streaming, toolEvents, artifacts, legacyCompanionAdapter) fun AuthSessionDto.toDomain(): AuthSession = AuthSession(id, profileId, state.toDomain(), principal, scopes, issuedAt, expiresAt) diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt index 2862c0b..fab5c5a 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt @@ -197,9 +197,19 @@ private object FakeHermesFixtures { val profile = ServerProfileDto( id = "profile-local", name = "Local Hermes", - baseUrl = "http://10.0.2.2:8787/", + baseUrl = "http://10.0.2.2:8000/", auth = AuthConfigDto(mode = AuthModeDto.BearerToken, tokenAlias = "local-dev-key"), - capabilities = ServerCapabilitiesDto(companionAdapter = true) + capabilities = ServerCapabilitiesDto( + health = true, + models = true, + chatCompletions = true, + responses = true, + sessions = false, + streaming = false, + toolEvents = false, + artifacts = false, + legacyCompanionAdapter = false + ) ) val auth = AuthSessionDto( @@ -228,8 +238,8 @@ private object FakeHermesFixtures { callId = "call-workspace", name = "workspace.inspect", status = ToolStatusDto.Succeeded, - summary = "Found Android app, companion package, and project docs.", - output = "apps/mobile/android\napps/companion\nPROJECT_PLAN.md\ndocs/ROADMAP.md" + summary = "Found Android app and project docs.", + output = "apps/mobile/android\nPROJECT_PLAN.md\ndocs/ROADMAP.md" ) ) @@ -262,7 +272,7 @@ private object FakeHermesFixtures { sessionId = "session-release", role = MessageRoleDto.Assistant, parts = listOf( - MessagePartDto.Text("Use the local Android build as the first gate, then exercise the companion flows separately."), + MessagePartDto.Text("Use the local Android build as the first gate, then exercise direct gateway flows separately."), MessagePartDto.Code("bash", "GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug"), MessagePartDto.ToolResultRef("tool-workspace"), MessagePartDto.ArtifactRef("artifact-checklist") diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt index 0c0994e..a6fcfbf 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt @@ -31,11 +31,15 @@ data class AuthConfigDto( ) data class ServerCapabilitiesDto( + val health: Boolean = true, + val models: Boolean = true, + val chatCompletions: Boolean = true, + val responses: Boolean = true, val sessions: Boolean = true, val streaming: Boolean = true, val toolEvents: Boolean = true, val artifacts: Boolean = true, - val companionAdapter: Boolean = false + val legacyCompanionAdapter: Boolean = false ) data class AuthSessionDto( 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 new file mode 100644 index 0000000..ce5f513 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/connection/ConnectionStateTest.kt @@ -0,0 +1,163 @@ +package cloud.molberg.hermesmobile.connection + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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( + ConnectionForm( + serverUrl = "http://10.0.2.2:8787", + authMode = ConnectionAuthMode.BearerToken, + bearerToken = " " + ) + ) + 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) + } + + + @Test + fun reducerSurfacesOfflineUnauthorizedRetryAndConnectedStates() { + val config = ConnectionConfig( + serverUrl = "http://10.0.2.2:8787", + authMode = ConnectionAuthMode.BearerToken, + hasBearerToken = true + ) + + 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(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") + } + + repository.save( + ConnectionForm( + serverUrl = "http://10.0.2.2:8787", + authMode = ConnectionAuthMode.None, + bearerToken = "ignored-token" + ) + ).getOrThrow() + + assertEquals(ConnectionAuthMode.None, store.authMode) + assertEquals("", store.bearerToken) + assertFalse(repository.config().hasBearerToken) + } + + @Test + fun repositoryCheckMapsHealthResultToUiState() { + 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.") + } + + val state = kotlinx.coroutines.runBlocking { repository.check() } + + assertEquals(ConnectionStatus.Unauthorized, state.status) + assertTrue(state.canRetry) + assertTrue(state.config.hasBearerToken) + } + + private class InMemoryConnectionStore : ConnectionStore { + override var serverUrl: String = "" + override var sessionLabel: String = "Default session" + override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken + override var bearerToken: String = "" + } +} diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt index 7b47653..e523367 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt @@ -25,19 +25,37 @@ import org.junit.Test class HermesMapperTest { @Test - fun serverProfileTrimsUrlAndPreservesOptionalCompanionCapability() { + fun serverProfileTrimsUrlAndPreservesDirectGatewayCapabilities() { val profile = ServerProfileDto( id = "p1", name = "", baseUrl = "https://hermes.example.test/", auth = AuthConfigDto(AuthModeDto.BearerToken, tokenAlias = "prod"), - capabilities = ServerCapabilitiesDto(companionAdapter = true) + capabilities = ServerCapabilitiesDto( + health = true, + models = true, + chatCompletions = true, + responses = true, + sessions = false, + streaming = false, + toolEvents = false, + artifacts = false, + legacyCompanionAdapter = true + ) ).toDomain() assertEquals("https://hermes.example.test", profile.baseUrl) assertEquals("https://hermes.example.test", profile.name) assertEquals(AuthMode.BearerToken, profile.authMode) - assertTrue(profile.capabilities.companionAdapter) + assertTrue(profile.capabilities.health) + assertTrue(profile.capabilities.models) + assertTrue(profile.capabilities.chatCompletions) + assertTrue(profile.capabilities.responses) + assertFalse(profile.capabilities.sessions) + assertFalse(profile.capabilities.streaming) + assertFalse(profile.capabilities.toolEvents) + assertFalse(profile.capabilities.artifacts) + assertTrue(profile.capabilities.legacyCompanionAdapter) } @Test diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt index 098eb43..510f830 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt @@ -23,11 +23,15 @@ class FakeHermesRepositoryTest { authMode = AuthMode.BearerToken, tokenAlias = "test-token", capabilities = ServerCapabilities( + health = true, + models = true, + chatCompletions = true, + responses = true, sessions = true, streaming = true, toolEvents = true, artifacts = true, - companionAdapter = false + legacyCompanionAdapter = false ), createdAt = null, updatedAt = null @@ -56,6 +60,11 @@ class FakeHermesRepositoryTest { val events = repository.streamEvents(profile.id, session.id).toList() assertEquals(AuthState.Authenticated, auth!!.state) + assertTrue(profile.capabilities.health) + assertTrue(profile.capabilities.models) + assertTrue(profile.capabilities.chatCompletions) + assertTrue(profile.capabilities.responses) + assertEquals(false, profile.capabilities.legacyCompanionAdapter) assertEquals(3, messages.size) assertEquals("workspace.inspect", tools.single().name) assertEquals("beta-checklist.md", artifacts.single().name) diff --git a/docs/DIRECT_GATEWAY_ARCHITECTURE.md b/docs/DIRECT_GATEWAY_ARCHITECTURE.md new file mode 100644 index 0000000..6b83b58 --- /dev/null +++ b/docs/DIRECT_GATEWAY_ARCHITECTURE.md @@ -0,0 +1,65 @@ +# Hermes Mobile Direct Gateway Architecture + +Hermes Mobile is pivoting to a native Android client that talks directly to a remote upstream Hermes Agent gateway/API server. The `apps/companion` TypeScript server is legacy compatibility code and must not be required by Android or future releases. + +## Architecture + +```text +Android app + Kotlin/Compose UI + connection/auth storage + transport DTOs + domain repositories + | + | HTTPS, bearer token when configured + v +Upstream Hermes Agent gateway/API server + auth/session authority + chat/completion authority + streaming/tool/artifact authority + provider credentials and Hermes runtime +``` + +The Android app remains a thin client. It may store server profiles, non-provider UI preferences, selected session IDs, and Android-secured bearer tokens. It must not embed provider credentials, clone Hermes backend behavior, or make the legacy companion server part of the release path. + +## Documented Upstream Surface + +The current repository/context documents these upstream Hermes API-server endpoints: + +| Endpoint | Status | Mobile contract | +| --- | --- | --- | +| `GET /health` | Documented | Primary reachability and compatibility probe. Response shape beyond a successful JSON response is treated as server-specific. | +| `GET /v1/models` | Documented | Authenticated compatibility probe for bearer-token connections and future model picker source. | +| `POST /v1/chat/completions` | Documented | Baseline OpenAI-compatible chat path. Mobile must assume this may not expose Hermes session IDs or structured tool events. | +| `POST /v1/responses` | Documented | Preferred future request/streaming path if upstream exposes richer response events. Exact Hermes event mapping is not yet verified. | + +These are the only upstream API-server routes currently treated as documented by this repository. Any route not listed here must be labeled provisional until checked against official Hermes Agent docs or a running gateway. + +## Unverified Assumptions + +The following are useful design assumptions, not confirmed contracts: + +- Bearer authentication is accepted through an `Authorization: Bearer ` header on `/v1/*`. +- `/health` returns a stable `status` field. +- `/v1/responses` can provide enough streaming metadata to preserve Hermes sessions, tool lifecycle, artifacts, cancellation, and reconnect state. +- Hermes session listing, creation, continuation, and deletion have stable direct HTTP endpoints. +- Tool result and artifact listing/download have stable direct HTTP endpoints. +- Cancellation has a stable direct HTTP endpoint. +- Error payloads consistently include `message` or `error`. + +Android contracts may model these needs, but implementation must keep them capability-gated until verified. + +## Compatibility Policy + +- Direct gateway routes are the default target for all new Android transport work. +- Legacy companion routes such as `/api/health`, `/api/auth/validate`, `/api/chat`, `/api/files/*`, and `/api/terminal/*` are temporary compatibility only. +- UI and domain terminology should say "gateway", "server", or "upstream Hermes" unless explicitly describing legacy companion behavior. +- 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. + +## 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. +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. +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 8286ec4..1bce4fa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,23 +1,23 @@ # Hermes Mobile Beta Roadmap -## Current Milestone: Native Android Beta Chat and Connection UX +## Current Milestone: Native Android Direct Gateway Contract -Status: in progress, close to beta validation. F0 source landing and F1 design-system extraction are complete; Android debug builds now pass in the shared Gradle cache environment. +Status: in progress. Android foundation work is complete, and the beta path now targets a remote upstream Hermes Agent gateway/API server directly. The legacy `apps/companion` server is compatibility-only and not required for future Android releases. Done: - Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface. -- Companion settings flow stores companion URL and access key locally, validates bearer auth, and surfaces health errors in-app. +- Android connection flow stores a gateway URL and bearer token locally, validates URL/auth state, and surfaces offline/unauthorized/retry states in-app. - Chat UI supports conversation list/thread navigation, native composer, busy state, selectable replies, error bubbles, and new-chat reset. -- Companion `/api/chat` invokes Hermes through the CLI with `hermes chat -Q --source tool -q `. -- Companion chat responses sanitize terminal banners/help/control output, truncate large replies for mobile, report CLI availability, and return structured failure responses. -- Workspace utilities remain native: command runner, health view, file browsing, read, and write. +- 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. - Local build artifacts, Android transient build output, `.gradle-user/`, and `.dev/` are ignored. - 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 a real paired device against a running companion and authenticated Hermes CLI. -- Exercise upstream-compatible transport/auth/session/streaming behavior end to end: bearer auth, health, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery. +- 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 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. - Document beta install/run steps after the native build is verified. @@ -26,9 +26,9 @@ Remaining for beta: - Native Kotlin/Compose Android app is the shipped beta client. - No WebView, Capacitor runtime dependency, fake backend, embedded secrets, or new external infrastructure is introduced for beta. -- Companion transport and auth are upstream-compatible and exercised against the real Hermes CLI. -- Chat/session/streaming behavior is exercised with Hermes, including success, CLI unavailable, timeout, non-zero exit, and malformed response cases. +- Direct gateway transport and auth are exercised against the upstream Hermes gateway/API server. +- Chat/session/streaming behavior is exercised with Hermes, including success, unauthorized, timeout, server error, malformed response, and stream interruption cases. - Settings connection flow is reliable: save, validate, health, Hermes test, offline/unauthorized states, and recovery are clear. - Adaptive accessibility passes on target Android devices or emulators. -- Required checks pass: `./gradlew :app:assembleDebug` from `apps/mobile/android`, companion typecheck/build, and relevant lint. +- Required checks pass: `./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` and relevant source lint. - Release notes clearly list remaining known beta limitations.