feat: pivot mobile client to direct gateway

This commit is contained in:
Hermes Agent
2026-07-24 10:49:48 +00:00
parent 983fb31801
commit 25a83e0b6f
14 changed files with 793 additions and 82 deletions
@@ -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<ConnectionUiState> =
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<String>): JSONObject {
var lastError: Throwable? = null
for (path in paths) {
val result = runCatching { request(path) }
result.onSuccess { return it }
val error = result.exceptionOrNull()
if (error is UnauthorizedConnectionException) throw error
lastError = error
}
throw lastError ?: IllegalStateException("Server is offline or unreachable.")
}
suspend fun health(): HealthState = withContext(Dispatchers.IO) {
val json = 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.")
}
}
@@ -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())
}
@@ -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"
}
}
@@ -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(
@@ -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)
@@ -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")
@@ -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(
@@ -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 = ""
}
}
@@ -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
@@ -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)