feat(vault): credential vault status and mid-turn auth prompt handling (#1090) (#1097)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 00:20:07 +03:00
1 parent d774a63663
commit 69cd34d289
23 files changed
+2224 -53

No files matched your search

@@ -0,0 +1,61 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models for the Credential Vault RPC surface (issue #1090).
*
* Backend: `tui_gateway/methods_vault.py`.
* Sources represent local encrypted vault or external managers (1Password, Bitwarden).
*/
@Serializable
data class VaultSource(
val name: String,
@SerialName("display_name") val displayName: String = name,
val enabled: Boolean = false,
@SerialName("needs_unlock") val needsUnlock: Boolean = false,
val unlocked: Boolean = false,
val installed: Boolean = false,
)
@Serializable
data class VaultSourcesResponse(
val sources: List<VaultSource> = emptyList(),
)
@Serializable
data class VaultItem(
val id: String,
val backend: String = "local",
val kind: String = "login",
val label: String = "",
val origin: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("identifier_type") val identifierType: String? = null,
val identifier: String? = null,
@SerialName("has_otp") val hasOtp: Boolean = false,
)
@Serializable
data class VaultListResponse(
val items: List<VaultItem> = emptyList(),
)
@Serializable
data class VaultSourceSetResponse(
val name: String? = null,
val enabled: Boolean? = null,
)
@Serializable
data class VaultUnlockResponse(
val name: String? = null,
val unlocked: Boolean? = null,
)
@Serializable
data class VaultLockResponse(
val locked: Boolean? = null,
)
@@ -341,6 +341,43 @@ object EventParser {
WsEvent.SecretExpire(requestId, sessionId)
}
// ── Credential Vault prompts (issue #1090) ──────────────────
"vault.unlock.request" -> {
val requestId = payload?.get("request_id") as? String
val backend = payload?.get("backend") as? String
val displayName = payload?.get("display_name") as? String
WsEvent.VaultUnlockRequest(requestId, sessionId, backend, displayName)
}
"vault.unlock.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.VaultUnlockExpire(requestId, sessionId)
}
"vault.save_login.request" -> {
val requestId = payload?.get("request_id") as? String
val origin = payload?.get("origin") as? String
val site = payload?.get("site") as? String
WsEvent.VaultSaveLoginRequest(requestId, sessionId, origin, site)
}
"vault.save_login.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.VaultSaveLoginExpire(requestId, sessionId)
}
"vault.code.request" -> {
val requestId = payload?.get("request_id") as? String
val site = payload?.get("site") as? String
val hint = payload?.get("hint") as? String
WsEvent.VaultCodeRequest(requestId, sessionId, site, hint)
}
"vault.code.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.VaultCodeExpire(requestId, sessionId)
}
else -> {
Log.w(TAG, "Unknown event type: $eventType")
WsEvent.Unknown(rawJson)
@@ -0,0 +1,91 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.VaultItem
import com.m57.hermescontrol.data.model.VaultListResponse
import com.m57.hermescontrol.data.model.VaultLockResponse
import com.m57.hermescontrol.data.model.VaultSource
import com.m57.hermescontrol.data.model.VaultSourceSetResponse
import com.m57.hermescontrol.data.model.VaultSourcesResponse
import com.m57.hermescontrol.data.model.VaultUnlockResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.serializer
/**
* Repository for Credential Vault RPC operations over WebSocket (issue #1090).
*/
object VaultRepository {
private val json get() = OkHttpProvider.json
@Suppress("UNCHECKED_CAST")
private inline fun <reified T> decode(result: Any?): T? {
if (result == null) return null
val element: JsonElement =
when (result) {
is JsonElement -> result
is Map<*, *> -> anyToJsonElement(result)
is List<*> -> JsonArray(result.map { anyToJsonElement(it) })
else -> JsonPrimitive(result.toString())
}
return json.decodeFromJsonElement(serializer<T>(), element)
}
@Suppress("UNCHECKED_CAST")
private fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is JsonElement -> value
is Map<*, *> -> JsonObject((value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) })
is List<*> -> JsonArray(value.map { anyToJsonElement(it) })
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
else -> JsonPrimitive(value.toString())
}
suspend fun getSources(): List<VaultSource> {
val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await()
return decode<VaultSourcesResponse>(result)?.sources ?: emptyList()
}
suspend fun setSourceEnabled(
name: String,
enabled: Boolean,
): Boolean {
val result =
HermesWsClient
.request(
WsMethods.VAULT_SOURCE_SET,
mapOf("name" to name, "enabled" to enabled),
).await()
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
}
suspend fun unlockSource(
name: String,
password: String,
): Boolean {
val result =
HermesWsClient
.request(
WsMethods.VAULT_UNLOCK,
mapOf("name" to name, "password" to password),
).await()
return decode<VaultUnlockResponse>(result)?.unlocked == true
}
suspend fun lockSource(name: String? = null): Boolean {
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
return decode<VaultLockResponse>(result)?.locked == true
}
suspend fun listItems(): List<VaultItem> {
val result = HermesWsClient.request(WsMethods.VAULT_LIST).await()
return decode<VaultListResponse>(result)?.items ?: emptyList()
}
}
@@ -303,6 +303,44 @@ sealed class WsEvent {
val sessionId: String?,
) : WsEvent()
// ── Credential Vault prompts (issue #1090) ──────────────────────────
data class VaultUnlockRequest(
val requestId: String?,
val sessionId: String? = null,
val backend: String? = null,
val displayName: String? = null,
) : WsEvent()
data class VaultUnlockExpire(
val requestId: String?,
val sessionId: String? = null,
) : WsEvent()
data class VaultSaveLoginRequest(
val requestId: String?,
val sessionId: String? = null,
val origin: String? = null,
val site: String? = null,
) : WsEvent()
data class VaultSaveLoginExpire(
val requestId: String?,
val sessionId: String? = null,
) : WsEvent()
data class VaultCodeRequest(
val requestId: String?,
val sessionId: String? = null,
val site: String? = null,
val hint: String? = null,
) : WsEvent()
data class VaultCodeExpire(
val requestId: String?,
val sessionId: String? = null,
) : WsEvent()
// ── Gateway-level errors ───────────────────────────────────────────
/**
@@ -43,6 +43,16 @@ object WsMethods {
const val SUDO_RESPOND = "sudo.respond"
const val SECRET_RESPOND = "secret.respond"
// ── Credential Vault (issue #1090) ────────────────────────────────────
const val VAULT_UNLOCK_RESPOND = "vault.unlock.respond"
const val VAULT_SAVE_LOGIN_RESPOND = "vault.save_login.respond"
const val VAULT_CODE_RESPOND = "vault.code.respond"
const val VAULT_LIST = "vault.list"
const val VAULT_SOURCES = "vault.sources"
const val VAULT_SOURCE_SET = "vault.source.set"
const val VAULT_UNLOCK = "vault.unlock"
const val VAULT_LOCK = "vault.lock"
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
/** Ultra-lightweight liveness check and round-trip latency measurement. */