fix(#545): fix gated ws auth loop and ticket refresh (#546)

Fix gated close codes, run ticket refresh on Dispatchers.IO, prevent scope race on startup, and auto-format files.
This commit is contained in:
M57 authored and GitHub committed 2026-07-11 00:21:23 +03:00
1 parent 53b3966931
commit 480d29e481
12 files changed
+118 -24

No files matched your search

@@ -45,7 +45,7 @@ object CookieManager {
synchronized(this) {
if (jar != null) return
val store = EncryptedCookieStore(context.applicationContext, legacyPrefsDeferred)
jar = PersistentCookieJar(store, scope)
jar = PersistentCookieJar(store, scope, initialServerId)
// Eagerly load the initial scope off the caller thread so the
// first REST call is instant without blocking app startup.
scope.launch { jar!!.useStore(initialServerId) }
@@ -33,12 +33,13 @@ import java.util.concurrent.atomic.AtomicReference
class PersistentCookieJar(
private val store: CookieStore,
private val storeScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
initialServerId: String = DEFAULT_SERVER_ID,
) : CookieJar {
// serverId -> (host -> cookies)
private val cache = ConcurrentHashMap<String, MutableMap<String, MutableList<Cookie>>>()
private val loadedScopes = ConcurrentHashMap.newKeySet<String>()
@Volatile private var currentServerId = AtomicReference(DEFAULT_SERVER_ID)
@Volatile private var currentServerId = AtomicReference(initialServerId)
private val scopeMutex = Mutex()
@@ -60,6 +61,9 @@ class PersistentCookieJar(
*/
fun getCookie(name: String): Cookie? {
val serverId = currentServerId.get()
if (!loadedScopes.contains(serverId)) {
kotlinx.coroutines.runBlocking { ensureLoaded(serverId) }
}
val hosts = cache[serverId] ?: return null
for (bucket in hosts.values) {
synchronized(bucket) {
@@ -189,7 +189,6 @@ object HermesWsClient {
intentionalClose.set(false)
currentBackoff = INITIAL_BACKOFF_MS
_connectionStatus.value = ConnectionStatus.CONNECTING
refreshWsTicketIfNeeded()
openSocket()
}
@@ -200,12 +199,24 @@ object HermesWsClient {
*
* The session cookie is attached automatically by the shared CookieJar on
* OkHttpProvider.probe (issue #470), so we no longer inject it manually.
*
* Returns true if the token is ready (either because we are not in gated mode,
* or because ticket refresh succeeded). Returns false if we are in gated mode
* and ticket refresh failed or cannot be performed.
*/
private fun refreshWsTicketIfNeeded() {
private fun refreshWsTicketIfNeeded(): Boolean {
val isGated = AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
if (!isGated) {
return true
}
val sessionCookie = AuthManager.getSessionCookie()
if (sessionCookie.isNullOrBlank()) {
return
Log.w(TAG, "WS ticket refresh aborted: no session cookie found in gated mode")
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
return false
}
try {
val client = OkHttpProvider.probe
val request =
@@ -214,20 +225,35 @@ object HermesWsClient {
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
.post("{}".toRequestBody())
.build()
val response = client.newCall(request).execute()
// Run network call on Dispatchers.IO to avoid NetworkOnMainThreadException
val response =
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
client.newCall(request).execute()
}
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\""").find(body)
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
val ticket = ticketMatch?.groupValues?.getOrNull(1)
if (!ticket.isNullOrBlank()) {
AuthManager.setToken(ticket)
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
return true
} else {
Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket")
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
return false
}
} else {
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
return false
}
} catch (e: Exception) {
Log.w(TAG, "WS ticket refresh failed: ${e.message}")
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
return false
}
}
@@ -375,7 +401,10 @@ object HermesWsClient {
// ── Internal ─────────────────────────────────────────────────────────
private fun openSocket() {
refreshWsTicketIfNeeded()
if (!refreshWsTicketIfNeeded()) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
return
}
val url = AuthManager.wsUrl()
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
@@ -479,7 +508,10 @@ object HermesWsClient {
Log.i(TAG, "WebSocket closed: $code $reason")
connected.set(false)
stopHealthTracking()
if (code == 4001 || reason.contains("unauthorized", ignoreCase = true)) {
if (code == 4001 || code == 4401 ||
reason.contains("unauthorized", ignoreCase = true) ||
reason.startsWith("auth:", ignoreCase = true)
) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else {
_connectionStatus.value = ConnectionStatus.RECONNECTING
@@ -301,6 +301,7 @@ class ChannelsViewModel :
onboardingExpiresIn = formatExpiry(setup?.expiresAt),
)
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
it.copy(
onboardingPhase = OnboardingPhase.IDLE,
@@ -350,6 +351,7 @@ class ChannelsViewModel :
}
_uiState.update { it.copy(onboardingError = null) }
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
val expired = isOnboardingExpired(_uiState.value.onboardingSetup)
if (expired) {
@@ -470,6 +472,7 @@ class ChannelsViewModel :
loadPlatforms()
}
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -520,7 +523,9 @@ private fun formatExpiry(expiresAt: String?): String {
val ms =
try {
isoTimestampParser
.parse(expiresAt)?.time?.minus(System.currentTimeMillis())
.parse(expiresAt)
?.time
?.minus(System.currentTimeMillis())
} catch (_: Exception) {
null
}
@@ -537,7 +542,8 @@ private fun isOnboardingExpired(setup: com.m57.hermescontrol.data.model.Telegram
val ms =
try {
isoTimestampParser
.parse(expiresAt)?.time
.parse(expiresAt)
?.time
} catch (_: Exception) {
null
}
@@ -186,6 +186,7 @@ internal fun TelegramOnboardingDialog(
Text("Save and restart")
}
}
else -> {}
}
},
@@ -194,9 +195,11 @@ internal fun TelegramOnboardingDialog(
OnboardingPhase.READY -> {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
OnboardingPhase.WAITING -> {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
}
},
@@ -54,7 +54,9 @@ object ChatWsEventReducer {
is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event)
is WsEvent.ReasoningDelta -> onReasoningDelta(state, streamingState, event)
is WsEvent.ReasoningAvailable -> onReasoningAvailable(state, streamingState, event)
is WsEvent.MessageComplete -> onMessageComplete(state, streamingState, event)
@@ -86,6 +88,7 @@ object ChatWsEventReducer {
// SudoRequest / SecretRequest are handled by the ViewModel (issue #524)
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
}
@@ -55,7 +55,10 @@ class KeysViewModel :
private fun buildCategoryList(
envVars: Map<String, EnvVarConfig>,
expandedCategories: Set<String> = _uiState.value.categories.map { it.name }.toSet(),
expandedCategories: Set<String> =
_uiState.value.categories
.map { it.name }
.toSet(),
): List<CategorySection> {
// Group by category, uncategorized go to "Other"
val grouped = mutableMapOf<String, MutableMap<String, EnvVarConfig>>()
@@ -119,6 +119,7 @@ fun McpServersScreen(
state.isLoading && state.servers.isEmpty() -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
@@ -126,6 +127,7 @@ fun McpServersScreen(
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -557,12 +559,14 @@ private fun CatalogSection(
}
Spacer(modifier = Modifier.height(spacing.sm))
}
state.catalogError != null -> {
ErrorState(
message = state.catalogError ?: "",
onRetry = { viewModel.loadCatalog() },
)
}
filteredCatalog.isEmpty() -> {
Text(
text = stringResource(R.string.mcp_servers_catalog_empty),
@@ -571,6 +575,7 @@ private fun CatalogSection(
modifier = Modifier.padding(vertical = spacing.md),
)
}
else -> {
filteredCatalog.forEach { entry ->
CatalogEntryCard(
@@ -112,6 +112,7 @@ class McpServersViewModel :
)
}
}
is NetworkResult.Failure -> {
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
}
@@ -130,6 +131,7 @@ class McpServersViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") }
}
@@ -148,6 +150,7 @@ class McpServersViewModel :
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
loadServers()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") }
}
@@ -167,6 +170,7 @@ class McpServersViewModel :
_uiState.update { it.copy(toastMessage = "Server '$name' restarted") }
loadServers()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to restart server: ${result.error.message}") }
}
@@ -220,7 +224,10 @@ class McpServersViewModel :
},
args =
if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) {
state.addServerArgs.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
state.addServerArgs
.trim()
.split("\\s+".toRegex())
.filter { it.isNotEmpty() }
} else {
null
},
@@ -244,6 +251,7 @@ class McpServersViewModel :
}
loadServers()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -300,6 +308,7 @@ class McpServersViewModel :
}
loadServers()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") }
}
@@ -324,6 +333,7 @@ class McpServersViewModel :
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
loadServers()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") }
}
@@ -349,6 +359,7 @@ class McpServersViewModel :
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -389,6 +400,7 @@ class McpServersViewModel :
}
loadServers()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -29,7 +29,9 @@ enum class SkillsViewMode {
HUB,
}
enum class SkillFilter(val labelRes: Int) {
enum class SkillFilter(
val labelRes: Int,
) {
ALL_STATUSES(R.string.skills_status_all),
ENABLED(R.string.skills_status_enabled),
DISABLED(R.string.skills_status_disabled),
@@ -74,8 +76,9 @@ data class SkillsUiState(
val sourceFilter: String? = null,
)
class SkillsViewModel(application: Application) :
AndroidViewModel(application),
class SkillsViewModel(
application: Application,
) : AndroidViewModel(application),
ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
@@ -189,6 +192,7 @@ class SkillsViewModel(application: Application) :
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -238,6 +242,7 @@ class SkillsViewModel(application: Application) :
// Refresh installed skills
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -274,6 +279,7 @@ class SkillsViewModel(application: Application) :
}
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -311,6 +317,7 @@ class SkillsViewModel(application: Application) :
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -469,6 +476,7 @@ class SkillsViewModel(application: Application) :
_uiState.update { it.copy(toastMessage = "Skills updated") }
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") }
}