fix(#1282): require capability consent for plugin updates (#1295)

This commit is contained in:
m57 authored and GitHub committed 2026-09-26 21:03:53 +03:00
1 parent 89858fc35d
commit ee487e0087
6 files changed
+247 -16

No files matched your search

@@ -81,6 +81,26 @@ data class PluginsHubResponse(
val providers: PluginsHubProviders? = null,
)
@Serializable
data class PluginUpdateRequest(
@SerialName("accept_capabilities") val acceptCapabilities: Boolean,
)
@Serializable
data class PluginUpdateResult(
val ok: Boolean = false,
val unchanged: Boolean = false,
@SerialName("consent_required") val consentRequired: Boolean = false,
val delta: Map<String, List<String>>? = null,
@SerialName("delta_lines") val deltaLines: List<String>? = null,
val error: String? = null,
) {
val capabilityDelta: List<String>
get() =
deltaLines?.takeIf { it.isNotEmpty() }
?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" }
}
@Serializable
data class AgentPluginInstallBody(
val identifier: String = "",
@@ -670,7 +670,8 @@ interface HermesApiService : KanbanApiService {
@POST("api/dashboard/agent-plugins/{name}/update")
suspend fun updatePlugin(
@Path("name", encoded = true) name: String,
): Response<Unit>
@Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest,
): Response<com.m57.hermescontrol.data.model.PluginUpdateResult>
@POST("api/dashboard/agent-plugins/{name}/enable")
suspend fun enablePlugin(
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.InstallDesktop
@@ -120,6 +122,37 @@ fun PluginsScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.updateConsent?.let { consent ->
AlertDialog(
onDismissRequest = viewModel::cancelPluginUpdate,
title = { Text(stringResource(R.string.plugins_update_consent_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.plugins_update_consent_text, consent.name))
val lines = consent.result.capabilityDelta
if (lines.isEmpty()) {
Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing))
} else {
lines.forEach { Text(it) }
}
}
},
confirmButton = {
TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) {
Text(stringResource(R.string.plugins_update_accept))
}
},
dismissButton = {
TextButton(onClick = viewModel::cancelPluginUpdate) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
// Remove confirmation dialog
if (state.removeConfirmPlugin != null) {
AlertDialog(
@@ -9,6 +9,8 @@ import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginUpdateRequest
import com.m57.hermescontrol.data.model.PluginUpdateResult
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.ProviderOption
import com.m57.hermescontrol.data.remote.ApiClient
@@ -33,6 +35,12 @@ enum class PluginsTab {
CATALOG,
}
data class PluginUpdateConsent(
val name: String,
val result: PluginUpdateResult,
val scope: DataScope?,
)
data class PluginsUiState(
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
val isLoading: Boolean = false,
@@ -55,6 +63,7 @@ data class PluginsUiState(
val rowBusy: String? = null,
// Confirm remove dialog
val removeConfirmPlugin: String? = null,
val updateConsent: PluginUpdateConsent? = null,
// Rescan
val rescanBusy: Boolean = false,
// Catalog state
@@ -99,6 +108,7 @@ class PluginsViewModel(
providerBusy = false,
rowBusy = null,
removeConfirmPlugin = null,
updateConsent = null,
rescanBusy = false,
)
}
@@ -450,23 +460,83 @@ class PluginsViewModel(
}
fun updatePlugin(name: String) {
viewModelScope.launch {
setRowBusy(name)
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
clearRowBusy(name)
loadPlugins(forceRefresh = true)
}
if (_uiState.value.updateConsent != null) return
performPluginUpdate(name, acceptCapabilities = false)
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
clearRowBusy(name)
fun cancelPluginUpdate() {
_uiState.update { it.copy(updateConsent = null) }
}
fun confirmPluginUpdate() {
val consent = _uiState.value.updateConsent ?: return
if (_uiState.value.rowBusy != null) return
cancelPluginUpdate()
if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return
performPluginUpdate(consent.name, acceptCapabilities = true)
}
private fun performPluginUpdate(
name: String,
acceptCapabilities: Boolean,
) {
if (_uiState.value.rowBusy != null) return
val generation = scopeGeneration
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val api = ApiClient.hermesApi
setRowBusy(name)
_uiState.update { it.copy(toastMessage = null) }
viewModelScope.launch {
try {
val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) }
if (generation != scopeGeneration ||
requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull()
) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
val update = result.data
// #1282: HTTP success is not update success; consent must be explicit.
when {
update.consentRequired -> {
_uiState.update {
it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope))
}
}
!update.ok -> {
_uiState.update {
it.copy(
toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}",
)
}
}
else -> {
_uiState.update {
it.copy(
toastMessage =
if (update.unchanged) {
"Plugin is already up to date"
} else {
"Plugin updated successfully"
},
)
}
loadPlugins(forceRefresh = true)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to update plugin: ${result.error.message}")
}
}
}
} finally {
if (generation == scopeGeneration) clearRowBusy(name)
}
}
}