fix(security,race,ux): close 6 high-priority bugs (B1–B6)
Wave 1 — CRITICAL (token + PII leak):
B1 (kanban t_afc1d26f): Auth token leaked to logcat via
HttpLoggingInterceptor.Level.BODY combined with the authInterceptor
that adds `Authorization: Bearer *** on every REST call. Enabled
buildConfig=true in app/build.gradle.kts and gate Level.BODY on
BuildConfig.DEBUG (Level.NONE in release). ApiClient.kt:44-56.
B2 (kanban t_8884db16): Chat prompts + AI replies leaked to logcat
via unguarded Log.d in HermesWsClient. Gated the 3 PII-critical
sites (line 104 outgoing JSON which contains user `text`, line ~123
connecting URL whose query string carries the auth token, line ~181
incoming WS payload with AI reply tokens and tool output) with
BuildConfig.DEBUG. Log.i/.w/.e left unguarded as intentional ops signals.
Wave 2 — HIGH (races + thread-pattern):
B3 (kanban t_33da8a97): switchSession fired SESSION_RESUME (WS) and
loadSessionMessages (REST) in parallel. The REST success path did an
unconditional `_uiState.update { it.copy(messages = chatMessages) }`
that stomped any in-flight streaming tokens appended via
WsEvent.MessageToken. ChatViewModel.loadSessionMessages now preserves
messages with isStreaming=true by re-appending them to the freshly
loaded history. Bonus side-fix discovered in same function: isLoading
was never set to false on success or error branches — now explicitly
cleared on all exit paths.
B4 (kanban t_2b834d90): HermesWsClient.scheduleReconnect used a raw
Thread{} with Thread.sleep + a non-atomic check-then-openSocket()
(TOCTOU). Two reconnect threads spawned by a rapid onFailure/onClosed
cycle could both pass the gate and both call openSocket(), the second
overwriting `webSocket` without closing the first — orphaned socket
listener leak. Flaky networks multiplied daemon thread count. Replaced
with a single cancellable coroutine Job keyed to wsScope
(Dispatchers.IO + SupervisorJob). disconnect() now cancels any pending
reconnect so a delayed openSocket() can't fire after explicit close.
Wave 3 — MEDIUM (UX gaps + persistence):
B5 (kanban t_2322818d): LogsScreen rendered a blank black Box when
state.logs was empty && !isLoading && errorMessage == null. Added an
explicit empty-state branch rendering "No logs available" in
onSurfaceVariant, centered. Mirrors sibling pattern in
PairingScreen.kt:200-219 and WebhooksScreen.kt:171-190.
B6 (kanban t_86e9be9b): Theme preference was in-memory only — lost on
every cold start. Verification confirmed three gaps: (1)
SettingsViewModel.loadSettings() didn't restore themePreference from
storage; (2) SettingsViewModel.save() didn't persist it; (3)
MainActivity called `HermesControlTheme {}` with no themePreference
arg so even if storage had worked, the app wouldn't apply it at
startup. Added getThemePreference/setThemePreference in AuthManager
(EncryptedSharedPreferences round-trip, validate stored enum name with
runCatching to handle forward-incompatible strings), wired
SettingsViewModel init + save, and passed the stored preference to
HermesControlTheme(themePreference = AuthManager.getThemePreference())
at startup.
Verification:
- ktlintCheck: BUILD SUCCESSFUL in 7s (ktlint app/src/**/*.kt — clean)
- ./gradlew test: NOT RUN on this host (no Android SDK at
$ANDROID_HOME); deferred to the CI workflow at
.github/workflows/android.yml. Same limitation applies to
assembleRelease. Symbol-resolution reasoning: app/build.gradle.kts:10
declares namespace=com.m57.hermescontrol so AGP auto-generates the
BuildConfig class at package com.m57.hermescontrol — matches the new
imports in ApiClient.kt and HermesWsClient.kt.
Kanban tracking (native Hermes kanban, board slug "hermes-mobile"):
B1 t_afc1d26f done - Auth token leak via Level.BODY
B2 t_8884db16 done - Chat PII via unguarded Log.d
B3 t_33da8a97 done - switchSession race stomps streaming tokens
B4 t_2b834d90 done - WS reconnect TOCTOU + unbounded Thread{}
B5 t_2322818d done - LogsScreen missing empty-state
B6 t_86e9be9b done - Theme preference not persisted
B7 t_74f3b383 ready - (deferred) NavigationController.goBack underflow
B8 t_39a149f0 ready - (deferred) KanbanBoardsResponse.current nullable
B9 t_4b1465cf ready - (deferred) E2eIntegrationTest base64 no-op via Mockk
B10 t_81ef4203 ready - (deferred) ChannelsScreen form state on scroll
B11 t_3325498b ready - (deferred) TEST_READY/TEST_INFRA stale (28 vs 123)
B12 t_674007f5 ready - (deferred) Hardcoded http:// (LAN assumption)
B13 t_179009d5 ready - (deferred) SystemViewModel silent doctor catch
Total: 8 files changed, +144 insertions, -26 deletions.
Co-Authored-By: Ronia <ronia@hermes>
This commit is contained in:
1 parent
bd439ba674
commit
a0b4ce8dc0
8 files changed
+143
-25
No files matched your search
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
|
||||
/**
|
||||
* Singleton that manages encrypted storage of the Hermes dashboard token
|
||||
@@ -18,6 +19,7 @@ object AuthManager {
|
||||
private const val KEY_HOST = "host"
|
||||
private const val KEY_PORT = "port"
|
||||
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
||||
private const val KEY_THEME_PREFERENCE = "theme_preference"
|
||||
|
||||
private const val DEFAULT_HOST = "127.0.0.1"
|
||||
private const val DEFAULT_PORT = 9119
|
||||
@@ -88,6 +90,25 @@ object AuthManager {
|
||||
requirePrefs().edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply()
|
||||
}
|
||||
|
||||
// ── Theme preference ──────────────────────────────────────────────────
|
||||
// B6 (Jun 18 2026, kanban t_86e9be9b): previously, SettingsScreen.kt let
|
||||
// the user pick SYSTEM/LIGHT/DARK in a SingleChoiceSegmentedButtonRow
|
||||
// (SettingsScreen.kt:213-238) and SettingsViewModel.onThemeChange
|
||||
// (SettingsViewModel.kt:62-64) updated in-memory state — but
|
||||
// SettingsViewModel.loadSettings() did NOT read the value back and
|
||||
// SettingsViewModel.save() did NOT persist it. As a result, every cold
|
||||
// start reset the theme to SYSTEM. Fix: round-trip via EncryptedSharedPreferences
|
||||
// (the same store already used by token / host / port / auto_reconnect).
|
||||
|
||||
fun getThemePreference(): ThemePreference =
|
||||
requirePrefs().getString(KEY_THEME_PREFERENCE, ThemePreference.SYSTEM.name)?.let { name ->
|
||||
runCatching { ThemePreference.valueOf(name) }.getOrNull()
|
||||
} ?: ThemePreference.SYSTEM
|
||||
|
||||
fun setThemePreference(theme: ThemePreference) {
|
||||
requirePrefs().edit().putString(KEY_THEME_PREFERENCE, theme.name).apply()
|
||||
}
|
||||
|
||||
/** Convenience: build the base URL from current host + port. */
|
||||
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -41,9 +42,18 @@ object ApiClient {
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun buildService(): HermesApiService {
|
||||
// B1 (Jun 18 2026, kanban t_afc1d26f): Level.BODY prints every request —
|
||||
// including the Authorization: Bearer header from authInterceptor below —
|
||||
// to logcat in plaintext. Gate on BuildConfig.DEBUG so release builds
|
||||
// are silent; debug builds behave identically to before.
|
||||
val logging =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
level =
|
||||
if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
}
|
||||
|
||||
val authInterceptor =
|
||||
|
||||
@@ -2,10 +2,17 @@ package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -44,6 +51,15 @@ object HermesWsClient {
|
||||
@Volatile
|
||||
private var currentBackoff = INITIAL_BACKOFF_MS
|
||||
|
||||
// B4 (Jun 18 2026, kanban t_2b834d90): dedicated IO coroutine scope for
|
||||
// the new single-Job reconnect scheduler below. SupervisorJob keeps one
|
||||
// child failure from cancelling the scope; the scope itself lives as
|
||||
// long as this singleton (object) exists.
|
||||
private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@Volatile
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
private val okHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
@@ -83,6 +99,11 @@ object HermesWsClient {
|
||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||
fun disconnect() {
|
||||
intentionalClose.set(true)
|
||||
// B4 (Jun 18 2026, kanban t_2b834d90): also cancel any pending
|
||||
// reconnect coroutine so a scheduled openSocket() can't fire after
|
||||
// the user explicitly disconnected.
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
connected.set(false)
|
||||
@@ -101,7 +122,10 @@ object HermesWsClient {
|
||||
val id = requestId.incrementAndGet().toString()
|
||||
val request = JsonRpcRequest(id = id, method = method, params = params)
|
||||
val json = gson.toJson(request)
|
||||
Log.d(TAG, "→ $json")
|
||||
// B2 (Jun 18 2026, kanban t_8884db16): outgoing JSON contains user
|
||||
// prompts (PromptSubmit params include the `text` field) — never
|
||||
// stream to logcat in release builds.
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
||||
webSocket?.send(json) ?: Log.w(TAG, "send() called while disconnected")
|
||||
return id
|
||||
}
|
||||
@@ -120,7 +144,9 @@ object HermesWsClient {
|
||||
|
||||
private fun openSocket() {
|
||||
val url = AuthManager.wsUrl()
|
||||
Log.d(TAG, "Connecting to $url")
|
||||
// B2 (Jun 18 2026, kanban t_8884db16): url contains the auth token
|
||||
// as a query param — never stream to logcat in release builds.
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $url")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
|
||||
@@ -129,7 +155,7 @@ object HermesWsClient {
|
||||
private fun scheduleReconnect() {
|
||||
if (intentionalClose.get()) return
|
||||
if (!AuthManager.isAutoReconnect()) {
|
||||
Log.d(TAG, "Auto-reconnect disabled")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
||||
return
|
||||
}
|
||||
val delay = currentBackoff
|
||||
@@ -137,23 +163,32 @@ object HermesWsClient {
|
||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||
.toLong()
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
Log.d(TAG, "Reconnecting in ${delay}ms …")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${delay}ms …")
|
||||
|
||||
// Simple thread-based scheduling; could be replaced with coroutine delay.
|
||||
Thread {
|
||||
try {
|
||||
Thread.sleep(delay)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
// B4 (Jun 18 2026, kanban t_2b834d90): replaced raw Thread{ sleep;
|
||||
// check; openSocket() } with a single cancellable coroutine Job.
|
||||
//
|
||||
// Old code had two failure modes:
|
||||
// 1. TOCTOU: line 149 `if (!intentionalClose && !connected)` was
|
||||
// not atomic with line 150 `openSocket()`. Two reconnect threads
|
||||
// spawned from a rapid onFailure/onClosed cycle could both pass
|
||||
// the gate and both call openSocket() — the second overwrote
|
||||
// `webSocket` without closing the first, leaking an orphaned
|
||||
// listener.
|
||||
// 2. Unbounded thread spawn: a flaky network multiplied the
|
||||
// daemon-thread count. Backoff grew but old threads weren't
|
||||
// cancelled.
|
||||
//
|
||||
// New behavior: cancel any in-flight reconnect before scheduling the
|
||||
// next one — only one reconnect coroutine exists at any time.
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob =
|
||||
wsScope.launch {
|
||||
delay(delay)
|
||||
if (!intentionalClose.get() && !connected.get()) {
|
||||
openSocket()
|
||||
}
|
||||
}
|
||||
if (!intentionalClose.get() && !connected.get()) {
|
||||
openSocket()
|
||||
}
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "hermes-ws-reconnect"
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(event: WsEvent) {
|
||||
@@ -178,7 +213,10 @@ object HermesWsClient {
|
||||
ws: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
Log.d(TAG, "← $text")
|
||||
// B2 (Jun 18 2026, kanban t_8884db16): incoming WS text contains
|
||||
// AI reply tokens and tool output — never stream to logcat in
|
||||
// release builds.
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
|
||||
try {
|
||||
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
|
||||
val event = EventParser.parse(rpc, text)
|
||||
|
||||
Reference in new issue
Block a user