diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index ba5ab2267a08..4f99ef709567 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -1755,7 +1755,7 @@ }, { "kind": "ui-call", - "line": 3152, + "line": 3173, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not stage an attachment for sending.", "surface": "android", @@ -1763,7 +1763,7 @@ }, { "kind": "ui-call", - "line": 3185, + "line": 3206, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.", "surface": "android", @@ -1771,7 +1771,7 @@ }, { "kind": "ui-call", - "line": 3191, + "line": 3212, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Attachments are too large to queue for one message; remove some and try again.", "surface": "android", @@ -1779,7 +1779,7 @@ }, { "kind": "ui-call", - "line": 3197, + "line": 3218, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline attachment storage is full; delete queued items first.", "surface": "android", @@ -1787,7 +1787,7 @@ }, { "kind": "ui-call", - "line": 3202, + "line": 3223, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Gateway health not OK; cannot send", "surface": "android", @@ -1795,7 +1795,7 @@ }, { "kind": "ui-call", - "line": 3209, + "line": 3230, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not queue message for later delivery.", "surface": "android", @@ -1803,7 +1803,7 @@ }, { "kind": "ui-call", - "line": 3926, + "line": 3947, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed", "surface": "android", @@ -1811,7 +1811,7 @@ }, { "kind": "ui-call", - "line": 4064, + "line": 4085, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Event stream interrupted; try refreshing.", "surface": "android", @@ -1819,7 +1819,7 @@ }, { "kind": "ui-call", - "line": 4201, + "line": 4222, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out waiting for a reply; try again or refresh.", "surface": "android", @@ -1827,7 +1827,7 @@ }, { "kind": "ui-call", - "line": 4411, + "line": 4432, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out confirming the sent message; refresh to check delivery.", "surface": "android", @@ -1843,7 +1843,7 @@ }, { "kind": "conditional-branch", - "line": 1530, + "line": 1563, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -1851,7 +1851,7 @@ }, { "kind": "conditional-branch", - "line": 1530, + "line": 1563, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", @@ -23443,7 +23443,7 @@ }, { "kind": "ui-localized-call", - "line": 4088, + "line": 4087, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -23451,7 +23451,7 @@ }, { "kind": "ui-localized-call", - "line": 4089, + "line": 4088, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -23459,7 +23459,7 @@ }, { "kind": "ui-localized-call", - "line": 4804, + "line": 4803, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -23467,7 +23467,7 @@ }, { "kind": "ui-localized-call", - "line": 4805, + "line": 4804, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -23475,7 +23475,7 @@ }, { "kind": "conditional-branch", - "line": 5157, + "line": 5156, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -23483,7 +23483,7 @@ }, { "kind": "conditional-branch", - "line": 5157, + "line": 5156, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", @@ -23491,7 +23491,7 @@ }, { "kind": "conditional-branch", - "line": 6327, + "line": 6326, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "No chat messages yet", "surface": "apple", @@ -23499,7 +23499,7 @@ }, { "kind": "conditional-branch", - "line": 6526, + "line": 6525, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -23507,7 +23507,7 @@ }, { "kind": "conditional-branch", - "line": 6526, + "line": 6525, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -23515,7 +23515,7 @@ }, { "kind": "conditional-branch", - "line": 8935, + "line": 8934, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval", "surface": "apple", @@ -23523,7 +23523,7 @@ }, { "kind": "conditional-branch", - "line": 8935, + "line": 8934, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "This approval was already", "surface": "apple", @@ -23531,7 +23531,7 @@ }, { "kind": "conditional-branch", - "line": 8941, + "line": 8940, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "This approval was already set to Always Allow.", "surface": "apple", @@ -23539,7 +23539,7 @@ }, { "kind": "conditional-branch", - "line": 8942, + "line": 8941, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval set to Always Allow.", "surface": "apple", @@ -23547,7 +23547,7 @@ }, { "kind": "conditional-branch", - "line": 10280, + "line": 10278, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "\\(urlText.prefix(500))…", "surface": "apple", @@ -25683,7 +25683,7 @@ }, { "kind": "ui-localized-call", - "line": 4700, + "line": 4693, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Realtime unavailable", "surface": "apple", @@ -25691,7 +25691,7 @@ }, { "kind": "ui-localized-call", - "line": 4722, + "line": 4715, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Listening (PTT)", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index a5f0f3a460dc..4e8c0fa519c4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -2230,7 +2230,28 @@ class ChatController internal constructor( stateRevision: Long, gatewayScope: ChatCacheScope?, ): Boolean { - val response = requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}") + val response = + try { + requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}") + } catch (err: GatewayRequestRejected) { + val unavailable = + err.gatewayError.missingScope() == "operator.questions" || + ( + err.gatewayError.code == "INVALID_REQUEST" && + err.gatewayError.message == "unknown method: question.list" + ) + if (!unavailable) throw err + if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false + return synchronized(questionStateLock) { + if (!questionRefreshIsCurrentLocked(refreshGeneration, stateRevision)) return@synchronized false + if (_questions.value.isNotEmpty()) { + _questions.value = emptyList() + questionStateRevision += 1 + } + syncQuestionEvictionsLocked() + true + } + } if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false val listedRecords = json.decodeFromString(response).questions val listedIds = listedRecords.mapTo(mutableSetOf()) { it.id } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 50331bf9af8c..439398d5b1db 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -75,9 +75,9 @@ private enum class GatewayConnectAuthSource { } /** - * Structured connect failure guidance from the gateway, preserved for reconnect and UI decisions. + * Structured gateway error details, preserved for reconnect, authorization, and UI decisions. */ -data class GatewayConnectErrorDetails( +data class GatewayErrorDetails( val code: String?, val canRetryWithDeviceToken: Boolean, val recommendedNextStep: String?, @@ -92,8 +92,17 @@ data class GatewayConnectErrorDetails( val clawhubTrustCode: String? = null, val clawhubWarning: String? = null, val clawhubVersion: String? = null, + val missingScope: String? = null, + val requiredScopes: List = emptyList(), ) +data class GatewayMissingScopeErrorDetails( + val missingScope: String, + val requiredScopes: List, +) + +private val legacyMissingScopePattern = Regex("\\bmissing scope:\\s*([a-z0-9._-]+)", RegexOption.IGNORE_CASE) + private val gatewayApprovalRequestIdPattern = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") /** Keeps copied approval commands single-argument and safe for a gateway host shell. */ @@ -214,8 +223,26 @@ class GatewaySession( data class ErrorShape( val code: String, val message: String, - val details: GatewayConnectErrorDetails? = null, - ) + val details: GatewayErrorDetails? = null, + ) { + fun missingScopeDetails(): GatewayMissingScopeErrorDetails? { + val details = details ?: return null + val missingScope = details.missingScope?.trim().orEmpty() + val requiredScopes = details.requiredScopes.map { it.trim() } + if (details.code != "MISSING_SCOPE" || missingScope.isEmpty() || requiredScopes.any { it.isEmpty() }) { + return null + } + return requiredScopes.takeIf { it.isNotEmpty() }?.let { + GatewayMissingScopeErrorDetails(missingScope = missingScope, requiredScopes = it) + } + } + + fun missingScope(): String? { + missingScopeDetails()?.let { return it.missingScope } + if (code != "FORBIDDEN" && code != "INVALID_REQUEST") return null + return legacyMissingScopePattern.find(message)?.groupValues?.getOrNull(1) + } + } /** * Structured RPC result used by callers that need error codes without exceptions. @@ -1346,7 +1373,7 @@ class GatewaySession( val detailObj = wireError.details.asObjectOrNull() val details = detailObj?.let { - GatewayConnectErrorDetails( + GatewayErrorDetails( code = it["code"].asStringOrNull(), canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true, recommendedNextStep = it["recommendedNextStep"].asStringOrNull(), @@ -1361,6 +1388,12 @@ class GatewaySession( clawhubTrustCode = it["clawhubTrustCode"].asStringOrNull(), clawhubWarning = it["warning"].asStringOrNull(), clawhubVersion = it["version"].asStringOrNull(), + missingScope = it["missingScope"].asStringOrNull(), + requiredScopes = + it["requiredScopes"] + .asArrayOrNull() + ?.mapNotNull { scope -> scope.asStringOrNull() } + .orEmpty(), ) } ErrorShape(wireError.code, wireError.message, details) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt index fe80d403b0cf..2089a9549003 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt @@ -1,6 +1,6 @@ package ai.openclaw.app -import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewayErrorDetails import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.ui.cronWakeModeLabel import ai.openclaw.app.ui.cronWakeModeOptions @@ -264,7 +264,7 @@ class CronJobManagementTest { code = "INVALID_REQUEST", message = "changed", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "CRON_JOB_CHANGED", canRetryWithDeviceToken = false, recommendedNextStep = null, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 3bcde1e49615..3adb9591e2d3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -2,9 +2,9 @@ package ai.openclaw.app import ai.openclaw.app.gateway.DeviceAuthStore import ai.openclaw.app.gateway.DeviceIdentityStore -import ai.openclaw.app.gateway.GatewayConnectErrorDetails import ai.openclaw.app.gateway.GatewayConnectOptions import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayErrorDetails import ai.openclaw.app.gateway.GatewayRegistryEntry import ai.openclaw.app.gateway.GatewayRegistryEntryKind import ai.openclaw.app.gateway.GatewaySession @@ -91,7 +91,7 @@ class GatewayBootstrapAuthTest { code = "UNAUTHORIZED", message = "unauthorized", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "AUTH_TOKEN_MISSING", canRetryWithDeviceToken = false, recommendedNextStep = "provide_token", @@ -129,7 +129,7 @@ class GatewayBootstrapAuthTest { code = "NOT_PAIRED", message = "pairing required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "PAIRING_REQUIRED", canRetryWithDeviceToken = false, recommendedNextStep = "wait_then_retry", diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt index b7c99c5a0893..b1645f048c7a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt @@ -1,7 +1,7 @@ package ai.openclaw.app -import ai.openclaw.app.gateway.GatewayConnectErrorDetails import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayErrorDetails import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewaySession @@ -1309,8 +1309,8 @@ class GatewayExecApprovalRuntimeTest { } """.trimIndent() - private fun gatewayErrorDetails(reason: String): GatewayConnectErrorDetails = - GatewayConnectErrorDetails( + private fun gatewayErrorDetails(reason: String): GatewayErrorDetails = + GatewayErrorDetails( code = null, canRetryWithDeviceToken = false, recommendedNextStep = null, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt index 61d7d8b1b24e..8efe090cb126 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt @@ -1,6 +1,6 @@ package ai.openclaw.app -import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewayErrorDetails import ai.openclaw.app.gateway.GatewaySession import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject @@ -29,7 +29,7 @@ class GatewayNodeApprovalStateTest { code = "NOT_PAIRED", message = "pairing required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "PAIRING_REQUIRED", canRetryWithDeviceToken = false, recommendedNextStep = "wait_then_retry", @@ -45,7 +45,7 @@ class GatewayNodeApprovalStateTest { code = "UNAUTHORIZED", message = "token mismatch", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken = false, recommendedNextStep = null, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt index 223d23311dde..443d4b67d5f0 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt @@ -1,6 +1,6 @@ package ai.openclaw.app -import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewayErrorDetails import ai.openclaw.app.gateway.GatewaySession import kotlinx.serialization.json.Json import kotlinx.serialization.json.boolean @@ -102,7 +102,7 @@ class SkillManagementTest { code = "UNAVAILABLE", message = "review required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = null, canRetryWithDeviceToken = false, recommendedNextStep = null, @@ -127,7 +127,7 @@ class SkillManagementTest { code = "UNAVAILABLE", message = "review required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = null, canRetryWithDeviceToken = false, recommendedNextStep = null, @@ -152,7 +152,7 @@ class SkillManagementTest { code = "UNAVAILABLE", message = "download blocked", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = null, canRetryWithDeviceToken = false, recommendedNextStep = null, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt index a0b28bf77899..f2e200b57018 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt @@ -1,6 +1,6 @@ package ai.openclaw.app.chat -import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewayErrorDetails import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.Question @@ -153,6 +153,43 @@ class ChatQuestionTest { assertEquals(listOf("ask_new"), controller.questions.value.map { it.record.id }) } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun structuredMissingQuestionScopeClearsStaleCards() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "question.list") { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "FORBIDDEN", + message = "permission denied", + details = + GatewayErrorDetails( + code = "MISSING_SCOPE", + missingScope = "operator.questions", + requiredScopes = listOf("operator.questions"), + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ), + ) + } + "{}" + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(record(id = "ask_stale"))) + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(controller.questions.value.isEmpty()) + } + @Test @OptIn(ExperimentalCoroutinesApi::class) fun pendingRefreshPreservesSubmissionLock() = @@ -775,7 +812,7 @@ class ChatQuestionTest { code = "INVALID_REQUEST", message = "question not found", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = null, reason = "QUESTION_NOT_FOUND", canRetryWithDeviceToken = false, @@ -963,7 +1000,7 @@ class ChatQuestionTest { code = "INVALID_REQUEST", message = "question not found", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = null, reason = "QUESTION_NOT_FOUND", canRetryWithDeviceToken = false, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayErrorDetailsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayErrorDetailsTest.kt new file mode 100644 index 000000000000..683a3557022e --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayErrorDetailsTest.kt @@ -0,0 +1,52 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GatewayErrorDetailsTest { + @Test + fun readsStructuredMissingScopeWithoutMessageParsing() { + val error = + GatewaySession.ErrorShape( + code = "FORBIDDEN", + message = "permission denied", + details = + GatewayErrorDetails( + code = "MISSING_SCOPE", + missingScope = "operator.questions", + requiredScopes = listOf("operator.read", "operator.questions"), + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ) + + assertEquals("operator.questions", error.missingScope()) + assertEquals( + GatewayMissingScopeErrorDetails( + missingScope = "operator.questions", + requiredScopes = listOf("operator.read", "operator.questions"), + ), + error.missingScopeDetails(), + ) + } + + @Test + fun legacyFallbackRequiresAnAuthorizationErrorCode() { + assertEquals( + "operator.read", + GatewaySession + .ErrorShape( + code = "INVALID_REQUEST", + message = "missing scope: operator.read", + ).missingScope(), + ) + assertNull( + GatewaySession + .ErrorShape( + code = "UNAVAILABLE", + message = "missing scope: operator.read", + ).missingScope(), + ) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt index ac550b812038..be42a6cf32c0 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -658,7 +658,7 @@ class GatewaySessionReconnectTest { code = "NOT_PAIRED", message = "pairing required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "PAIRING_REQUIRED", canRetryWithDeviceToken = false, recommendedNextStep = "wait_then_retry", @@ -685,7 +685,7 @@ class GatewaySessionReconnectTest { code = "NOT_PAIRED", message = "pairing required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "PAIRING_REQUIRED", canRetryWithDeviceToken = false, recommendedNextStep = null, @@ -711,7 +711,7 @@ class GatewaySessionReconnectTest { code = "NOT_PAIRED", message = "pairing required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "PAIRING_REQUIRED", canRetryWithDeviceToken = false, recommendedNextStep = "wait_then_retry", @@ -748,7 +748,7 @@ class GatewaySessionReconnectTest { code = "INVALID_REQUEST", message = "authentication failed", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = code, canRetryWithDeviceToken = false, recommendedNextStep = null, @@ -785,7 +785,7 @@ class GatewaySessionReconnectTest { code = "INVALID_REQUEST", message = "authentication failed", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "AUTH_UNAUTHORIZED", canRetryWithDeviceToken = nextStep == "retry_with_device_token", recommendedNextStep = nextStep, @@ -811,7 +811,7 @@ class GatewaySessionReconnectTest { code = "INVALID_REQUEST", message = "authentication rate limited", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "AUTH_RATE_LIMITED", canRetryWithDeviceToken = false, recommendedNextStep = "wait_then_retry", @@ -836,7 +836,7 @@ class GatewaySessionReconnectTest { code = "INVALID_REQUEST", message = "protocol mismatch", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "PROTOCOL_MISMATCH", canRetryWithDeviceToken = false, recommendedNextStep = null, @@ -865,7 +865,7 @@ class GatewaySessionReconnectTest { code = "NOT_PAIRED", message = "pairing required", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = "PAIRING_REQUIRED", canRetryWithDeviceToken = false, recommendedNextStep = null, @@ -985,6 +985,41 @@ class GatewaySessionReconnectTest { } } + @Test + fun methodFailurePreservesMissingScopeDetails() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + when (method) { + "connect" -> webSocket.send(connectResponseFrame(id)) + "question.list" -> + webSocket.send( + """ + {"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"permission denied","details":{"code":"MISSING_SCOPE","missingScope":"operator.questions","requiredScopes":["operator.questions"]}}} + """.trimIndent(), + ) + } + } + val harness = createReconnectHarness(onConnected = { connected.complete(Unit) }) + + try { + connectNodeSession(harness.session, server.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connected.await() } + + val result = harness.session.requestDetailed("question.list", "{}") + + assertFalse(result.ok) + assertEquals("MISSING_SCOPE", result.error?.details?.code) + assertEquals("operator.questions", result.error?.details?.missingScope) + assertEquals(listOf("operator.questions"), result.error?.details?.requiredScopes) + assertEquals("operator.questions", result.error?.missingScope()) + } finally { + shutdownReconnectHarness(harness, server) + } + } + private fun createReconnectHarness( onConnected: () -> Unit = {}, onHello: (GatewayHelloSummary) -> Unit = {}, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt index c32df959b3cc..f09f07cc47f3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt @@ -1,6 +1,6 @@ package ai.openclaw.app.voice -import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewayErrorDetails import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest @@ -64,7 +64,7 @@ class TalkSpeakClientTest { code = "UNAVAILABLE", message = "talk unavailable", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = null, canRetryWithDeviceToken = false, recommendedNextStep = null, @@ -93,7 +93,7 @@ class TalkSpeakClientTest { code = "UNAVAILABLE", message = "provider failed", details = - GatewayConnectErrorDetails( + GatewayErrorDetails( code = null, canRetryWithDeviceToken = false, recommendedNextStep = null, diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 090aa0092cee..ef703c6ac58e 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -2077,12 +2077,11 @@ final class NodeAppModel { } return decoded.ok ?? false } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - await self.setGatewayHealthMonitorDisabled(true) - return true - } + if let gatewayError = error as? GatewayResponseError, + gatewayError.isAuthorizationFailure + { + await self.setGatewayHealthMonitorDisabled(true) + return true } return false } @@ -10160,12 +10159,11 @@ extension NodeAppModel { VoiceWakePreferences.saveTriggerWords(triggers) } catch { guard shouldApply() else { return } - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - self.setGatewayHealthMonitorDisabled(true) - return - } + if let gatewayError = error as? GatewayResponseError, + gatewayError.isAuthorizationFailure + { + self.setGatewayHealthMonitorDisabled(true) + return } // Best-effort only. } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 8e8c90f7689f..c476dc0fd168 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -4273,14 +4273,7 @@ extension TalkModeManager { private static func missingTalkScope(from error: Error) -> String? { let targetScope = "operator.talk.secrets" if let gatewayError = error as? GatewayResponseError { - if Self.errorTextIndicatesMissingScope(gatewayError.message, scope: targetScope) { - return targetScope - } - if let missingScope = gatewayError.details["missingScope"]?.value as? String, - missingScope == targetScope - { - return targetScope - } + return gatewayError.missingScope == targetScope ? targetScope : nil } if Self.errorTextIndicatesMissingScope(error.localizedDescription, scope: targetScope) { return targetScope diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift index b63d4c6e793a..dd9ecbccafe4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift @@ -675,9 +675,8 @@ extension OpenClawChatViewModel { } private nonisolated static func questionListIsUnavailable(_ error: GatewayResponseError) -> Bool { - guard error.code == "INVALID_REQUEST" else { return false } - return error.message == "unknown method: question.list" || - error.message == "missing scope: operator.questions" + if error.missingScope == "operator.questions" { return true } + return error.code == "INVALID_REQUEST" && error.message == "unknown method: question.list" } private nonisolated static func questionIsNotFound(_ error: GatewayResponseError) -> Bool { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift index e8bce77f5491..6d7a14878630 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift @@ -195,6 +195,16 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable { } /// Structured error surfaced when the gateway responds with `{ ok: false }`. +public struct GatewayMissingScopeErrorDetails: Equatable, Sendable { + public let missingScope: String + public let requiredScopes: [String] + + public init(missingScope: String, requiredScopes: [String]) { + self.missingScope = missingScope + self.requiredScopes = requiredScopes + } +} + public struct GatewayResponseError: LocalizedError, @unchecked Sendable { public let method: String public let code: String @@ -218,6 +228,40 @@ public struct GatewayResponseError: LocalizedError, @unchecked Sendable { return trimmed.isEmpty ? nil : trimmed } + public var missingScopeDetails: GatewayMissingScopeErrorDetails? { + guard self.details["code"]?.stringValue == "MISSING_SCOPE" else { return nil } + let missingScope = self.details["missingScope"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !missingScope.isEmpty, let values = self.details["requiredScopes"]?.arrayValue else { + return nil + } + let requiredScopes = values.compactMap { value -> String? in + let scope = value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return scope.isEmpty ? nil : scope + } + guard !requiredScopes.isEmpty, requiredScopes.count == values.count else { return nil } + return GatewayMissingScopeErrorDetails( + missingScope: missingScope, + requiredScopes: requiredScopes) + } + + /// Structured missing scope with a fallback for gateways predating error details. + public var missingScope: String? { + if let structured = self.missingScopeDetails { return structured.missingScope } + guard self.code == "FORBIDDEN" || self.code == "INVALID_REQUEST" else { return nil } + guard let marker = self.message.range(of: "missing scope:", options: .caseInsensitive) else { + return nil + } + let suffix = self.message[marker.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + return suffix.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) + } + + public var isAuthorizationFailure: Bool { + if self.missingScope != nil { return true } + return self.code == "INVALID_REQUEST" && + self.message.localizedCaseInsensitiveContains("unauthorized role") + } + public var errorDescription: String? { if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } return "\(self.method): [\(self.code)] \(self.message)" diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index c0f6a3129faf..add3200a6350 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -1392,6 +1392,28 @@ struct ChatViewModelTests { #expect(viewModel.questionCards.isEmpty) } + @Test @MainActor func `structured missing question scope clears stale cards`() async { + let transport = TestChatTransport( + historyResponses: [], + listQuestionsHook: { + throw GatewayResponseError( + method: "question.list", + code: "FORBIDDEN", + message: "permission denied", + details: [ + "code": AnyCodable("MISSING_SCOPE"), + "missingScope": AnyCodable("operator.questions"), + "requiredScopes": AnyCodable(["operator.questions"]), + ]) + }) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + viewModel.upsertQuestion(chatQuestionRecord(id: "ask_stale")) + + await viewModel.refreshQuestions() + + #expect(viewModel.questionCards.isEmpty) + } + @Test @MainActor func `transient question list rejection preserves event cards`() async { let transport = TestChatTransport( historyResponses: [], diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift index ccdddd06db5b..3704840b2edd 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -3,6 +3,41 @@ import OpenClawKit import Testing struct GatewayErrorsTests { + @Test func `response error reads structured missing scope`() throws { + let error = GatewayResponseError( + method: "question.list", + code: "FORBIDDEN", + message: "permission denied", + details: [ + "code": AnyCodable("MISSING_SCOPE"), + "missingScope": AnyCodable("operator.questions"), + "requiredScopes": AnyCodable(["operator.read", "operator.questions"]), + ]) + + let details = try #require(error.missingScopeDetails) + #expect(details.missingScope == "operator.questions") + #expect(details.requiredScopes == ["operator.read", "operator.questions"]) + #expect(error.missingScope == "operator.questions") + } + + @Test func `response error keeps legacy missing scope compatibility`() { + let legacy = GatewayResponseError( + method: "question.list", + code: "INVALID_REQUEST", + message: "missing scope: operator.questions", + details: nil) + let unrelated = GatewayResponseError( + method: "question.list", + code: "UNAVAILABLE", + message: "missing scope: operator.questions", + details: nil) + + #expect(legacy.missingScope == "operator.questions") + #expect(unrelated.missingScope == nil) + #expect(legacy.isAuthorizationFailure) + #expect(!unrelated.isAuthorizationFailure) + } + @Test func `bootstrap token invalid is non recoverable`() { let error = GatewayConnectAuthError( message: "setup code expired", diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 2d52fd1f85d5..f9b4f3c24fb2 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -122,7 +122,7 @@ b013053a61e7d9be3d0c683c02baf57fa7e4393ec54e0df6a46ab0f2fe2348fd module/extensi e2d4a336857b4bdc98137162d16a683d8aa39b46028b74deb731b9f939efc62b module/file-access-runtime d13708bc224297df10a7f91e244f87e822fef9d71e2ca799442a49e42b6868c5 module/file-lock ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway-method-runtime -ce90af5578795d6e1cd2e9e3db5018071e57e0b21d67dfe8b8e847c0dfec5bcc module/gateway-runtime +1b8eea4bad785031864822f0f07dc03cbe8670e76e0c3ee8fa51c7a2ee5c5e15 module/gateway-runtime 4611de9835515711d641f0f3e48d3c4e528afc343d5e5934fe3ed62072955511 module/global-singleton 6062869202e3d7fdaa1f6801d6c1df7bf7406b8118cc4c7167ed409f5257b05c module/group-access 5e672df8c679d90e2e61b30d08cc9de8f1a1cac439e7020869b088ee9ab4e5ce module/group-activation diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index 9afcc492a891..ff28930224b9 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -36,6 +36,7 @@ require the `node` role. | `operator.admin` | Administrative access. Satisfies every `operator.*` scope. Required for config mutation, updates, native hooks, reserved namespaces, and high-risk approvals. | | `operator.pairing` | Device and node pairing management: list, approve, reject, remove, rotate, revoke. | | `operator.approvals` | Exec and plugin approval APIs. | +| `operator.questions` | Listing, reading, answering, and resolving interactive questions. | | `operator.talk.secrets` | Reading Talk configuration with secrets included. | Unknown future `operator.*` scopes require an exact match unless the caller @@ -44,8 +45,18 @@ already holds `operator.admin`. ## Method scope is only the first gate Each Gateway RPC has a least-privilege method scope that decides whether a -request reaches its handler. Some handlers then apply stricter checks based on -the concrete thing being approved or mutated: +request reaches its handler. Params-aware methods derive that scope before +dispatch so authorization failures have one canonical structured response: + +- `agent` needs `operator.write` for ordinary turns and `operator.admin` for + `/new` or `/reset` session lifecycle commands. +- `node.invoke` needs `operator.write` for ordinary relay commands and + `operator.admin` for `browser.proxy`, `fs.listDir`, and `terminal.upload`. +- `talk.config` needs `operator.read`; `includeSecrets: true` also needs + `operator.talk.secrets`. + +Some handlers then apply stricter checks based on the concrete thing being +approved or mutated: - `device.pair.approve` is reachable with `operator.pairing`, but approving an operator device can only mint or preserve scopes the caller already holds. @@ -77,8 +88,8 @@ Approving a device request: `operator.admin`, even though `device.pair.approve` itself only needs `operator.pairing`. - A request for `operator.read`, `operator.write`, `operator.approvals`, - `operator.pairing`, or `operator.talk.secrets` requires the caller to already - hold that scope, or `operator.admin`. + `operator.questions`, `operator.pairing`, or `operator.talk.secrets` requires + the caller to already hold that scope, or `operator.admin`. - A request for `operator.admin` requires `operator.admin`. - A repair request with no explicit scopes can inherit the existing operator token's scopes; if that token is admin-scoped, approval still requires @@ -113,8 +124,8 @@ Approving a node declaration does not enable commands that have a separate runtime allowlist gate. For example, approving a node that declares `computer.act` requires pairing plus write scope, but only records the surface. An administrator or owner must still arm `computer.act`. While it remains -armed, invoking it through the write-scoped `node.invoke` method does not -require admin scope for each action. +armed, invoking it through `node.invoke` requires write scope, but not admin +scope for each action. Node pairing establishes identity and trust; it does not replace a node's own `system.run` exec approval policy. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 4e48c5d0abac..eb0ab9d0fe91 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -40,6 +40,11 @@ missing-scope details: `requiredScopes` is the complete known scope set for the requested operation. The legacy `missing scope: ` message is retained for older clients. +Clients should read `details` first and use the legacy message only as a compatibility +fallback. `readMissingScopeError` and `readMissingScopeErrorDetails` are exported from +`@openclaw/gateway-protocol/gateway-error-details`; the browser-safe gateway client +re-exports them from `@openclaw/gateway-client/browser`. + The schemas are exported as `GatewayErrorDetailsSchema`, `MissingScopeErrorDetailsSchema` from `@openclaw/gateway-protocol/schema`. HTTP scope failures mirror the `MISSING_SCOPE` object under `error.details` and diff --git a/packages/gateway-client/src/browser.ts b/packages/gateway-client/src/browser.ts index beabbb43854d..e80a68b25b72 100644 --- a/packages/gateway-client/src/browser.ts +++ b/packages/gateway-client/src/browser.ts @@ -8,6 +8,7 @@ export * from "./reconnect-policy.js"; export { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS } from "./timeouts.js"; export * from "@openclaw/gateway-protocol/client-info"; export * from "@openclaw/gateway-protocol/connect-error-details"; +export * from "@openclaw/gateway-protocol/gateway-error-details"; export * from "@openclaw/gateway-protocol/startup-unavailable"; export * from "@openclaw/gateway-protocol/version"; export type { ConnectParams, ErrorShape, EventFrame, HelloOk } from "@openclaw/gateway-protocol"; diff --git a/packages/gateway-protocol/package.json b/packages/gateway-protocol/package.json index fcda431948d7..5697d0fd7499 100644 --- a/packages/gateway-protocol/package.json +++ b/packages/gateway-protocol/package.json @@ -29,6 +29,11 @@ "import": "./dist/frame-guards.mjs", "default": "./dist/frame-guards.mjs" }, + "./gateway-error-details": { + "types": "./dist/gateway-error-details.d.mts", + "import": "./dist/gateway-error-details.mjs", + "default": "./dist/gateway-error-details.mjs" + }, "./schema": { "types": "./dist/schema.d.mts", "import": "./dist/schema.mjs", @@ -46,7 +51,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/gateway-error-details.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean" }, "dependencies": { "typebox": "1.3.3" diff --git a/packages/gateway-protocol/src/gateway-error-details.ts b/packages/gateway-protocol/src/gateway-error-details.ts new file mode 100644 index 000000000000..594b86265c34 --- /dev/null +++ b/packages/gateway-protocol/src/gateway-error-details.ts @@ -0,0 +1,104 @@ +/** Gateway JSON-RPC style error codes shared by clients and server handlers. */ +export const ErrorCodes = { + /** Client has not completed account/device linking for this gateway. */ + NOT_LINKED: "NOT_LINKED", + /** Device exists but still needs an explicit pairing approval. */ + NOT_PAIRED: "NOT_PAIRED", + /** Agent turn exceeded the gateway wait window. */ + AGENT_TIMEOUT: "AGENT_TIMEOUT", + /** Request payload failed protocol validation or method preconditions. */ + INVALID_REQUEST: "INVALID_REQUEST", + /** Authenticated caller lacks permission for the requested operation. */ + FORBIDDEN: "FORBIDDEN", + /** Approval resolution referenced a missing or expired approval request. */ + APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND", + /** Gateway service or required backend is temporarily unavailable. */ + UNAVAILABLE: "UNAVAILABLE", +} as const; + +/** Closed set of canonical gateway error code strings. */ +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +/** Stable discriminants for structured method-level authorization failures. */ +export const GatewayErrorDetailCodes = { + MISSING_SCOPE: "MISSING_SCOPE", +} as const; + +/** Missing operator-scope details shared by WebSocket and HTTP responses. */ +export type MissingScopeErrorDetails = { + code: typeof GatewayErrorDetailCodes.MISSING_SCOPE; + missingScope: string; + requiredScopes: string[]; +}; + +/** Structured details emitted by method-level authorization failures. */ +export type GatewayErrorDetails = MissingScopeErrorDetails; + +type GatewayErrorLike = { + code?: unknown; + gatewayCode?: unknown; + message?: unknown; + details?: unknown; +}; + +const LEGACY_MISSING_SCOPE_PATTERN = /\bmissing scope:\s*([a-z0-9._-]+)/i; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** Reads validated missing-scope details from an untrusted protocol payload. */ +export function readMissingScopeErrorDetails(details: unknown): MissingScopeErrorDetails | null { + const record = asRecord(details); + if (record?.code !== GatewayErrorDetailCodes.MISSING_SCOPE) { + return null; + } + const missingScope = typeof record.missingScope === "string" ? record.missingScope.trim() : ""; + const requiredScopes = Array.isArray(record.requiredScopes) + ? record.requiredScopes.map((scope) => (typeof scope === "string" ? scope.trim() : "")) + : []; + if (!missingScope || requiredScopes.length === 0 || requiredScopes.some((scope) => !scope)) { + return null; + } + return { + code: GatewayErrorDetailCodes.MISSING_SCOPE, + missingScope, + requiredScopes, + }; +} + +/** + * Reads a method-level missing-scope failure, preferring structured details. + * The message fallback keeps clients compatible with gateways predating structured details. + */ +export function readMissingScopeError(error: unknown): MissingScopeErrorDetails | null { + const record = asRecord(error); + if (!record) { + return null; + } + const structured = readMissingScopeErrorDetails(record.details); + if (structured) { + return structured; + } + const gatewayError = record as GatewayErrorLike; + const code = + typeof gatewayError.gatewayCode === "string" + ? gatewayError.gatewayCode + : typeof gatewayError.code === "string" + ? gatewayError.code + : ""; + if (code !== ErrorCodes.FORBIDDEN && code !== ErrorCodes.INVALID_REQUEST) { + return null; + } + const message = typeof gatewayError.message === "string" ? gatewayError.message : ""; + const missingScope = message.match(LEGACY_MISSING_SCOPE_PATTERN)?.[1]; + return missingScope + ? { + code: GatewayErrorDetailCodes.MISSING_SCOPE, + missingScope, + requiredScopes: [missingScope], + } + : null; +} diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index c4aa2bce3253..941fdd7c9bec 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -1,4 +1,5 @@ export * from "./clawhub-trust-error-details.js"; +export { readMissingScopeError, readMissingScopeErrorDetails } from "./gateway-error-details.js"; export * from "./session-icon.js"; export * from "./terminal-validators.js"; export { diff --git a/packages/gateway-protocol/src/schema/error-codes.test.ts b/packages/gateway-protocol/src/schema/error-codes.test.ts index 3b6356ab8067..2a1d070121ef 100644 --- a/packages/gateway-protocol/src/schema/error-codes.test.ts +++ b/packages/gateway-protocol/src/schema/error-codes.test.ts @@ -6,6 +6,8 @@ import { GatewayErrorDetailsSchema, MissingScopeErrorDetailsSchema, missingScopeErrorShape, + readMissingScopeError, + readMissingScopeErrorDetails, } from "./error-codes.js"; describe("gateway error details", () => { @@ -49,4 +51,58 @@ describe("gateway error details", () => { requiredScopes: ["operator.admin"], }); }); + + it("reads structured missing-scope details without parsing the message", () => { + expect( + readMissingScopeError({ + code: ErrorCodes.FORBIDDEN, + message: "permission denied", + details: { + code: GatewayErrorDetailCodes.MISSING_SCOPE, + missingScope: "operator.questions", + requiredScopes: ["operator.read", "operator.questions"], + }, + }), + ).toEqual({ + code: GatewayErrorDetailCodes.MISSING_SCOPE, + missingScope: "operator.questions", + requiredScopes: ["operator.read", "operator.questions"], + }); + }); + + it("falls back to the legacy message only for authorization error codes", () => { + expect( + readMissingScopeError({ + gatewayCode: ErrorCodes.INVALID_REQUEST, + message: "missing scope: operator.read", + }), + ).toEqual({ + code: GatewayErrorDetailCodes.MISSING_SCOPE, + missingScope: "operator.read", + requiredScopes: ["operator.read"], + }); + expect( + readMissingScopeError({ + code: ErrorCodes.UNAVAILABLE, + message: "missing scope: operator.read", + }), + ).toBeNull(); + }); + + it("rejects malformed structured details", () => { + expect( + readMissingScopeErrorDetails({ + code: GatewayErrorDetailCodes.MISSING_SCOPE, + missingScope: "operator.read", + requiredScopes: [], + }), + ).toBeNull(); + expect( + readMissingScopeErrorDetails({ + code: GatewayErrorDetailCodes.MISSING_SCOPE, + missingScope: "operator.read", + requiredScopes: ["operator.read", 42], + }), + ).toBeNull(); + }); }); diff --git a/packages/gateway-protocol/src/schema/error-codes.ts b/packages/gateway-protocol/src/schema/error-codes.ts index 3fc69347326b..28790e87e176 100644 --- a/packages/gateway-protocol/src/schema/error-codes.ts +++ b/packages/gateway-protocol/src/schema/error-codes.ts @@ -1,35 +1,24 @@ // Gateway Protocol schema module defines protocol validation shapes. -import type { Static } from "typebox"; import { Type } from "typebox"; +import { + ErrorCodes, + GatewayErrorDetailCodes, + type ErrorCode, + type MissingScopeErrorDetails, +} from "../gateway-error-details.js"; import { closedObject } from "./closed-object.js"; import type { ErrorShape } from "./frames.js"; import { NonEmptyString } from "./primitives.js"; -/** Gateway JSON-RPC style error codes shared by clients and server handlers. */ -export const ErrorCodes = { - /** Client has not completed account/device linking for this gateway. */ - NOT_LINKED: "NOT_LINKED", - /** Device exists but still needs an explicit pairing approval. */ - NOT_PAIRED: "NOT_PAIRED", - /** Agent turn exceeded the gateway wait window. */ - AGENT_TIMEOUT: "AGENT_TIMEOUT", - /** Request payload failed protocol validation or method preconditions. */ - INVALID_REQUEST: "INVALID_REQUEST", - /** Authenticated caller lacks permission for the requested operation. */ - FORBIDDEN: "FORBIDDEN", - /** Approval resolution referenced a missing or expired approval request. */ - APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND", - /** Gateway service or required backend is temporarily unavailable. */ - UNAVAILABLE: "UNAVAILABLE", -} as const; - -/** Closed set of canonical gateway error code strings. */ -export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; - -/** Stable discriminants for structured method-level authorization failures. */ -export const GatewayErrorDetailCodes = { - MISSING_SCOPE: "MISSING_SCOPE", -} as const; +export { + ErrorCodes, + GatewayErrorDetailCodes, + type ErrorCode, + type GatewayErrorDetails, + type MissingScopeErrorDetails, + readMissingScopeError, + readMissingScopeErrorDetails, +} from "../gateway-error-details.js"; /** Missing operator-scope details shared by WebSocket and HTTP responses. */ export const MissingScopeErrorDetailsSchema = closedObject({ @@ -41,9 +30,6 @@ export const MissingScopeErrorDetailsSchema = closedObject({ /** Structured details emitted by method-level authorization failures. */ export const GatewayErrorDetailsSchema = MissingScopeErrorDetailsSchema; -export type MissingScopeErrorDetails = Static; -export type GatewayErrorDetails = Static; - /** Builds the canonical gateway error payload while preserving optional retry metadata. */ export function errorShape( code: ErrorCode, diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 1f238cff5d8b..8bc88fe3cb0f 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -236,14 +236,12 @@ export const handleToolsCommand: CommandHandler = async (params, allowTextComman shouldContinue: false, reply: { text: buildToolsMessage(result, { verbose }) }, }; - } catch (err) { - const message = String(err); - const text = message.includes("missing scope:") - ? "You do not have permission to view available tools." - : "Couldn't load available tools right now. Try again in a moment."; + } catch { + // Inventory resolves in-process after sender authorization; this path cannot receive + // gateway RPC scope errors, so failures here are local discovery failures. return { shouldContinue: false, - reply: { text }, + reply: { text: "Couldn't load available tools right now. Try again in a moment." }, }; } }; diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index 482c941750db..b8991762f83d 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; +import { readConnectErrorDetailCode } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { readMissingScopeError } from "../../../packages/gateway-protocol/src/gateway-error-details.js"; import type { OperatorScope } from "../../gateway/method-scopes.js"; import { parseStrictFiniteNumber, @@ -32,18 +34,6 @@ const STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES = new Set([ "PAIRING_REQUIRED", ]); -function readGatewayClientRequestDetailCode(value: unknown): string | null { - if (!(value instanceof Error) || value.name !== "GatewayClientRequestError") { - return null; - } - const details = (value as Error & { details?: unknown }).details; - if (!details || typeof details !== "object") { - return null; - } - const code = (details as { code?: unknown }).code; - return typeof code === "string" ? code : null; -} - function isDiagnosticsAuthFallbackError(value: unknown): value is Error { if ( value instanceof Error && @@ -53,16 +43,15 @@ function isDiagnosticsAuthFallbackError(value: unknown): value is Error { ) { return true; } - const detailCode = readGatewayClientRequestDetailCode(value); + if (!(value instanceof Error) || value.name !== "GatewayClientRequestError") { + return false; + } + const details = (value as Error & { details?: unknown }).details; + const detailCode = readConnectErrorDetailCode(details); if (detailCode !== null && STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES.has(detailCode)) { return true; } - return ( - value instanceof Error && - value.name === "GatewayClientRequestError" && - (value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" && - value.message.includes("missing scope: operator.read") - ); + return readMissingScopeError(value)?.missingScope === "operator.read"; } function isUnknownGatewayMethodError(value: unknown, method: string): value is Error { diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index b849a17311e9..6c419f57807d 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -728,9 +728,14 @@ describe("cli program (nodes basics)", () => { useStoredDeviceAuth?: boolean; }; if (opts.method === "node.list" && opts.useStoredDeviceAuth) { - throw Object.assign(new Error("missing scope: operator.read"), { + throw Object.assign(new Error("permission denied"), { name: "GatewayClientRequestError", - gatewayCode: "INVALID_REQUEST", + gatewayCode: "FORBIDDEN", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.read", + requiredScopes: ["operator.read"], + }, }); } if (opts.method === "node.list" && opts.scopes?.includes("operator.pairing")) { diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index 22f5371ad1d2..8af9fc3a2b20 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -252,6 +252,32 @@ describe("probe reachability classification", () => { ); }); + it("uses structured missing-scope probe details before the display message", () => { + const probe = { + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 51, + error: "permission denied", + missingScopeErrorDetails: { + code: "MISSING_SCOPE" as const, + missingScope: "operator.read", + requiredScopes: ["operator.read"], + }, + close: null, + auth: { + role: "operator", + scopes: ["operator.write"], + capability: "write_capable" as const, + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + + expect(isScopeLimitedProbeFailure(probe)).toBe(true); + }); + it("treats post-connect read failures as reachable with failed diagnostics", () => { const probe = { ok: false, diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 8176101b2900..6af618536dae 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -11,7 +11,7 @@ import type { GatewayProbeCapability, GatewayProbeResult } from "../../gateway/p import { inspectBestEffortPrimaryTailnetIPv4 } from "../../infra/network-discovery-display.js"; import { parseStrictInteger } from "../../infra/parse-finite-number.js"; -const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i; +const LEGACY_MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i; type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel"; @@ -296,7 +296,10 @@ export function isScopeLimitedProbeFailure(probe: GatewayProbeResult): boolean { if (probe.ok || probe.connectLatencyMs == null) { return false; } - return MISSING_SCOPE_PATTERN.test(probe.error ?? ""); + if (probe.missingScopeErrorDetails) { + return probe.missingScopeErrorDetails.missingScope === "operator.read"; + } + return LEGACY_MISSING_SCOPE_PATTERN.test(probe.error ?? ""); } /** Returns true when the gateway connection was established but a later probe failed. */ diff --git a/src/gateway/agent-command-policy.ts b/src/gateway/agent-command-policy.ts new file mode 100644 index 000000000000..3c06ccaeadb0 --- /dev/null +++ b/src/gateway/agent-command-policy.ts @@ -0,0 +1,7 @@ +/** Commands routed through `agent` that mutate session lifecycle state. */ +export const AGENT_SESSION_RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i; + +/** Returns true when an agent message requests a session reset. */ +export function isAgentSessionResetCommand(message: unknown): boolean { + return typeof message === "string" && AGENT_SESSION_RESET_COMMAND_RE.test(message); +} diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 53b5e18a32c9..2f69e65a059f 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -117,6 +117,60 @@ describe("method scope resolution", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("node.pending.drain")).toStrictEqual([]); }); + it("raises agent reset commands from write to admin scope", () => { + expect(resolveLeastPrivilegeOperatorScopesForMethod("agent", { message: "hello" })).toEqual([ + "operator.write", + ]); + expect(resolveLeastPrivilegeOperatorScopesForMethod("agent", { message: "/reset" })).toEqual([ + "operator.admin", + ]); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("agent", { message: "/new follow up" }), + ).toEqual(["operator.admin"]); + expect( + authorizeOperatorScopesForMethod("agent", ["operator.write"], { message: "/reset" }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); + }); + + it("raises host-sensitive node commands from write to admin scope", () => { + expect( + resolveLeastPrivilegeOperatorScopesForMethod("node.invoke", { command: "device.info" }), + ).toEqual(["operator.write"]); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("node.invoke", { command: "browser.proxy" }), + ).toEqual(["operator.admin"]); + expect( + authorizeOperatorScopesForMethod("node.invoke", ["operator.write"], { + command: "fs.listDir", + }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("node.invoke", { + command: "browser.proxy", + params: { method: "POST", path: "/profiles/create" }, + }), + ).toEqual(["operator.write"]); + }); + + it("adds talk secret scope only when unredacted config is requested", () => { + expect(resolveLeastPrivilegeOperatorScopesForMethod("talk.config", {})).toEqual([ + "operator.read", + ]); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("talk.config", { includeSecrets: true }), + ).toEqual(["operator.read", "operator.talk.secrets"]); + expect( + authorizeOperatorScopesForMethod("talk.config", ["operator.read"], { + includeSecrets: true, + }), + ).toEqual({ allowed: false, missingScope: "operator.talk.secrets" }); + expect( + authorizeOperatorScopesForMethod("talk.config", ["operator.read", "operator.talk.secrets"], { + includeSecrets: true, + }), + ).toEqual({ allowed: true }); + }); + it("classifies plugin session actions with a CLI-safe default operator scope", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction")).toEqual([ "operator.write", diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 3a14dee516bc..455f10ae9b25 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -1,14 +1,17 @@ // Gateway method authorization scope resolver. // Maps static and plugin-defined gateway methods to operator scopes. import { normalizeOptionalString as normalizeSessionActionParam } from "@openclaw/normalization-core/string-coerce"; +import { isAdminOnlyNodeInvokeCommand } from "../infra/node-commands.js"; import { getPluginRegistryState } from "../plugins/runtime-state.js"; import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js"; +import { isAgentSessionResetCommand } from "./agent-command-policy.js"; import { isCoreGatewayMethodClassified, isCoreNodeGatewayMethod, isDynamicOperatorGatewayMethod, resolveCoreOperatorGatewayMethodScope, } from "./methods/core-descriptors.js"; +import { isForbiddenBrowserProxyMutation } from "./node-browser-proxy-policy.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE, @@ -161,6 +164,33 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod( if (method === "plugins.sessionAction") { return resolveSessionActionLeastPrivilegeScopes(params); } + if (method === "agent") { + const message = + params && typeof params === "object" && !Array.isArray(params) + ? (params as { message?: unknown }).message + : undefined; + return isAgentSessionResetCommand(message) ? [ADMIN_SCOPE] : [WRITE_SCOPE]; + } + if (method === "node.invoke") { + const record = + params && typeof params === "object" && !Array.isArray(params) + ? (params as { command?: unknown; params?: unknown }) + : undefined; + const command = record?.command; + // Invalid persistent-profile mutations must reach the handler's precise fail-closed + // rejection instead of being disguised as an admin-scope failure. + if (command === "browser.proxy" && isForbiddenBrowserProxyMutation(record?.params)) { + return [WRITE_SCOPE]; + } + return isAdminOnlyNodeInvokeCommand(command) ? [ADMIN_SCOPE] : [WRITE_SCOPE]; + } + if (method === "talk.config") { + const includeSecrets = + params && typeof params === "object" && !Array.isArray(params) + ? (params as { includeSecrets?: unknown }).includeSecrets + : undefined; + return includeSecrets === true ? [READ_SCOPE, TALK_SECRETS_SCOPE] : [READ_SCOPE]; + } if (method === "sessions.patch") { return resolveSessionsPatchRequiredScopes(params); } @@ -237,40 +267,26 @@ export function authorizeOperatorScopesForMethod( return { allowed: true }; } if (isDynamicOperatorGatewayMethod(method)) { - if (method === "sessions.create") { - const missingScope = findMissingOperatorScope( - resolveSessionsCreateRequiredScopes(params), - scopes, - ); - return missingScope ? { allowed: false, missingScope } : { allowed: true }; - } - if (method === "sessions.patch") { - const missingScope = findMissingOperatorScope( - resolveSessionsPatchRequiredScopes(params), - scopes, - ); - return missingScope ? { allowed: false, missingScope } : { allowed: true }; - } - if (method === "sessions.delete") { - const missingScope = findMissingOperatorScope( - resolveSessionsDeleteRequiredScopes(params), - scopes, - ); - return missingScope ? { allowed: false, missingScope } : { allowed: true }; - } - const registeredScopes = resolveSessionActionRegisteredScopes(params); - if (!registeredScopes && params && typeof params === "object" && !Array.isArray(params)) { - const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId); - const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId); - if (!pluginId || !actionId) { - // Malformed dynamic params cannot be matched to a plugin action. Any valid operator scope - // may proceed so the handler can return the precise validation error. - return scopes.some((scope) => isOperatorScope(scope)) - ? { allowed: true } - : { allowed: false, missingScope: WRITE_SCOPE }; + if (method === "plugins.sessionAction") { + const registeredScopes = resolveSessionActionRegisteredScopes(params); + if (!registeredScopes && params && typeof params === "object" && !Array.isArray(params)) { + const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId); + const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId); + if (!pluginId || !actionId) { + // Malformed dynamic params cannot be matched to a plugin action. Any valid operator scope + // may proceed so the handler can return the precise validation error. + return scopes.some((scope) => isOperatorScope(scope)) + ? { allowed: true } + : { allowed: false, missingScope: WRITE_SCOPE }; + } } + const missingScope = findMissingOperatorScope(registeredScopes ?? [WRITE_SCOPE], scopes); + return missingScope ? { allowed: false, missingScope } : { allowed: true }; } - const missingScope = findMissingOperatorScope(registeredScopes ?? [WRITE_SCOPE], scopes); + const missingScope = findMissingOperatorScope( + resolveDynamicLeastPrivilegeOperatorScopesForMethod(method, params), + scopes, + ); return missingScope ? { allowed: false, missingScope } : { allowed: true }; } const requiredScope = resolveRequiredOperatorScopeForMethod(method) ?? ADMIN_SCOPE; diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index fb60feea4e1b..46e2e61d848d 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -84,7 +84,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "wizard.cancel", scope: "operator.admin" }, { name: "wizard.status", scope: "operator.admin" }, { name: "talk.catalog", scope: "operator.read" }, - { name: "talk.config", scope: "operator.read" }, + // Params-aware: reading redacted config needs read; includeSecrets also needs talk secrets. + { name: "talk.config", scope: "dynamic" }, { name: "talk.client.create", scope: "operator.write" }, { name: "talk.client.toolCall", scope: "operator.write" }, { name: "talk.client.steer", scope: "operator.write" }, @@ -243,7 +244,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "node.skills.update", scope: "node" }, { name: "node.pending.drain", scope: "node" }, { name: "node.pending.enqueue", scope: "operator.write" }, - { name: "node.invoke", scope: "operator.write" }, + // Params-aware: host-sensitive commands raise direct invocation from write to admin. + { name: "node.invoke", scope: "dynamic" }, { name: "node.pending.pull", scope: "node" }, { name: "node.pending.ack", scope: "node" }, { name: "node.invoke.progress", scope: "node" }, @@ -267,7 +269,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "conversations.turn", scope: "operator.admin" }, { name: "conversations.turn.cancel", scope: "operator.admin" }, { name: "send", scope: "operator.write" }, - { name: "agent", scope: "operator.write" }, + // Params-aware: ordinary turns need write; /new and /reset mutate lifecycle state as admin. + { name: "agent", scope: "dynamic" }, { name: "agent.identity.get", scope: "operator.read" }, { name: "agent.wait", scope: "operator.write", startup: true }, { name: "chat.history", scope: "operator.read", startup: true }, diff --git a/src/gateway/server-methods/node-browser-proxy.ts b/src/gateway/node-browser-proxy-policy.ts similarity index 100% rename from src/gateway/server-methods/node-browser-proxy.ts rename to src/gateway/node-browser-proxy-policy.ts diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index a4d2cc08dbe8..5d07e70b86d0 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -24,6 +24,7 @@ const gatewayClientState = vi.hoisted(() => ({ reason: "scope-upgrade", requestId: "req-123", } as Record | null, + requestError: null as { code: string; message: string; details?: unknown } | null, stopCalls: 0, stopAndWaitCalls: [] as Array<{ timeoutMs?: number } | undefined>, stopAndWaitMode: "resolve" as "resolve" | "defer" | "reject", @@ -55,10 +56,15 @@ const eventLoopReadyState = vi.hoisted(() => ({ })); class MockGatewayClientRequestError extends Error { + readonly code: string; + readonly gatewayCode: string; readonly details?: unknown; - constructor(error: { message?: string; details?: unknown }) { + constructor(error: { code?: string; message?: string; details?: unknown }) { super(error.message ?? "gateway request failed"); + this.name = "GatewayClientRequestError"; + this.code = error.code ?? "UNAVAILABLE"; + this.gatewayCode = this.code; this.details = error.details; } } @@ -143,6 +149,9 @@ class MockGatewayClient { async request(method: string): Promise { gatewayClientState.requests.push(method); + if (gatewayClientState.requestError) { + throw new MockGatewayClientRequestError(gatewayClientState.requestError); + } if (method === "system-presence") { return []; } @@ -303,6 +312,7 @@ describe("probeGateway", () => { reason: "scope-upgrade", requestId: "req-123", }; + gatewayClientState.requestError = null; gatewayClientState.stopCalls = 0; gatewayClientState.stopAndWaitCalls = []; gatewayClientState.stopAndWaitMode = "resolve"; @@ -389,6 +399,33 @@ describe("probeGateway", () => { }); }); + it("preserves structured missing-scope details from a post-connect request", async () => { + gatewayClientState.helloAuth = { + role: "operator", + scopes: ["operator.write"], + }; + gatewayClientState.requestError = { + code: "FORBIDDEN", + message: "permission denied", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.read", + requiredScopes: ["operator.read"], + }, + }; + + const result = await runTokenProbe(); + + expect(result.ok).toBe(false); + expect(result.error).toBe("permission denied"); + expect(result.connectLatencyMs).not.toBeNull(); + expect(result.missingScopeErrorDetails).toEqual({ + code: "MISSING_SCOPE", + missingScope: "operator.read", + requiredScopes: ["operator.read"], + }); + }); + it("loads probe identity and cached device auth from the provided env", async () => { const env = { ...process.env, diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 43a5e2ae3da6..424e812aa742 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -5,6 +5,10 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../packages/gateway-protocol/src/client-info.js"; +import { + readMissingScopeError, + type MissingScopeErrorDetails, +} from "../../packages/gateway-protocol/src/gateway-error-details.js"; import { loadDeviceAuthToken } from "../infra/device-auth-store.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { SystemPresence } from "../infra/system-presence.js"; @@ -49,6 +53,7 @@ export type GatewayProbeResult = { connectLatencyMs: number | null; error: string | null; connectErrorDetails?: unknown; + missingScopeErrorDetails?: MissingScopeErrorDetails; close: GatewayProbeClose | null; auth: GatewayProbeAuthSummary; server?: GatewayProbeServerSummary; @@ -330,6 +335,7 @@ export async function probeGateway(opts: { const settleProbe = (params: { ok: boolean; error: string | null; + missingScopeErrorDetails?: MissingScopeErrorDetails; verifiedRead?: boolean; health: unknown; status: unknown; @@ -340,6 +346,9 @@ export async function probeGateway(opts: { ok: params.ok, connectLatencyMs, error: params.error, + ...(params.missingScopeErrorDetails + ? { missingScopeErrorDetails: params.missingScopeErrorDetails } + : {}), connectErrorDetails, close, auth: resolveProbeAuthSummary({ @@ -477,9 +486,11 @@ export async function probeGateway(opts: { }); } catch (err) { const error = formatErrorMessage(err); + const missingScopeErrorDetails = readMissingScopeError(err); settleProbe({ ok: false, error, + ...(missingScopeErrorDetails ? { missingScopeErrorDetails } : {}), health: null, status: null, presence: null, diff --git a/src/gateway/server-methods/agent-handler-helpers.ts b/src/gateway/server-methods/agent-handler-helpers.ts index 0116c2a33d91..21ae632be94d 100644 --- a/src/gateway/server-methods/agent-handler-helpers.ts +++ b/src/gateway/server-methods/agent-handler-helpers.ts @@ -28,7 +28,6 @@ import { import { loadSessionEntry, resolveDeletedAgentIdFromSessionKey } from "../session-utils.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; -export const RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i; export const CRON_CONTINUATION_RELEASE_RECOVERY_DELAYS_MS = [250, 1_000, 4_000, 15_000] as const; export type RestoredCronContinuation = { diff --git a/src/gateway/server-methods/agent-reset-phase.ts b/src/gateway/server-methods/agent-reset-phase.ts index 358ab94cc824..ae0935e2de8c 100644 --- a/src/gateway/server-methods/agent-reset-phase.ts +++ b/src/gateway/server-methods/agent-reset-phase.ts @@ -2,13 +2,18 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { + ErrorCodes, + errorShape, + missingScopeErrorShape, +} from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js"; +import { AGENT_SESSION_RESET_COMMAND_RE } from "../agent-command-policy.js"; import { ADMIN_SCOPE } from "../method-scopes.js"; import { formatForLog } from "../ws-log.js"; import { setGatewayDedupeEntries } from "./agent-dedupe.js"; -import { clientHasAdminScope, RESET_COMMAND_RE } from "./agent-handler-helpers.js"; +import { clientHasAdminScope } from "./agent-handler-helpers.js"; import type { AgentRunRequest } from "./agent-request-types.js"; import { buildBareSessionResetResponse, @@ -60,7 +65,7 @@ export async function runAgentResetPhase(params: { effectiveTranscriptInputText: params.effectiveTranscriptInputText, message: params.message, }; - const resetCommandMatch = params.message.match(RESET_COMMAND_RE); + const resetCommandMatch = params.message.match(AGENT_SESSION_RESET_COMMAND_RE); if (!resetCommandMatch || !params.requestedSessionKey) { return { ...base, stop: false, accepted: false }; } @@ -77,7 +82,7 @@ export async function runAgentResetPhase(params: { params.respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${ADMIN_SCOPE}`), + missingScopeErrorShape({ missingScope: ADMIN_SCOPE, requiredScopes: [ADMIN_SCOPE] }), ); return { ...base, stop: true, accepted: false }; } diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 311868b489df..ffa1516e8273 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -703,7 +703,15 @@ describe("node.invoke APNs wake path", () => { const call = firstRespondCall(respond); expect(call[0]).toBe(false); - expect(call[2]?.message).toContain("missing scope: operator.admin"); + expect(call[2]).toMatchObject({ + code: "FORBIDDEN", + message: "missing scope: operator.admin", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.admin", + requiredScopes: ["operator.admin"], + }, + }); expect(nodeRegistry.invoke).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index f2e0c5c327a8..dc88d0f6884a 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -34,7 +34,7 @@ import { removePairedDeviceRole, } from "../../infra/device-pairing.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { NODE_ADMIN_ONLY_INVOKE_COMMANDS } from "../../infra/node-commands.js"; +import { isAdminOnlyNodeInvokeCommand } from "../../infra/node-commands.js"; import { approveNodePairing, getPendingNodePairing, @@ -58,6 +58,7 @@ import { refreshRemoteNodeBins, removeRemoteNodeInfo, } from "../../skills/runtime/remote.js"; +import { isForbiddenBrowserProxyMutation } from "../node-browser-proxy-policy.js"; import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js"; import { isForegroundRestrictedPluginNodeCommand, @@ -84,7 +85,6 @@ import { type DeviceManagementAuthz, } from "./device-management-authz.js"; import { emitDeviceManagementSecurityEvent } from "./device-management-security.js"; -import { isForbiddenBrowserProxyMutation } from "./node-browser-proxy.js"; import { buildNodeCommandRejectionHint } from "./node-command-rejection-hint.js"; import { nodeInvokePolicy } from "./nodes-policy.js"; import { @@ -118,7 +118,6 @@ const TALK_PTT_COMMANDS = new Set([ "talk.ptt.cancel", "talk.ptt.once", ]); -const ADMIN_ONLY_NODE_INVOKE_COMMANDS = new Set(NODE_ADMIN_ONLY_INVOKE_COMMANDS); const talkPttEventSeqBySessionId = new Map(); type NodeWakeNudgeAttempt = { @@ -1451,13 +1450,13 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } if ( - ADMIN_ONLY_NODE_INVOKE_COMMANDS.has(command) && + isAdminOnlyNodeInvokeCommand(command) && !nodeInvokePolicy.clientHasOperatorAdminScope(client) ) { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${ADMIN_SCOPE}`), + missingScopeErrorShape({ missingScope: ADMIN_SCOPE, requiredScopes: [ADMIN_SCOPE] }), ); return; } diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 3eac8b773a3f..6f085fcee956 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -9,6 +9,7 @@ import { ErrorCodes, errorShape, formatValidationErrors, + missingScopeErrorShape, type TalkSpeakParams, validateTalkCatalogParams, validateTalkConfigParams, @@ -51,7 +52,7 @@ import { synthesizeSpeech, type TtsDirectiveOverrides, } from "../../tts/tts.js"; -import { ADMIN_SCOPE, TALK_SECRETS_SCOPE } from "../operator-scopes.js"; +import { ADMIN_SCOPE, READ_SCOPE, TALK_SECRETS_SCOPE } from "../operator-scopes.js"; import { resolveConfiguredSecretInputString } from "../resolve-configured-secret-input-string.js"; import { formatForLog } from "../ws-log.js"; import { inferSpeechMimeType } from "./speech-mime.js"; @@ -727,7 +728,10 @@ export const talkHandlers: GatewayRequestHandlers = { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${TALK_SECRETS_SCOPE}`), + missingScopeErrorShape({ + missingScope: TALK_SECRETS_SCOPE, + requiredScopes: [READ_SCOPE, TALK_SECRETS_SCOPE], + }), ); return; } diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 15bd03ba5356..c2dd8bd96f6e 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -502,7 +502,15 @@ describe("gateway server agent", () => { idempotencyKey: "idem-agent-write-reset", }); expect(viaAgent.ok).toBe(false); - expect(viaAgent.error?.message).toContain("missing scope: operator.admin"); + expect(viaAgent.error).toMatchObject({ + code: "FORBIDDEN", + message: "missing scope: operator.admin", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.admin", + requiredScopes: ["operator.admin"], + }, + }); const stored = loadSessionEntry({ sessionKey: "agent:main:main", storePath }); expect(stored?.sessionId).toBe("sess-main-before-write-reset"); diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 094b4b0b06ee..36fe05cb9963 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -618,7 +618,15 @@ describe("node.invoke approval bypass", () => { idempotencyKey: crypto.randomUUID(), }); expect(directProxy.ok).toBe(false); - expect(directProxy.error?.message ?? "").toContain("missing scope: operator.admin"); + expect(directProxy.error).toMatchObject({ + code: "FORBIDDEN", + message: "missing scope: operator.admin", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.admin", + requiredScopes: ["operator.admin"], + }, + }); await expectNoForwardedInvoke(() => sawInvoke); } finally { ws.close(); @@ -674,7 +682,14 @@ describe("node.invoke approval bypass", () => { idempotencyKey: crypto.randomUUID(), }); expect(directList.ok).toBe(false); - expect(directList.error?.message ?? "").toContain("missing scope: operator.admin"); + expect(directList.error).toMatchObject({ + code: "FORBIDDEN", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.admin", + requiredScopes: ["operator.admin"], + }, + }); await expectNoForwardedInvoke(() => sawInvoke); } finally { ws.close(); diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index c2b04ea1d43a..211c11a0d3a7 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -290,7 +290,15 @@ describe("gateway talk.config", () => { await withTalkConfigConnection(["operator.read"], async (ws) => { const res = await fetchTalkConfig(ws, { includeSecrets: true }); expect(res.ok).toBe(false); - expect(res.error?.message).toContain("missing scope: operator.talk.secrets"); + expect(res.error).toMatchObject({ + code: "FORBIDDEN", + message: "missing scope: operator.talk.secrets", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.talk.secrets", + requiredScopes: ["operator.read", "operator.talk.secrets"], + }, + }); }); }); diff --git a/src/infra/node-commands.ts b/src/infra/node-commands.ts index 6dd1c0359b18..98cf5f2b59ac 100644 --- a/src/infra/node-commands.ts +++ b/src/infra/node-commands.ts @@ -24,18 +24,18 @@ export const NODE_EXEC_APPROVALS_COMMANDS = [ ] as const; // Direct node.invoke and pairing approval share this admin-only subset. -export const NODE_ADMIN_ONLY_INVOKE_COMMANDS = [ +const NODE_ADMIN_ONLY_INVOKE_COMMANDS = [ NODE_BROWSER_PROXY_COMMAND, NODE_FS_LIST_DIR_COMMAND, NODE_TERMINAL_UPLOAD_COMMAND, ] as const; -// Pairing also protects commands whose execution uses a separate gated path. -export const NODE_ADMIN_PAIR_APPROVAL_COMMANDS = [ - ...NODE_SYSTEM_RUN_COMMANDS, - ...NODE_ADMIN_ONLY_INVOKE_COMMANDS, - ...NODE_EXEC_APPROVALS_COMMANDS, -] as const; +const NODE_ADMIN_ONLY_INVOKE_COMMAND_SET = new Set(NODE_ADMIN_ONLY_INVOKE_COMMANDS); + +/** Returns true when direct node invocation crosses an admin-only host boundary. */ +export function isAdminOnlyNodeInvokeCommand(command: unknown): boolean { + return typeof command === "string" && NODE_ADMIN_ONLY_INVOKE_COMMAND_SET.has(command); +} export const NODE_MCP_TOOL_CALL_TIMEOUT_MS = 120_000; export const NODE_MCP_TOOL_CALL_GATEWAY_TIMEOUT_MS = NODE_MCP_TOOL_CALL_TIMEOUT_MS + 5_000; diff --git a/src/infra/node-pairing-authz.test.ts b/src/infra/node-pairing-authz.test.ts index 75c8e620feb2..4889720204f1 100644 --- a/src/infra/node-pairing-authz.test.ts +++ b/src/infra/node-pairing-authz.test.ts @@ -1,12 +1,21 @@ // Covers scope requirements for node pairing approvals. import { describe, expect, it } from "vitest"; import { - NODE_ADMIN_ONLY_INVOKE_COMMANDS, - NODE_ADMIN_PAIR_APPROVAL_COMMANDS, + NODE_BROWSER_PROXY_COMMAND, NODE_EXEC_APPROVALS_COMMANDS, + NODE_FS_LIST_DIR_COMMAND, + NODE_SYSTEM_RUN_COMMANDS, + NODE_TERMINAL_UPLOAD_COMMAND, + isAdminOnlyNodeInvokeCommand, } from "./node-commands.js"; import { resolveNodePairApprovalScopes } from "./node-pairing-authz.js"; +const ADMIN_ONLY_INVOKE_COMMANDS = [ + NODE_BROWSER_PROXY_COMMAND, + NODE_FS_LIST_DIR_COMMAND, + NODE_TERMINAL_UPLOAD_COMMAND, +] as const; + describe("resolveNodePairApprovalScopes", () => { it("requires operator.admin for system.run commands", () => { expect(resolveNodePairApprovalScopes(["system.run"])).toEqual([ @@ -15,7 +24,7 @@ describe("resolveNodePairApprovalScopes", () => { ]); }); - it.each(NODE_ADMIN_PAIR_APPROVAL_COMMANDS)( + it.each([...NODE_SYSTEM_RUN_COMMANDS, ...ADMIN_ONLY_INVOKE_COMMANDS])( "requires operator.admin for %s commands", (command) => { expect(resolveNodePairApprovalScopes([command])).toEqual([ @@ -25,10 +34,8 @@ describe("resolveNodePairApprovalScopes", () => { }, ); - it("keeps every direct-invoke admin command admin-gated at pairing", () => { - for (const command of NODE_ADMIN_ONLY_INVOKE_COMMANDS) { - expect(NODE_ADMIN_PAIR_APPROVAL_COMMANDS).toContain(command); - } + it.each(ADMIN_ONLY_INVOKE_COMMANDS)("classifies %s as admin-only at invocation", (command) => { + expect(isAdminOnlyNodeInvokeCommand(command)).toBe(true); }); it("keeps dedicated exec-approval commands admin-gated at pairing", () => { diff --git a/src/infra/node-pairing-authz.ts b/src/infra/node-pairing-authz.ts index 77a5b736064a..797db45a6d11 100644 --- a/src/infra/node-pairing-authz.ts +++ b/src/infra/node-pairing-authz.ts @@ -1,5 +1,9 @@ // Maps node pairing command declarations to required operator scopes. -import { NODE_ADMIN_PAIR_APPROVAL_COMMANDS } from "./node-commands.js"; +import { + NODE_EXEC_APPROVALS_COMMANDS, + NODE_SYSTEM_RUN_COMMANDS, + isAdminOnlyNodeInvokeCommand, +} from "./node-commands.js"; /** Operator scopes required to approve a pending node pairing surface. */ export type NodeApprovalScope = "operator.pairing" | "operator.write" | "operator.admin"; @@ -8,16 +12,20 @@ const OPERATOR_PAIRING_SCOPE: NodeApprovalScope = "operator.pairing"; const OPERATOR_WRITE_SCOPE: NodeApprovalScope = "operator.write"; const OPERATOR_ADMIN_SCOPE: NodeApprovalScope = "operator.admin"; +function isAdminPairApprovalCommand(command: string): boolean { + return ( + isAdminOnlyNodeInvokeCommand(command) || + NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command) || + NODE_EXEC_APPROVALS_COMMANDS.some((allowed) => allowed === command) + ); +} + /** Map declared node commands to the least operator scopes needed for approval. */ export function resolveNodePairApprovalScopes(commands: unknown): NodeApprovalScope[] { const normalized = Array.isArray(commands) ? commands.filter((command): command is string => typeof command === "string") : []; - if ( - normalized.some((command) => - NODE_ADMIN_PAIR_APPROVAL_COMMANDS.some((allowed) => allowed === command), - ) - ) { + if (normalized.some(isAdminPairApprovalCommand)) { return [OPERATOR_PAIRING_SCOPE, OPERATOR_ADMIN_SCOPE]; } if (normalized.length > 0) { diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 324b5b6f435a..b6e63f43422c 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -231,6 +231,16 @@ export const sharedVitestConfig = { find: "@openclaw/gateway-protocol/frame-guards", replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "frame-guards.ts"), }, + { + find: "@openclaw/gateway-protocol/gateway-error-details", + replacement: path.join( + repoRoot, + "packages", + "gateway-protocol", + "src", + "gateway-error-details.ts", + ), + }, { find: "@openclaw/gateway-protocol/schema", replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "schema.ts"), diff --git a/tsconfig.json b/tsconfig.json index 3ae4507e03a4..cc80f03f7560 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -75,6 +75,9 @@ "@openclaw/gateway-protocol/connect-error-details": [ "./packages/gateway-protocol/src/connect-error-details.ts" ], + "@openclaw/gateway-protocol/gateway-error-details": [ + "./packages/gateway-protocol/src/gateway-error-details.ts" + ], "@openclaw/gateway-protocol/frame-guards": [ "./packages/gateway-protocol/src/frame-guards.ts" ], diff --git a/ui/src/lib/gateway-errors.test.ts b/ui/src/lib/gateway-errors.test.ts new file mode 100644 index 000000000000..c2b7c53b0e93 --- /dev/null +++ b/ui/src/lib/gateway-errors.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { isMissingOperatorReadScopeError } from "./gateway-errors.ts"; + +function gatewayRequestError(params: { code: string; message: string; details?: unknown }): Error { + return Object.assign(new Error(params.message), { + name: "GatewayRequestError", + code: params.code, + gatewayCode: params.code, + details: params.details, + }); +} + +describe("gateway error helpers", () => { + it("classifies structured read-scope failures without message parsing", () => { + expect( + isMissingOperatorReadScopeError( + gatewayRequestError({ + code: "FORBIDDEN", + message: "permission denied", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.read", + requiredScopes: ["operator.read"], + }, + }), + ), + ).toBe(true); + }); + + it("keeps compatibility with legacy scope messages and detail codes", () => { + expect( + isMissingOperatorReadScopeError( + gatewayRequestError({ + code: "INVALID_REQUEST", + message: "missing scope: operator.read", + }), + ), + ).toBe(true); + expect( + isMissingOperatorReadScopeError( + gatewayRequestError({ + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_UNAUTHORIZED" }, + }), + ), + ).toBe(true); + }); + + it("does not confuse another missing scope with operator.read", () => { + expect( + isMissingOperatorReadScopeError( + gatewayRequestError({ + code: "FORBIDDEN", + message: "missing scope: operator.questions", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.questions", + requiredScopes: ["operator.questions"], + }, + }), + ), + ).toBe(false); + }); +}); diff --git a/ui/src/lib/gateway-errors.ts b/ui/src/lib/gateway-errors.ts index f43b3a5e95ac..6c87d60b71d9 100644 --- a/ui/src/lib/gateway-errors.ts +++ b/ui/src/lib/gateway-errors.ts @@ -1,4 +1,5 @@ // Control UI shared Gateway error helpers. +import { readMissingScopeError } from "@openclaw/gateway-client/browser"; import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; import { resolveGatewayErrorDetailCode } from "../api/gateway.ts"; @@ -9,13 +10,12 @@ export function isMissingOperatorReadScopeError(err: unknown): boolean { if (!(err instanceof Error) || err.name !== "GatewayRequestError") { return false; } + if (readMissingScopeError(err)?.missingScope === "operator.read") { + return true; + } const detailCode = resolveGatewayErrorDetailCode(err as { details?: unknown }); - // AUTH_UNAUTHORIZED is the current server signal for scope failures in RPC responses. - // The message-based branch catches responses that do not include a structured detail code yet. - return ( - detailCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED || - err.message.includes("missing scope: operator.read") - ); + // Older gateways sometimes reused the connect-time authorization detail for RPC failures. + return detailCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED; } export function formatMissingOperatorReadScopeMessage(feature: string): string {