feat: pivot mobile client to direct gateway
This commit is contained in:
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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(
|
||||
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(
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+15
-5
@@ -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")
|
||||
|
||||
+5
-1
@@ -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(
|
||||
|
||||
+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 {
|
||||
@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
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user