feat: pivot mobile client to direct gateway
This commit is contained in:
@@ -5,6 +5,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### 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 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 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.
|
- 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.
|
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
|
||||||
|
|
||||||
### Changed
|
### 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.
|
- 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`.
|
- 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.
|
- 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.
|
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
|
||||||
|
|
||||||
### Verification
|
### 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 — script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed.
|
||||||
- Parent preflight immediately before B1 completion — `git diff --check` passed.
|
- Parent preflight immediately before B1 completion — `git diff --check` passed.
|
||||||
- `git diff --check` — passed
|
- `git diff --check` — passed
|
||||||
|
|||||||
+17
-10
@@ -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
|
## Beta path — upstream-connected MVP
|
||||||
|
|
||||||
- [ ] **B2 — Connection and authentication UX**
|
- [ ] **B2 — Direct gateway connection and authentication UX**
|
||||||
- Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states.
|
- 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.
|
- Store sensitive values using Android-appropriate secure storage; never log secrets.
|
||||||
- Verification: connection-state tests and manual unhappy-path walkthrough.
|
- Verification: connection-state tests and manual unhappy-path walkthrough.
|
||||||
|
|
||||||
- [ ] **B3 — Session list and durable chat state**
|
- [ ] **B3 — Direct gateway session contract and durable chat state**
|
||||||
- Fetch/list/search/select/create sessions through repository contracts.
|
- 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.
|
- 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**
|
- [ ] **B4 — Direct gateway streaming, reconnects, and resilient long-running work**
|
||||||
- Stream event reducer: token deltas, tool lifecycle, final/error/cancel, reconnect/backoff.
|
- 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.
|
- 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**
|
- [ ] **B5 — Tool, artifact, and output experience**
|
||||||
- Compact tool cards with lifecycle/status, expandable output, copy/share/save affordances.
|
- 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.
|
- [ ] Notifications for task completion.
|
||||||
- [ ] Mature tablet/two-pane session + optional terminal/log pane.
|
- [ ] Mature tablet/two-pane session + optional terminal/log pane.
|
||||||
- [ ] Offline cache/search and diagnostic export with privacy controls.
|
- [ ] 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.
|
- 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.
|
- 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
|
## Work-loop checklist
|
||||||
|
|
||||||
Before coding:
|
Before coding:
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -155,30 +164,36 @@ data class AppearancePrefs(
|
|||||||
|
|
||||||
data class HealthState(
|
data class HealthState(
|
||||||
val reachable: Boolean = false,
|
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 hermesCli: String = "Unknown / unavailable",
|
||||||
val workspace: String = "Unknown",
|
val workspace: String = "Unknown",
|
||||||
val uptime: String = "-",
|
val uptime: String = "-",
|
||||||
val mode: 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 prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE)
|
||||||
private val http = OkHttpClient.Builder()
|
private val http = OkHttpClient.Builder()
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
.readTimeout(120, TimeUnit.SECONDS)
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
init {
|
||||||
|
migrateLegacyConnectionPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
var baseUrl: String
|
var baseUrl: String
|
||||||
get() = prefs.getString("baseUrl", "") ?: ""
|
get() = connectionStorage.serverUrl
|
||||||
set(value) {
|
set(value) {
|
||||||
prefs.edit().putString("baseUrl", value.trim()).apply()
|
connectionStorage.serverUrl = value
|
||||||
}
|
}
|
||||||
|
|
||||||
var accessKey: String
|
var accessKey: String
|
||||||
get() = prefs.getString("accessKey", "") ?: ""
|
get() = connectionStorage.bearerToken
|
||||||
set(value) {
|
set(value) {
|
||||||
prefs.edit().putString("accessKey", value.trim()).apply()
|
connectionStorage.bearerToken = value
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaults: AgentDefaults
|
var defaults: AgentDefaults
|
||||||
@@ -207,15 +222,24 @@ class CompanionApi(private val context: Context) {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun endpoint(path: String): String = baseUrl.trimEnd('/') + path
|
fun connectionConfig() = connectionRepository.config()
|
||||||
|
|
||||||
|
fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> =
|
||||||
|
connectionRepository.save(form)
|
||||||
|
|
||||||
|
suspend fun checkConnectionState(): ConnectionUiState =
|
||||||
|
connectionRepository.check()
|
||||||
|
|
||||||
private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject {
|
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 mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
val builder = Request.Builder()
|
val builder = Request.Builder()
|
||||||
.url(endpoint(path))
|
.url(normalizedUrl + path)
|
||||||
.header("Accept", "application/json")
|
.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) {
|
val request = when (method) {
|
||||||
"POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
|
"POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
|
||||||
"PUT" -> builder.put((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 text = cleanServerText(response.body?.string().orEmpty())
|
||||||
val json = runCatching { if (text.isBlank()) JSONObject() else JSONObject(text) }.getOrNull()
|
val json = runCatching { if (text.isBlank()) JSONObject() else JSONObject(text) }.getOrNull()
|
||||||
if (!response.isSuccessful) {
|
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() }
|
val message = json?.optString("reply")?.takeIf { it.isNotBlank() }
|
||||||
?: json?.optString("message")?.takeIf { it.isNotBlank() }
|
?: json?.optString("message")?.takeIf { it.isNotBlank() }
|
||||||
?: json?.optString("error")?.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)
|
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<String>): JSONObject {
|
||||||
|
var lastError: Throwable? = null
|
||||||
|
for (path in paths) {
|
||||||
|
val result = runCatching { request(path) }
|
||||||
|
result.onSuccess { return it }
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error is UnauthorizedConnectionException) throw error
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
throw lastError ?: IllegalStateException("Server is offline or unreachable.")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun health(): HealthState = withContext(Dispatchers.IO) {
|
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 checks = json.optJSONObject("checks")?.optJSONObject("hermes")
|
||||||
val config = json.optJSONObject("config")
|
val config = json.optJSONObject("config")
|
||||||
HealthState(
|
HealthState(
|
||||||
reachable = true,
|
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.",
|
hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Hermes CLI was not found on PATH.",
|
||||||
workspace = config?.optString("workspaceRoot") ?: "Unknown",
|
workspace = config?.optString("workspaceRoot") ?: "Unknown",
|
||||||
uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s",
|
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) {
|
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) {
|
suspend fun chat(prompt: String): ChatResult = withContext(Dispatchers.IO) {
|
||||||
@@ -268,7 +325,7 @@ class CompanionApi(private val context: Context) {
|
|||||||
return if (result.hermesAvailable) {
|
return if (result.hermesAvailable) {
|
||||||
"Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}."
|
"Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}."
|
||||||
} else {
|
} else {
|
||||||
result.reply.ifBlank { "Hermes CLI is not available on the companion machine." }
|
result.reply.ifBlank { "Hermes is not available through this server." }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +364,23 @@ class CompanionApi(private val context: Context) {
|
|||||||
"exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}"
|
"exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}"
|
||||||
).filter { it.isNotBlank() }.joinToString("\n")
|
).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 =
|
fun cleanServerText(text: String): String =
|
||||||
text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "")
|
text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "")
|
||||||
@@ -336,8 +409,8 @@ fun HermesNativeApp() {
|
|||||||
scope.launch { snackbar.showSnackbar(message) }
|
scope.launch { snackbar.showSnackbar(message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(screen, api.baseUrl, api.accessKey) {
|
LaunchedEffect(screen) {
|
||||||
runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false }
|
api.checkConnection().let { online = it is ConnectionCheckResult.Healthy }
|
||||||
}
|
}
|
||||||
|
|
||||||
HermesMobileTheme {
|
HermesMobileTheme {
|
||||||
@@ -667,7 +740,7 @@ fun TerminalHome(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> U
|
|||||||
var output by remember { mutableStateOf("Command output appears here.") }
|
var output by remember { mutableStateOf("Command output appears here.") }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
ScreenList {
|
ScreenList {
|
||||||
SectionHeader("Command", "Run a shell command on the companion workspace.")
|
SectionHeader("Command", "Legacy companion command runner.")
|
||||||
FlatCard {
|
FlatCard {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
FieldLabel("Working directory")
|
FieldLabel("Working directory")
|
||||||
@@ -689,9 +762,9 @@ fun TerminalHome(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CodeCard(output)
|
CodeCard(output)
|
||||||
SectionHeader("Workspace", "Browse files or inspect the companion host.")
|
SectionHeader("Workspace", "Legacy companion utilities kept for compatibility.")
|
||||||
ListRow(icon = Icons.Filled.Folder, title = "Files", subtitle = "Browse, read, and edit workspace files", onClick = { onPanel(TerminalPanel.Files) })
|
ListRow(icon = Icons.Filled.Folder, title = "Files", subtitle = "Browse legacy workspace files", onClick = { onPanel(TerminalPanel.Files) })
|
||||||
ListRow(icon = Icons.Filled.Terminal, title = "Machine", subtitle = "Companion health, workspace, and CLI status") { onPanel(TerminalPanel.Machine) }
|
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.")
|
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)
|
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(".") }
|
LaunchedEffect(Unit) { load(".") }
|
||||||
|
|
||||||
DetailList("Files", onBack) {
|
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()) {
|
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)) }
|
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() }
|
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
|
@Composable
|
||||||
fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) {
|
fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) {
|
||||||
val scope = remember { CoroutineScope(Dispatchers.Main) }
|
val scope = remember { CoroutineScope(Dispatchers.Main) }
|
||||||
var baseUrl by remember { mutableStateOf(api.baseUrl) }
|
val initialConfig = remember { api.connectionConfig() }
|
||||||
var accessKey by remember { mutableStateOf(api.accessKey) }
|
var baseUrl by remember { mutableStateOf(initialConfig.serverUrl) }
|
||||||
var status by remember { mutableStateOf("Server URL is blank until you enter your companion address.") }
|
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 {
|
ScreenList {
|
||||||
SectionHeader("Companion", "Connect this device before using Inbox, terminal, or files.")
|
SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.")
|
||||||
FlatCard {
|
FlatCard {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
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)
|
AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri)
|
||||||
|
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")
|
FieldLabel("Access key")
|
||||||
AppTextField(accessKey, { accessKey = it }, "hm_...", password = true)
|
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()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
|
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
|
||||||
api.baseUrl = baseUrl
|
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
|
||||||
api.accessKey = accessKey
|
connectionState = api.saveConnection(form).getOrElse {
|
||||||
status = "Settings saved."
|
ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
|
||||||
notify(status)
|
}
|
||||||
|
if (connectionState.status == ConnectionStatus.Checking) {
|
||||||
|
connectionState = ConnectionStateReducer.savedForManualCheck(connectionState.config)
|
||||||
|
accessKey = ""
|
||||||
|
}
|
||||||
|
notify(connectionState.message)
|
||||||
}
|
}
|
||||||
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
|
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
|
||||||
api.baseUrl = baseUrl
|
saveAndCheck()
|
||||||
api.accessKey = accessKey
|
|
||||||
status = "Testing..."
|
|
||||||
scope.launch {
|
|
||||||
status = runCatching { api.validate() }.getOrElse { it.message ?: "Connection failed" }
|
|
||||||
notify(status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
ConnectionNotice(connectionState)
|
||||||
NoticeCard(status, danger = status.contains("failed", true) || status.contains("Unauthorized", true))
|
SectionHeader("Gateway", "Inspect server health and authentication.")
|
||||||
SectionHeader("Machine", "Inspect companion health and credentials.")
|
ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health and compatibility status") { onPanel(SettingsPanel.Health) }
|
||||||
ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health, workspace, and CLI status") { onPanel(SettingsPanel.Health) }
|
ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Provider credentials stay upstream, outside the app") { onPanel(SettingsPanel.Accounts) }
|
||||||
ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Use companion environment and Hermes CLI auth") { onPanel(SettingsPanel.Accounts) }
|
|
||||||
SectionHeader("Preferences", "Local defaults stored on this Android device.")
|
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.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) }
|
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
|
@Composable
|
||||||
fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) {
|
fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) {
|
||||||
var density by remember { mutableStateOf(api.appearance.density) }
|
var density by remember { mutableStateOf(api.appearance.density) }
|
||||||
@@ -911,7 +1032,7 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un
|
|||||||
|
|
||||||
DetailList("Hermes", onBack) {
|
DetailList("Hermes", onBack) {
|
||||||
NoticeCard(health.status, danger = !health.reachable)
|
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("Hermes CLI", health.hermesCli)
|
||||||
MetricRow("Workspace", health.workspace)
|
MetricRow("Workspace", health.workspace)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -931,9 +1052,9 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un
|
|||||||
@Composable
|
@Composable
|
||||||
fun ConnectedAccountsSettings(onBack: () -> Unit) {
|
fun ConnectedAccountsSettings(onBack: () -> Unit) {
|
||||||
DetailList("Accounts", onBack) {
|
DetailList("Accounts", onBack) {
|
||||||
MetricRow("Hermes CLI", "Managed on companion machine")
|
MetricRow("Hermes runtime", "Managed by the upstream server")
|
||||||
MetricRow("API keys", "Read from companion environment")
|
MetricRow("API keys", "Read from upstream Hermes configuration")
|
||||||
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.")
|
NoticeCard("The mobile app does not store provider account tokens. Configure Hermes and provider credentials upstream, then use Settings > Hermes to test connectivity.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+213
@@ -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<String> {
|
||||||
|
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<ConnectionConfig> =
|
||||||
|
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<ConnectionUiState>
|
||||||
|
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<ConnectionUiState> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
+92
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -26,11 +26,15 @@ data class ServerProfile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class ServerCapabilities(
|
data class ServerCapabilities(
|
||||||
|
val health: Boolean,
|
||||||
|
val models: Boolean,
|
||||||
|
val chatCompletions: Boolean,
|
||||||
|
val responses: Boolean,
|
||||||
val sessions: Boolean,
|
val sessions: Boolean,
|
||||||
val streaming: Boolean,
|
val streaming: Boolean,
|
||||||
val toolEvents: Boolean,
|
val toolEvents: Boolean,
|
||||||
val artifacts: Boolean,
|
val artifacts: Boolean,
|
||||||
val companionAdapter: Boolean
|
val legacyCompanionAdapter: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AuthSession(
|
data class AuthSession(
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ fun ServerProfileDto.toDomain(): ServerProfile =
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun ServerCapabilitiesDto.toDomain(): ServerCapabilities =
|
fun ServerCapabilitiesDto.toDomain(): ServerCapabilities =
|
||||||
ServerCapabilities(sessions, streaming, toolEvents, artifacts, companionAdapter)
|
ServerCapabilities(health, models, chatCompletions, responses, sessions, streaming, toolEvents, artifacts, legacyCompanionAdapter)
|
||||||
|
|
||||||
fun AuthSessionDto.toDomain(): AuthSession =
|
fun AuthSessionDto.toDomain(): AuthSession =
|
||||||
AuthSession(id, profileId, state.toDomain(), principal, scopes, issuedAt, expiresAt)
|
AuthSession(id, profileId, state.toDomain(), principal, scopes, issuedAt, expiresAt)
|
||||||
|
|||||||
+15
-5
@@ -197,9 +197,19 @@ private object FakeHermesFixtures {
|
|||||||
val profile = ServerProfileDto(
|
val profile = ServerProfileDto(
|
||||||
id = "profile-local",
|
id = "profile-local",
|
||||||
name = "Local Hermes",
|
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"),
|
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(
|
val auth = AuthSessionDto(
|
||||||
@@ -228,8 +238,8 @@ private object FakeHermesFixtures {
|
|||||||
callId = "call-workspace",
|
callId = "call-workspace",
|
||||||
name = "workspace.inspect",
|
name = "workspace.inspect",
|
||||||
status = ToolStatusDto.Succeeded,
|
status = ToolStatusDto.Succeeded,
|
||||||
summary = "Found Android app, companion package, and project docs.",
|
summary = "Found Android app and project docs.",
|
||||||
output = "apps/mobile/android\napps/companion\nPROJECT_PLAN.md\ndocs/ROADMAP.md"
|
output = "apps/mobile/android\nPROJECT_PLAN.md\ndocs/ROADMAP.md"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,7 +272,7 @@ private object FakeHermesFixtures {
|
|||||||
sessionId = "session-release",
|
sessionId = "session-release",
|
||||||
role = MessageRoleDto.Assistant,
|
role = MessageRoleDto.Assistant,
|
||||||
parts = listOf(
|
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.Code("bash", "GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug"),
|
||||||
MessagePartDto.ToolResultRef("tool-workspace"),
|
MessagePartDto.ToolResultRef("tool-workspace"),
|
||||||
MessagePartDto.ArtifactRef("artifact-checklist")
|
MessagePartDto.ArtifactRef("artifact-checklist")
|
||||||
|
|||||||
+5
-1
@@ -31,11 +31,15 @@ data class AuthConfigDto(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class ServerCapabilitiesDto(
|
data class ServerCapabilitiesDto(
|
||||||
|
val health: Boolean = true,
|
||||||
|
val models: Boolean = true,
|
||||||
|
val chatCompletions: Boolean = true,
|
||||||
|
val responses: Boolean = true,
|
||||||
val sessions: Boolean = true,
|
val sessions: Boolean = true,
|
||||||
val streaming: Boolean = true,
|
val streaming: Boolean = true,
|
||||||
val toolEvents: Boolean = true,
|
val toolEvents: Boolean = true,
|
||||||
val artifacts: Boolean = true,
|
val artifacts: Boolean = true,
|
||||||
val companionAdapter: Boolean = false
|
val legacyCompanionAdapter: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AuthSessionDto(
|
data class AuthSessionDto(
|
||||||
|
|||||||
+163
@@ -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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -25,19 +25,37 @@ import org.junit.Test
|
|||||||
|
|
||||||
class HermesMapperTest {
|
class HermesMapperTest {
|
||||||
@Test
|
@Test
|
||||||
fun serverProfileTrimsUrlAndPreservesOptionalCompanionCapability() {
|
fun serverProfileTrimsUrlAndPreservesDirectGatewayCapabilities() {
|
||||||
val profile = ServerProfileDto(
|
val profile = ServerProfileDto(
|
||||||
id = "p1",
|
id = "p1",
|
||||||
name = "",
|
name = "",
|
||||||
baseUrl = "https://hermes.example.test/",
|
baseUrl = "https://hermes.example.test/",
|
||||||
auth = AuthConfigDto(AuthModeDto.BearerToken, tokenAlias = "prod"),
|
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()
|
).toDomain()
|
||||||
|
|
||||||
assertEquals("https://hermes.example.test", profile.baseUrl)
|
assertEquals("https://hermes.example.test", profile.baseUrl)
|
||||||
assertEquals("https://hermes.example.test", profile.name)
|
assertEquals("https://hermes.example.test", profile.name)
|
||||||
assertEquals(AuthMode.BearerToken, profile.authMode)
|
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
|
@Test
|
||||||
|
|||||||
+10
-1
@@ -23,11 +23,15 @@ class FakeHermesRepositoryTest {
|
|||||||
authMode = AuthMode.BearerToken,
|
authMode = AuthMode.BearerToken,
|
||||||
tokenAlias = "test-token",
|
tokenAlias = "test-token",
|
||||||
capabilities = ServerCapabilities(
|
capabilities = ServerCapabilities(
|
||||||
|
health = true,
|
||||||
|
models = true,
|
||||||
|
chatCompletions = true,
|
||||||
|
responses = true,
|
||||||
sessions = true,
|
sessions = true,
|
||||||
streaming = true,
|
streaming = true,
|
||||||
toolEvents = true,
|
toolEvents = true,
|
||||||
artifacts = true,
|
artifacts = true,
|
||||||
companionAdapter = false
|
legacyCompanionAdapter = false
|
||||||
),
|
),
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
updatedAt = null
|
updatedAt = null
|
||||||
@@ -56,6 +60,11 @@ class FakeHermesRepositoryTest {
|
|||||||
val events = repository.streamEvents(profile.id, session.id).toList()
|
val events = repository.streamEvents(profile.id, session.id).toList()
|
||||||
|
|
||||||
assertEquals(AuthState.Authenticated, auth!!.state)
|
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(3, messages.size)
|
||||||
assertEquals("workspace.inspect", tools.single().name)
|
assertEquals("workspace.inspect", tools.single().name)
|
||||||
assertEquals("beta-checklist.md", artifacts.single().name)
|
assertEquals("beta-checklist.md", artifacts.single().name)
|
||||||
|
|||||||
@@ -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 <token>` 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`.
|
||||||
+11
-11
@@ -1,23 +1,23 @@
|
|||||||
# Hermes Mobile Beta Roadmap
|
# 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:
|
Done:
|
||||||
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
|
- 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.
|
- 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 <prompt>`.
|
- Direct gateway compatibility contract is documented in `docs/DIRECT_GATEWAY_ARCHITECTURE.md`.
|
||||||
- Companion chat responses sanitize terminal banners/help/control output, truncate large replies for mobile, report CLI availability, and return structured failure responses.
|
- Legacy companion chat, terminal, and file utilities remain in the repo during migration but are not the target architecture.
|
||||||
- Workspace utilities remain native: command runner, health view, file browsing, read, and write.
|
|
||||||
- Local build artifacts, Android transient build output, `.gradle-user/`, and `.dev/` are ignored.
|
- 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.
|
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
|
||||||
|
|
||||||
Remaining for beta:
|
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`.
|
- 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 a real device or emulator against a running upstream Hermes gateway/API server.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Direct gateway transport and auth are exercised against the upstream Hermes gateway/API server.
|
||||||
- Chat/session/streaming behavior is exercised with Hermes, including success, CLI unavailable, timeout, non-zero exit, and malformed response cases.
|
- 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.
|
- 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.
|
- 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.
|
- Release notes clearly list remaining known beta limitations.
|
||||||
|
|||||||
Reference in New Issue
Block a user