@@ -14,6 +14,7 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
1414import ai.openclaw.app.gateway.GatewayTlsProbeResult
1515import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
1616import ai.openclaw.app.gateway.NodeEventSendOutcome
17+ import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId
1718import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
1819import ai.openclaw.app.gateway.parseChatSendAck
1920import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
@@ -82,6 +83,7 @@ import java.util.concurrent.ConcurrentHashMap
8283import java.util.concurrent.atomic.AtomicLong
8384
8485private const val MAX_PENDING_NOTIFICATION_EVENTS = 128
86+ private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L
8587
8688internal data class PendingNotificationNodeEvent (
8789 val event : String ,
@@ -488,8 +490,8 @@ class NodeRuntime(
488490 val isConnected: StateFlow <Boolean > = _isConnected .asStateFlow()
489491 private val _nodeConnected = MutableStateFlow (false )
490492 val nodeConnected: StateFlow <Boolean > = _nodeConnected .asStateFlow()
491- private val _nodeCapabilityApprovalState = MutableStateFlow ( GatewayNodeApprovalState .Loading )
492- val nodeCapabilityApprovalState : StateFlow <GatewayNodeApprovalState > = _nodeCapabilityApprovalState .asStateFlow()
493+ private val _nodeCapabilityApproval = MutableStateFlow < GatewayNodeCapabilityApproval >( GatewayNodeCapabilityApproval .Loading )
494+ val nodeCapabilityApproval : StateFlow <GatewayNodeCapabilityApproval > = _nodeCapabilityApproval .asStateFlow()
493495
494496 private val _gatewayConnectionDisplay = MutableStateFlow (GatewayConnectionDisplay (false , " Offline" , null ))
495497 val gatewayConnectionDisplay: StateFlow <GatewayConnectionDisplay > = _gatewayConnectionDisplay .asStateFlow()
@@ -1893,6 +1895,7 @@ class NodeRuntime(
18931895 ) {
18941896 // A user-selected connect target must never inherit notification content from another gateway.
18951897 notificationOutbox.clear()
1898+ invalidateNodeCapabilityApprovalState()
18961899 val connectAttemptId = connectAttemptSeq.incrementAndGet()
18971900 _pendingGatewayTrust .value = null
18981901 val tls = connectionManager.resolveTlsParams(endpoint)
@@ -2432,7 +2435,16 @@ class NodeRuntime(
24322435 nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
24332436 _nodesDevicesRefreshing .value = true
24342437 _nodesDevicesErrorText .value = null
2435- _nodeCapabilityApprovalState .value = GatewayNodeApprovalState .Loading
2438+ _nodesDevicesSummary .value = _nodesDevicesSummary .value.withoutExactApprovalRequestIds()
2439+ val pendingFallback = _nodeCapabilityApproval .value.withoutExactRequestId()
2440+ if (pendingFallback != null ) {
2441+ _nodeCapabilityApproval .value = pendingFallback
2442+ } else if (
2443+ _nodeCapabilityApproval .value !is GatewayNodeCapabilityApproval .PendingApproval &&
2444+ _nodeCapabilityApproval .value !is GatewayNodeCapabilityApproval .PendingReapproval
2445+ ) {
2446+ _nodeCapabilityApproval .value = GatewayNodeCapabilityApproval .Loading
2447+ }
24362448 }
24372449 if (! refreshStarted) return
24382450 if (! operatorConnected) {
@@ -2451,18 +2463,19 @@ class NodeRuntime(
24512463 val nodesRes = operatorSession.request(" node.list" , " {}" )
24522464 val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
24532465 val nodes = parseGatewayNodes(nodesRoot?.get(" nodes" ) as ? JsonArray )
2454- val approvalState =
2455- currentNodeCapabilityApprovalState (
2466+ val approval =
2467+ currentNodeCapabilityApproval (
24562468 nodes = nodes,
24572469 selfNodeId = identityStore.loadOrCreate().deviceId,
24582470 )
24592471 val publishedApproval =
24602472 nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2461- _nodeCapabilityApprovalState .value = approvalState
2473+ _nodeCapabilityApproval .value = approval
24622474 }
24632475 if (! publishedApproval) {
24642476 return
24652477 }
2478+ scheduleNodeApprovalCommandRefresh(refreshGeneration, approval)
24662479 val devicesRoot =
24672480 try {
24682481 val devicesRes = operatorSession.request(" device.pair.list" , " {}" )
@@ -2490,6 +2503,26 @@ class NodeRuntime(
24902503 }
24912504 }
24922505
2506+ private fun scheduleNodeApprovalCommandRefresh (
2507+ refreshGeneration : Long ,
2508+ approval : GatewayNodeCapabilityApproval ,
2509+ ) {
2510+ val fallback = approval.withoutExactRequestId() ? : return
2511+ scope.launch {
2512+ delay(NODE_APPROVAL_COMMAND_FRESH_MS )
2513+ // Pairing request IDs expire on the Gateway. Age out cached commands before rechecking so
2514+ // recovery never leaves an old exact ID visible when a refresh fails or races disconnect.
2515+ val shouldRefresh =
2516+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2517+ _nodeCapabilityApproval .value = fallback
2518+ _nodesDevicesSummary .value = _nodesDevicesSummary .value.withoutExactApprovalRequestIds()
2519+ }
2520+ if (shouldRefresh && operatorConnected) {
2521+ refreshNodesDevicesFromGateway()
2522+ }
2523+ }
2524+ }
2525+
24932526 private suspend fun refreshExecApprovalsFromGateway () {
24942527 val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
24952528 _execApprovalsRefreshing .value = true
@@ -2683,7 +2716,8 @@ class NodeRuntime(
26832716 private fun invalidateNodeCapabilityApprovalState () {
26842717 val refreshGeneration = nodeApprovalRefreshGuard.begin()
26852718 nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2686- _nodeCapabilityApprovalState .value = GatewayNodeApprovalState .Loading
2719+ _nodeCapabilityApproval .value = GatewayNodeCapabilityApproval .Loading
2720+ _nodesDevicesSummary .value = _nodesDevicesSummary .value.withoutExactApprovalRequestIds()
26872721 _nodesDevicesRefreshing .value = false
26882722 }
26892723 }
@@ -3476,6 +3510,36 @@ enum class GatewayNodeApprovalState {
34763510 Unapproved ,
34773511}
34783512
3513+ /* * Current phone approval state; only pending variants can carry an approval target. */
3514+ sealed interface GatewayNodeCapabilityApproval {
3515+ data object Loading : GatewayNodeCapabilityApproval
3516+
3517+ data object Unsupported : GatewayNodeCapabilityApproval
3518+
3519+ data object Approved : GatewayNodeCapabilityApproval
3520+
3521+ data class PendingApproval (
3522+ val requestId : String? ,
3523+ ) : GatewayNodeCapabilityApproval
3524+
3525+ data class PendingReapproval (
3526+ val requestId : String? ,
3527+ ) : GatewayNodeCapabilityApproval
3528+
3529+ data object Unapproved : GatewayNodeCapabilityApproval
3530+ }
3531+
3532+ internal fun GatewayNodeCapabilityApproval.withoutExactRequestId (): GatewayNodeCapabilityApproval ? =
3533+ when (this ) {
3534+ is GatewayNodeCapabilityApproval .PendingApproval ->
3535+ requestId?.let { GatewayNodeCapabilityApproval .PendingApproval (requestId = null ) }
3536+ is GatewayNodeCapabilityApproval .PendingReapproval ->
3537+ requestId?.let { GatewayNodeCapabilityApproval .PendingReapproval (requestId = null ) }
3538+ else -> null
3539+ }
3540+
3541+ internal fun GatewayNodesDevicesSummary.withoutExactApprovalRequestIds (): GatewayNodesDevicesSummary = copy(nodes = nodes.map { node -> node.copy(pendingRequestId = null ) })
3542+
34793543/* * Prevents older node.list responses from overwriting newer approval state. */
34803544internal class GatewayNodeApprovalRefreshGuard {
34813545 private val lock = Any ()
@@ -3508,14 +3572,26 @@ internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalSta
35083572 else -> GatewayNodeApprovalState .Loading
35093573 }
35103574
3511- internal fun currentNodeCapabilityApprovalState (
3575+ internal fun currentNodeCapabilityApproval (
35123576 nodes : List <GatewayNodeSummary >,
35133577 selfNodeId : String ,
3514- ): GatewayNodeApprovalState =
3515- nodes
3516- .firstOrNull { it.id == selfNodeId }
3517- ?.approvalState
3518- ? : GatewayNodeApprovalState .Loading
3578+ ): GatewayNodeCapabilityApproval {
3579+ val node = nodes.firstOrNull { it.id == selfNodeId } ? : return GatewayNodeCapabilityApproval .Loading
3580+ return when (node.approvalState) {
3581+ GatewayNodeApprovalState .Loading -> GatewayNodeCapabilityApproval .Loading
3582+ GatewayNodeApprovalState .Unsupported -> GatewayNodeCapabilityApproval .Unsupported
3583+ GatewayNodeApprovalState .Approved -> GatewayNodeCapabilityApproval .Approved
3584+ GatewayNodeApprovalState .PendingApproval ->
3585+ GatewayNodeCapabilityApproval .PendingApproval (
3586+ normalizeGatewayApprovalRequestId(node.pendingRequestId),
3587+ )
3588+ GatewayNodeApprovalState .PendingReapproval ->
3589+ GatewayNodeCapabilityApproval .PendingReapproval (
3590+ normalizeGatewayApprovalRequestId(node.pendingRequestId),
3591+ )
3592+ GatewayNodeApprovalState .Unapproved -> GatewayNodeCapabilityApproval .Unapproved
3593+ }
3594+ }
35193595
35203596internal fun parseGatewayNodeSummary (item : JsonElement ): GatewayNodeSummary ? {
35213597 val obj = item.asObjectOrNull() ? : return null
@@ -3536,7 +3612,7 @@ internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
35363612 } else {
35373613 GatewayNodeApprovalState .Unsupported
35383614 },
3539- pendingRequestId = obj[" pendingRequestId" ].asStringOrNull()?.trim()?. takeIf { it.isNotEmpty() } ,
3615+ pendingRequestId = normalizeGatewayApprovalRequestId( obj[" pendingRequestId" ].asStringOrNull()) ,
35403616 capabilities = parseGatewayStringArray(obj[" caps" ] as ? JsonArray ),
35413617 commands = parseGatewayStringArray(obj[" commands" ] as ? JsonArray ),
35423618 )
0 commit comments