From e39249100ebb0dc18e476ff5a2c8fc851686d7c1 Mon Sep 17 00:00:00 2001 From: Colin Johnson Date: Mon, 22 Jun 2026 20:00:16 -0400 Subject: [PATCH] fix: route Android exec approvals to in-app inbox (#95593) * fix: route Android exec approvals to in-app inbox * fix: read nested Android exec approval commands --- .../ai/openclaw/app/GatewayExecApprovals.kt | 160 +++++++++++ .../java/ai/openclaw/app/MainViewModel.kt | 14 + .../main/java/ai/openclaw/app/NodeRuntime.kt | 264 ++++++++++++++++++ .../ai/openclaw/app/ui/SettingsScreens.kt | 137 ++++++++- .../java/ai/openclaw/app/ui/ShellScreen.kt | 12 +- .../app/GatewayExecApprovalParsingTest.kt | 101 +++++++ 6 files changed, 677 insertions(+), 11 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt b/apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt new file mode 100644 index 000000000000..7e5a10370507 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt @@ -0,0 +1,160 @@ +package ai.openclaw.app + +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +data class GatewayExecApprovalSummary( + val id: String, + val commandText: String, + val commandPreview: String?, + val allowedDecisions: List, + val host: String?, + val nodeId: String?, + val agentId: String?, + val createdAtMs: Long?, + val expiresAtMs: Long?, + val resolvingDecision: String? = null, + val errorText: String? = null, +) + +internal fun parseGatewayExecApprovalListPayload( + payloadJson: String, + json: Json, +): List = + try { + (json.parseToJsonElement(payloadJson) as? JsonArray) + ?.mapNotNull(::parseGatewayExecApprovalListEntry) + ?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE } + .orEmpty() + } catch (_: Throwable) { + emptyList() + } + +internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? { + val obj = item.asObjectOrNull() ?: return null + val id = obj["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return null + val request = obj["request"].asObjectOrNull() + val commandText = gatewayExecApprovalListCommandText(obj, request) + return GatewayExecApprovalSummary( + id = id, + commandText = commandText, + commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText), + allowedDecisions = emptyList(), + host = + request + ?.get("host") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() }, + nodeId = + request + ?.get("nodeId") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() }, + agentId = + request + ?.get("agentId") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() }, + createdAtMs = obj.long("createdAtMs"), + expiresAtMs = obj.long("expiresAtMs"), + ) +} + +internal fun parseGatewayExecApprovalDetail( + obj: JsonObject, + createdAtMs: Long?, +): GatewayExecApprovalSummary? { + val id = obj["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return null + return GatewayExecApprovalSummary( + id = id, + commandText = + obj["commandText"] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: "Command request", + commandPreview = + obj["commandPreview"] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() }, + allowedDecisions = gatewayExecApprovalAllowedDecisions(obj), + host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + createdAtMs = createdAtMs, + expiresAtMs = obj.long("expiresAtMs"), + ) +} + +private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String = + obj["commandText"] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: request + ?.get("command") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: "Command request" + +private fun gatewayExecApprovalListCommandPreview( + obj: JsonObject, + request: JsonObject?, + commandText: String, +): String? { + val preview = + obj["commandPreview"] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: request + ?.get("commandPreview") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + return preview?.takeIf { it != commandText } +} + +private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List { + val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray) + if (explicit.isNotEmpty()) return explicit + val allowed = + if (request + ?.get("ask") + .asStringOrNull() + ?.trim() + ?.lowercase() == "always" + ) { + listOf("allow-once", "deny") + } else { + listOf("allow-once", "allow-always", "deny") + } + val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet() + return allowed.filterNot { it == "allow-always" && it in unavailable } +} + +private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List = + items + ?.mapNotNull { item -> + when (item.asStringOrNull()?.trim()) { + "allow-once" -> "allow-once" + "allow-always" -> "allow-always" + "deny" -> "deny" + else -> null + } + }?.distinct() + .orEmpty() + +private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index f8e360c534aa..9b7c004c853c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -204,6 +204,9 @@ class MainViewModel( val chatPendingToolCalls: StateFlow> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls } val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } val pendingRunCount: StateFlow = runtimeState(initial = 0) { it.pendingRunCount } + val execApprovals: StateFlow> = runtimeState(initial = emptyList()) { it.execApprovals } + val execApprovalsRefreshing: StateFlow = runtimeState(initial = false) { it.execApprovalsRefreshing } + val execApprovalsErrorText: StateFlow = runtimeState(initial = null) { it.execApprovalsErrorText } val canvas: CanvasController get() = ensureRuntime().canvas @@ -537,6 +540,17 @@ class MainViewModel( ensureRuntime().refreshNodesDevices() } + fun refreshExecApprovals() { + ensureRuntime().refreshExecApprovals() + } + + fun resolveExecApproval( + id: String, + decision: String, + ) { + ensureRuntime().resolveExecApproval(id = id, decision = decision) + } + fun refreshChannels() { ensureRuntime().refreshChannels() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index a37810250bd7..7ab5422c4b9e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -74,7 +74,9 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import java.util.Collections import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong /** @@ -400,6 +402,15 @@ class NodeRuntime( private val _nodesDevicesErrorText = MutableStateFlow(null) val nodesDevicesErrorText: StateFlow = _nodesDevicesErrorText.asStateFlow() private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard() + private val _execApprovals = MutableStateFlow>(emptyList()) + val execApprovals: StateFlow> = _execApprovals.asStateFlow() + private val _execApprovalsRefreshing = MutableStateFlow(false) + val execApprovalsRefreshing: StateFlow = _execApprovalsRefreshing.asStateFlow() + private val _execApprovalsErrorText = MutableStateFlow(null) + val execApprovalsErrorText: StateFlow = _execApprovalsErrorText.asStateFlow() + private val execApprovalsRefreshSeq = AtomicLong(0) + private val execApprovalsStateLock = Any() + private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap()) private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList())) val channelsSummary: StateFlow = _channelsSummary.asStateFlow() private val _channelsRefreshing = MutableStateFlow(false) @@ -449,6 +460,7 @@ class NodeRuntime( micCapture.onGatewayConnectionChanged(true) scope.launch { subscribeOperatorSessionEvents() + refreshExecApprovalsFromGateway() refreshHomeCanvasOverviewIfConnected() if (voiceReplySpeakerLazy.isInitialized()) { voiceReplySpeaker.refreshConfig() @@ -478,6 +490,11 @@ class NodeRuntime( pendingDevices = emptyList(), pairedDevices = emptyList(), ) + invalidateExecApprovalRefreshes() + resolvedExecApprovalIds.clear() + _execApprovals.value = emptyList() + _execApprovalsRefreshing.value = false + _execApprovalsErrorText.value = null _channelsSummary.value = GatewayChannelsSummary(channels = emptyList()) _dreamingSummary.value = GatewayDreamingSummary() _healthLogsSummary.value = GatewayHealthLogsSummary() @@ -825,6 +842,24 @@ class NodeRuntime( } } + fun refreshExecApprovals() { + scope.launch { + refreshExecApprovalsFromGateway() + } + } + + fun resolveExecApproval( + id: String, + decision: String, + ) { + val normalizedId = id.trim() + val normalizedDecision = decision.trim() + if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return + scope.launch { + resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision) + } + } + fun refreshChannels() { scope.launch { refreshChannelsFromGateway() @@ -1000,6 +1035,9 @@ class NodeRuntime( _isForeground.value = value if (value) { reconnectPreferredGatewayOnForeground() + scope.launch { + refreshExecApprovalsFromGateway() + } } else { stopManualVoiceSession() publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true) @@ -1829,11 +1867,47 @@ class NodeRuntime( if (event == "update.available") { _gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson) } + handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson) micCapture.handleGatewayEvent(event, payloadJson) talkMode.handleGatewayEvent(event, payloadJson) chat.handleGatewayEvent(event, payloadJson) } + private fun handleExecApprovalGatewayEvent( + event: String, + payloadJson: String?, + ) { + when (event) { + "exec.approval.requested" -> { + val approvalId = parseExecApprovalEventId(payloadJson) + approvalId?.let(resolvedExecApprovalIds::remove) + scope.launch { + if (approvalId == null) { + refreshExecApprovalsFromGateway() + } else { + refreshExecApprovalFromGateway(approvalId) + } + } + } + "exec.approval.resolved" -> { + val approvalId = parseExecApprovalEventId(payloadJson) ?: return + markExecApprovalResolved(approvalId) + } + } + } + + private fun parseExecApprovalEventId(payloadJson: String?): String? = + try { + payloadJson + ?.let { json.parseToJsonElement(it).asObjectOrNull() } + ?.get("id") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + } catch (_: Throwable) { + null + } + private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? { return try { val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } @@ -2080,6 +2154,196 @@ class NodeRuntime( } } + private suspend fun refreshExecApprovalsFromGateway() { + val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet() + _execApprovalsRefreshing.value = true + _execApprovalsErrorText.value = null + if (!operatorConnected) { + if (execApprovalsRefreshSeq.get() == refreshGeneration) { + _execApprovals.value = emptyList() + _execApprovalsRefreshing.value = false + } + return + } + try { + val res = operatorSession.request("exec.approval.list", "{}") + val existing = _execApprovals.value.associateBy { it.id } + val rows = + parseGatewayExecApprovalListPayload(res, json) + .filterNot { it.id in resolvedExecApprovalIds } + .map { row -> + val hydrated = + try { + fetchExecApprovalDetailFromGateway( + id = row.id, + createdAtMs = row.createdAtMs ?: System.currentTimeMillis(), + ) + } catch (_: Throwable) { + null + } ?: row.copy(errorText = "Could not load approval details. Refresh and try again.") + val current = existing[row.id] + if (current == null) { + hydrated + } else { + hydrated.copy( + resolvingDecision = current.resolvingDecision, + errorText = current.errorText ?: hydrated.errorText, + ) + } + } + publishExecApprovalsIfCurrent(refreshGeneration, rows) + } catch (_: Throwable) { + if (execApprovalsRefreshSeq.get() == refreshGeneration) { + _execApprovalsErrorText.value = "Could not load approvals." + } + } finally { + if (execApprovalsRefreshSeq.get() == refreshGeneration) { + _execApprovalsRefreshing.value = false + } + } + } + + private suspend fun refreshExecApprovalFromGateway(id: String) { + if (!operatorConnected) return + if (id in resolvedExecApprovalIds) return + try { + val current = _execApprovals.value.firstOrNull { it.id == id } + val row = + fetchExecApprovalDetailFromGateway( + id = id, + createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(), + ) ?: return + if (id in resolvedExecApprovalIds) return + invalidateExecApprovalRefreshes() + upsertExecApproval(row) + } catch (_: Throwable) { + refreshExecApprovalsFromGateway() + } + } + + private suspend fun fetchExecApprovalDetailFromGateway( + id: String, + createdAtMs: Long, + ): GatewayExecApprovalSummary? { + val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString() + val res = operatorSession.request("exec.approval.get", params) + val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null + return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs) + } + + private suspend fun resolveExecApprovalOnGateway( + id: String, + decision: String, + ) { + synchronized(execApprovalsStateLock) { + if (!operatorConnected || id in resolvedExecApprovalIds) return + val currentRows = _execApprovals.value + if (currentRows.none { it.id == id }) return + invalidateExecApprovalRefreshes() + _execApprovals.value = + currentRows.map { row -> + if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row + } + } + try { + val params = + buildJsonObject { + put("id", JsonPrimitive(id)) + put("decision", JsonPrimitive(decision)) + }.toString() + operatorSession.request("exec.approval.resolve", params) + markExecApprovalResolved(id) + } catch (_: Throwable) { + synchronized(execApprovalsStateLock) { + if (!operatorConnected || id in resolvedExecApprovalIds) return + _execApprovals.value = + _execApprovals.value.map { row -> + if (row.id == id) { + row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.") + } else { + row + } + } + } + } + } + + private fun upsertExecApproval(row: GatewayExecApprovalSummary) { + synchronized(execApprovalsStateLock) { + if (!operatorConnected || row.id in resolvedExecApprovalIds) return + if (row.isExpiredExecApproval()) return + val rows = _execApprovals.value + val replaced = rows.any { it.id == row.id } + val nextRows = + ( + if (replaced) { + rows.map { current -> + if (current.id == row.id) { + row.copy( + resolvingDecision = current.resolvingDecision, + errorText = current.errorText, + ) + } else { + current + } + } + } else { + rows + row + } + ).filterActiveExecApprovals() + .sortedBy { it.createdAtMs ?: Long.MAX_VALUE } + _execApprovals.value = nextRows + scheduleExecApprovalExpiryPrune(nextRows) + } + } + + private fun invalidateExecApprovalRefreshes() { + execApprovalsRefreshSeq.incrementAndGet() + _execApprovalsRefreshing.value = false + } + + private fun markExecApprovalResolved(id: String) { + synchronized(execApprovalsStateLock) { + resolvedExecApprovalIds.add(id) + invalidateExecApprovalRefreshes() + _execApprovals.value = _execApprovals.value.filterNot { it.id == id } + } + } + + private fun publishExecApprovalsIfCurrent( + refreshGeneration: Long, + rows: List, + ) { + synchronized(execApprovalsStateLock) { + if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) { + val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals() + _execApprovals.value = nextRows + scheduleExecApprovalExpiryPrune(nextRows) + } + } + } + + private fun scheduleExecApprovalExpiryPrune(rows: List) { + val now = System.currentTimeMillis() + val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return + scope.launch { + delay((nextExpiry - now + 250).coerceAtLeast(0)) + pruneExpiredExecApprovals() + } + } + + private fun pruneExpiredExecApprovals() { + synchronized(execApprovalsStateLock) { + _execApprovals.value = _execApprovals.value.filterActiveExecApprovals() + } + } + + private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true + + private fun List.filterActiveExecApprovals( + nowMs: Long = System.currentTimeMillis(), + ): List = filterNot { it.isExpiredExecApproval(nowMs) } + private fun invalidateNodeCapabilityApprovalState() { val refreshGeneration = nodeApprovalRefreshGuard.begin() nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index 7973eb3c148c..1f97ffb5aed5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -4,6 +4,7 @@ import ai.openclaw.app.AppearanceThemeMode import ai.openclaw.app.BuildConfig import ai.openclaw.app.GatewayAgentSummary import ai.openclaw.app.GatewayCronJobSummary +import ai.openclaw.app.GatewayExecApprovalSummary import ai.openclaw.app.GatewayUsageProviderSummary import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel @@ -301,29 +302,62 @@ private fun ApprovalsSettingsScreen( viewModel: MainViewModel, onBack: () -> Unit, ) { + val isConnected by viewModel.isConnected.collectAsState() + val execApprovals by viewModel.execApprovals.collectAsState() + val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState() + val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val pendingRunCount by viewModel.pendingRunCount.collectAsState() - val waitingCount = pendingToolCalls.count { it.isError != true } - val issueCount = pendingToolCalls.count { it.isError == true } + val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true } + + LaunchedEffect(isConnected) { + if (isConnected) { + viewModel.refreshExecApprovals() + } + } SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) { SettingsMetricPanel( rows = listOf( - SettingsMetric("Pending", waitingCount.toString()), + SettingsMetric("Gateway Pending", execApprovals.size.toString()), + SettingsMetric("Session Activity", pendingToolCalls.size.toString()), SettingsMetric("Issues", issueCount.toString()), SettingsMetric("Active Runs", pendingRunCount.toString()), ), ) - if (pendingToolCalls.isEmpty()) { + ClawSecondaryButton( + text = if (execApprovalsRefreshing) "Refreshing" else "Refresh", + onClick = viewModel::refreshExecApprovals, + enabled = isConnected && !execApprovalsRefreshing, + modifier = Modifier.fillMaxWidth(), + ) + if (execApprovalsErrorText != null) { + ClawPanel { + Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning) + } + } + if (!isConnected) { ClawPanel { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(text = "Nothing needs approval.", style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text(text = "OpenClaw will show action requests here when a session pauses for review.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + Text(text = "Gateway disconnected.", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = "Connect the gateway to load approval requests in the app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + } + } else if (execApprovals.isEmpty()) { + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(text = "No gateway approvals.", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = "Exec approval requests will appear here while this phone is connected.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } } } else { - ApprovalsPanel(toolCalls = pendingToolCalls) + ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval) + } + if (pendingToolCalls.isNotEmpty()) { + Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = "Chat tool calls waiting in the active session remain visible here.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + SessionToolCallsPanel(toolCalls = pendingToolCalls) } } } @@ -1100,7 +1134,70 @@ internal data class SettingsMetric( ) @Composable -private fun ApprovalsPanel(toolCalls: List) { +private fun ExecApprovalsPanel( + approvals: List, + onResolve: (String, String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + approvals.forEach { approval -> + ExecApprovalCard(approval = approval, onResolve = onResolve) + } + } +} + +@Composable +private fun ExecApprovalCard( + approval: GatewayExecApprovalSummary, + onResolve: (String, String) -> Unit, +) { + val resolving = approval.resolvingDecision != null + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis) + approval.commandPreview?.let { preview -> + Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success) + } + Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis) + approval.errorText?.let { errorText -> + Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if ("allow-once" in approval.allowedDecisions) { + ClawPrimaryButton( + text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once", + onClick = { onResolve(approval.id, "allow-once") }, + enabled = !resolving, + modifier = Modifier.weight(1f), + ) + } + if ("allow-always" in approval.allowedDecisions) { + ClawSecondaryButton( + text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always", + onClick = { onResolve(approval.id, "allow-always") }, + enabled = !resolving, + modifier = Modifier.weight(1f), + ) + } + if ("deny" in approval.allowedDecisions) { + ClawSecondaryButton( + text = if (approval.resolvingDecision == "deny") "Denying" else "Deny", + onClick = { onResolve(approval.id, "deny") }, + enabled = !resolving, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Composable +private fun SessionToolCallsPanel(toolCalls: List) { ClawListPanel(items = toolCalls) { toolCall -> ApprovalListRow(toolCall = toolCall) } @@ -1233,6 +1330,30 @@ private fun approvalSubtitle( return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m" } +private fun execApprovalMetadata(approval: GatewayExecApprovalSummary): String { + val target = + when { + approval.host == "node" && approval.nodeId != null -> "Node ${approval.nodeId.take(8)}" + approval.host != null -> approval.host.replaceFirstChar { it.uppercaseChar() } + else -> "Gateway" + } + val agent = approval.agentId?.let { "Agent ${it.take(8)}" } + val age = approval.createdAtMs?.let { "Waiting ${formatApprovalDuration(System.currentTimeMillis() - it)}" } + val expires = approval.expiresAtMs?.let { "Expires ${formatApprovalDuration(it - System.currentTimeMillis())}" } + return listOfNotNull(target, agent, age, expires).joinToString(" · ") +} + +private fun formatApprovalDuration(deltaMs: Long): String { + val safeDelta = deltaMs.coerceAtLeast(0L) + val minutes = safeDelta / 60_000L + val hours = minutes / 60L + return when { + minutes < 1 -> "soon" + hours < 1 -> "${minutes}m" + else -> "${hours}h" + } +} + /** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */ private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}" diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index 9cac23cbfff2..d83dcf572434 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -360,6 +360,7 @@ private fun OverviewScreen( val statusText by viewModel.statusText.collectAsState() val models by viewModel.modelCatalog.collectAsState() val providers by viewModel.modelAuthProviders.collectAsState() + val execApprovals by viewModel.execApprovals.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val cronStatus by viewModel.cronStatus.collectAsState() val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState() @@ -367,10 +368,11 @@ private fun OverviewScreen( val agents by viewModel.gatewayAgents.collectAsState() val defaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState() val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready } + val pendingApprovalsCount = execApprovals.size + pendingToolCalls.size val attentionRows = homeAttentionRows( isConnected = isConnected, - pendingApprovals = pendingToolCalls.size, + pendingApprovals = pendingApprovalsCount, channelsSummary = channelsSummary, nodesDevicesSummary = nodesDevicesSummary, readyProviderCount = readyProviderCount, @@ -390,7 +392,7 @@ private fun OverviewScreen( isConnected = isConnected, hasAttention = attentionRows.isNotEmpty(), nodesDevicesSummary = nodesDevicesSummary, - pendingApprovals = pendingToolCalls.size, + pendingApprovals = pendingApprovalsCount, sessionCount = sessions.size, ) @@ -402,6 +404,7 @@ private fun OverviewScreen( viewModel.refreshCronJobs() viewModel.refreshNodesDevices() viewModel.refreshChannels() + viewModel.refreshExecApprovals() } } @@ -1364,6 +1367,7 @@ private fun SettingsShellScreen( val notificationForwardingEnabled by viewModel.notificationForwardingEnabled.collectAsState() val speakerEnabled by viewModel.speakerEnabled.collectAsState() val agents by viewModel.gatewayAgents.collectAsState() + val execApprovals by viewModel.execApprovals.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val cronStatus by viewModel.cronStatus.collectAsState() val usageSummary by viewModel.usageSummary.collectAsState() @@ -1373,6 +1377,7 @@ private fun SettingsShellScreen( val dreamingSummary by viewModel.dreamingSummary.collectAsState() val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState() val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready } + val pendingApprovalsCount = execApprovals.size + pendingToolCalls.size LaunchedEffect(isConnected) { if (isConnected) { @@ -1384,6 +1389,7 @@ private fun SettingsShellScreen( viewModel.refreshNodesDevices() viewModel.refreshChannels() viewModel.refreshDreaming() + viewModel.refreshExecApprovals() } } @@ -1430,7 +1436,7 @@ private fun SettingsShellScreen( status = if (isConnected) readyProviderCount > 0 else false, route = SettingsRoute.ProvidersModels, ), - SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals), + SettingsRow("Approvals", approvalsSummary(pendingApprovalsCount), Icons.Default.Lock, status = approvalsStatus(pendingApprovalsCount), route = SettingsRoute.Approvals), SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs), SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage), SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills), diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt new file mode 100644 index 000000000000..132714dea502 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt @@ -0,0 +1,101 @@ +package ai.openclaw.app + +import ai.openclaw.app.node.asObjectOrNull +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayExecApprovalParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parsesGatewayExecApprovalListPayload() { + val rows = + parseGatewayExecApprovalListPayload( + """ + [ + { + "id": "approval-2", + "createdAtMs": 20, + "expiresAtMs": 120, + "request": { + "host": "node", + "nodeId": "node-1", + "agentId": "agent-1", + "command": "Sanitized command", + "commandPreview": "Sanitized preview", + "systemRunPlan": { + "commandText": "/bin/sh -lc 'echo secret'", + "commandPreview": "echo secret" + }, + "allowedDecisions": ["allow-once", "deny"] + } + }, + { + "id": "approval-1", + "createdAtMs": 10, + "expiresAtMs": 110, + "request": { + "host": "gateway", + "command": "pnpm test --token secret", + "commandPreview": "pnpm test", + "unavailableDecisions": ["allow-always"] + } + } + ] + """.trimIndent(), + json, + ) + + assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id }) + assertEquals("pnpm test --token secret", rows[0].commandText) + assertEquals("pnpm test", rows[0].commandPreview) + assertEquals(emptyList(), rows[0].allowedDecisions) + assertEquals("Sanitized command", rows[1].commandText) + assertEquals("Sanitized preview", rows[1].commandPreview) + assertEquals("node-1", rows[1].nodeId) + assertEquals("agent-1", rows[1].agentId) + } + + @Test + fun parsesGatewayExecApprovalGetPayload() { + val root = + json + .parseToJsonElement( + """ + { + "id": "approval-1", + "commandText": "rm -rf build", + "commandPreview": "rm build", + "allowedDecisions": ["allow-once", "allow-always", "deny"], + "host": "gateway", + "nodeId": null, + "agentId": "agent-main", + "expiresAtMs": 200 + } + """.trimIndent(), + ).asObjectOrNull() + + requireNotNull(root) + val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100) + + requireNotNull(row) + assertEquals("approval-1", row.id) + assertEquals("rm -rf build", row.commandText) + assertEquals("rm build", row.commandPreview) + assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions) + assertEquals("gateway", row.host) + assertNull(row.nodeId) + assertEquals("agent-main", row.agentId) + assertEquals(100L, row.createdAtMs) + assertEquals(200L, row.expiresAtMs) + } + + @Test + fun ignoresMalformedGatewayExecApprovalListPayload() { + assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty()) + assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty()) + } +}