fix(gateway): make scope errors machine-readable across clients (#111013)

* fix(gateway): consume structured scope errors

* docs(gateway): clarify tools error boundary

* refactor(gateway): unify node admin policy
This commit is contained in:
Peter Steinberger
2026-07-18 17:53:45 -07:00
committed by GitHub
parent 593ac4ae0c
commit 62c5a8b888
54 changed files with 957 additions and 231 deletions
+28 -28
View File
@@ -1755,7 +1755,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 3152, "line": 3173,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not stage an attachment for sending.", "source": "Could not stage an attachment for sending.",
"surface": "android", "surface": "android",
@@ -1763,7 +1763,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 3185, "line": 3206,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "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.", "source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
"surface": "android", "surface": "android",
@@ -1771,7 +1771,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 3191, "line": 3212,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "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.", "source": "Attachments are too large to queue for one message; remove some and try again.",
"surface": "android", "surface": "android",
@@ -1779,7 +1779,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 3197, "line": 3218,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Offline attachment storage is full; delete queued items first.", "source": "Offline attachment storage is full; delete queued items first.",
"surface": "android", "surface": "android",
@@ -1787,7 +1787,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 3202, "line": 3223,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Gateway health not OK; cannot send", "source": "Gateway health not OK; cannot send",
"surface": "android", "surface": "android",
@@ -1795,7 +1795,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 3209, "line": 3230,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not queue message for later delivery.", "source": "Could not queue message for later delivery.",
"surface": "android", "surface": "android",
@@ -1803,7 +1803,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 3926, "line": 3947,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Chat failed", "source": "Chat failed",
"surface": "android", "surface": "android",
@@ -1811,7 +1811,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 4064, "line": 4085,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Event stream interrupted; try refreshing.", "source": "Event stream interrupted; try refreshing.",
"surface": "android", "surface": "android",
@@ -1819,7 +1819,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 4201, "line": 4222,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Timed out waiting for a reply; try again or refresh.", "source": "Timed out waiting for a reply; try again or refresh.",
"surface": "android", "surface": "android",
@@ -1827,7 +1827,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 4411, "line": 4432,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Timed out confirming the sent message; refresh to check delivery.", "source": "Timed out confirming the sent message; refresh to check delivery.",
"surface": "android", "surface": "android",
@@ -1843,7 +1843,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 1530, "line": 1563,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Connecting…", "source": "Connecting…",
"surface": "android", "surface": "android",
@@ -1851,7 +1851,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 1530, "line": 1563,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Reconnecting…", "source": "Reconnecting…",
"surface": "android", "surface": "android",
@@ -23443,7 +23443,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 4088, "line": 4087,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Approval needed", "source": "Approval needed",
"surface": "apple", "surface": "apple",
@@ -23451,7 +23451,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 4089, "line": 4088,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Action required", "source": "Action required",
"surface": "apple", "surface": "apple",
@@ -23459,7 +23459,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 4804, "line": 4803,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connecting...", "source": "Connecting...",
"surface": "apple", "surface": "apple",
@@ -23467,7 +23467,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 4805, "line": 4804,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Reconnecting...", "source": "Reconnecting...",
"surface": "apple", "surface": "apple",
@@ -23475,7 +23475,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 5157, "line": 5156,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connected", "source": "Connected",
"surface": "apple", "surface": "apple",
@@ -23483,7 +23483,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 5157, "line": 5156,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Offline", "source": "Offline",
"surface": "apple", "surface": "apple",
@@ -23491,7 +23491,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 6327, "line": 6326,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "No chat messages yet", "source": "No chat messages yet",
"surface": "apple", "surface": "apple",
@@ -23499,7 +23499,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 6526, "line": 6525,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connecting…", "source": "Connecting…",
"surface": "apple", "surface": "apple",
@@ -23507,7 +23507,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 6526, "line": 6525,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Reconnecting…", "source": "Reconnecting…",
"surface": "apple", "surface": "apple",
@@ -23515,7 +23515,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 8935, "line": 8934,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Approval", "source": "Approval",
"surface": "apple", "surface": "apple",
@@ -23523,7 +23523,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 8935, "line": 8934,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "This approval was already", "source": "This approval was already",
"surface": "apple", "surface": "apple",
@@ -23531,7 +23531,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 8941, "line": 8940,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "This approval was already set to Always Allow.", "source": "This approval was already set to Always Allow.",
"surface": "apple", "surface": "apple",
@@ -23539,7 +23539,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 8942, "line": 8941,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Approval set to Always Allow.", "source": "Approval set to Always Allow.",
"surface": "apple", "surface": "apple",
@@ -23547,7 +23547,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 10280, "line": 10278,
"path": "apps/ios/Sources/Model/NodeAppModel.swift", "path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "\\(urlText.prefix(500))…", "source": "\\(urlText.prefix(500))…",
"surface": "apple", "surface": "apple",
@@ -25683,7 +25683,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 4700, "line": 4693,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift", "path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime unavailable", "source": "Realtime unavailable",
"surface": "apple", "surface": "apple",
@@ -25691,7 +25691,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 4722, "line": 4715,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift", "path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening (PTT)", "source": "Listening (PTT)",
"surface": "apple", "surface": "apple",
@@ -2230,7 +2230,28 @@ class ChatController internal constructor(
stateRevision: Long, stateRevision: Long,
gatewayScope: ChatCacheScope?, gatewayScope: ChatCacheScope?,
): Boolean { ): 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 if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false
val listedRecords = json.decodeFromString<QuestionListResult>(response).questions val listedRecords = json.decodeFromString<QuestionListResult>(response).questions
val listedIds = listedRecords.mapTo(mutableSetOf()) { it.id } val listedIds = listedRecords.mapTo(mutableSetOf()) { it.id }
@@ -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 code: String?,
val canRetryWithDeviceToken: Boolean, val canRetryWithDeviceToken: Boolean,
val recommendedNextStep: String?, val recommendedNextStep: String?,
@@ -92,8 +92,17 @@ data class GatewayConnectErrorDetails(
val clawhubTrustCode: String? = null, val clawhubTrustCode: String? = null,
val clawhubWarning: String? = null, val clawhubWarning: String? = null,
val clawhubVersion: String? = null, val clawhubVersion: String? = null,
val missingScope: String? = null,
val requiredScopes: List<String> = emptyList(),
) )
data class GatewayMissingScopeErrorDetails(
val missingScope: String,
val requiredScopes: List<String>,
)
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}$") 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. */ /** Keeps copied approval commands single-argument and safe for a gateway host shell. */
@@ -214,8 +223,26 @@ class GatewaySession(
data class ErrorShape( data class ErrorShape(
val code: String, val code: String,
val message: 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. * Structured RPC result used by callers that need error codes without exceptions.
@@ -1346,7 +1373,7 @@ class GatewaySession(
val detailObj = wireError.details.asObjectOrNull() val detailObj = wireError.details.asObjectOrNull()
val details = val details =
detailObj?.let { detailObj?.let {
GatewayConnectErrorDetails( GatewayErrorDetails(
code = it["code"].asStringOrNull(), code = it["code"].asStringOrNull(),
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true, canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(), recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
@@ -1361,6 +1388,12 @@ class GatewaySession(
clawhubTrustCode = it["clawhubTrustCode"].asStringOrNull(), clawhubTrustCode = it["clawhubTrustCode"].asStringOrNull(),
clawhubWarning = it["warning"].asStringOrNull(), clawhubWarning = it["warning"].asStringOrNull(),
clawhubVersion = it["version"].asStringOrNull(), clawhubVersion = it["version"].asStringOrNull(),
missingScope = it["missingScope"].asStringOrNull(),
requiredScopes =
it["requiredScopes"]
.asArrayOrNull()
?.mapNotNull { scope -> scope.asStringOrNull() }
.orEmpty(),
) )
} }
ErrorShape(wireError.code, wireError.message, details) ErrorShape(wireError.code, wireError.message, details)
@@ -1,6 +1,6 @@
package ai.openclaw.app 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.gateway.GatewaySession
import ai.openclaw.app.ui.cronWakeModeLabel import ai.openclaw.app.ui.cronWakeModeLabel
import ai.openclaw.app.ui.cronWakeModeOptions import ai.openclaw.app.ui.cronWakeModeOptions
@@ -264,7 +264,7 @@ class CronJobManagementTest {
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "changed", message = "changed",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "CRON_JOB_CHANGED", code = "CRON_JOB_CHANGED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -2,9 +2,9 @@ package ai.openclaw.app
import ai.openclaw.app.gateway.DeviceAuthStore import ai.openclaw.app.gateway.DeviceAuthStore
import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewayConnectErrorDetails
import ai.openclaw.app.gateway.GatewayConnectOptions import ai.openclaw.app.gateway.GatewayConnectOptions
import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayErrorDetails
import ai.openclaw.app.gateway.GatewayRegistryEntry import ai.openclaw.app.gateway.GatewayRegistryEntry
import ai.openclaw.app.gateway.GatewayRegistryEntryKind import ai.openclaw.app.gateway.GatewayRegistryEntryKind
import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.GatewaySession
@@ -91,7 +91,7 @@ class GatewayBootstrapAuthTest {
code = "UNAUTHORIZED", code = "UNAUTHORIZED",
message = "unauthorized", message = "unauthorized",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "AUTH_TOKEN_MISSING", code = "AUTH_TOKEN_MISSING",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = "provide_token", recommendedNextStep = "provide_token",
@@ -129,7 +129,7 @@ class GatewayBootstrapAuthTest {
code = "NOT_PAIRED", code = "NOT_PAIRED",
message = "pairing required", message = "pairing required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "PAIRING_REQUIRED", code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry", recommendedNextStep = "wait_then_retry",
@@ -1,7 +1,7 @@
package ai.openclaw.app package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayConnectErrorDetails
import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayErrorDetails
import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown
import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.GatewaySession
@@ -1309,8 +1309,8 @@ class GatewayExecApprovalRuntimeTest {
} }
""".trimIndent() """.trimIndent()
private fun gatewayErrorDetails(reason: String): GatewayConnectErrorDetails = private fun gatewayErrorDetails(reason: String): GatewayErrorDetails =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -1,6 +1,6 @@
package ai.openclaw.app 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.gateway.GatewaySession
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -29,7 +29,7 @@ class GatewayNodeApprovalStateTest {
code = "NOT_PAIRED", code = "NOT_PAIRED",
message = "pairing required", message = "pairing required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "PAIRING_REQUIRED", code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry", recommendedNextStep = "wait_then_retry",
@@ -45,7 +45,7 @@ class GatewayNodeApprovalStateTest {
code = "UNAUTHORIZED", code = "UNAUTHORIZED",
message = "token mismatch", message = "token mismatch",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "AUTH_TOKEN_MISMATCH", code = "AUTH_TOKEN_MISMATCH",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -1,6 +1,6 @@
package ai.openclaw.app 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.gateway.GatewaySession
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.boolean import kotlinx.serialization.json.boolean
@@ -102,7 +102,7 @@ class SkillManagementTest {
code = "UNAVAILABLE", code = "UNAVAILABLE",
message = "review required", message = "review required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -127,7 +127,7 @@ class SkillManagementTest {
code = "UNAVAILABLE", code = "UNAVAILABLE",
message = "review required", message = "review required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -152,7 +152,7 @@ class SkillManagementTest {
code = "UNAVAILABLE", code = "UNAVAILABLE",
message = "download blocked", message = "download blocked",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -1,6 +1,6 @@
package ai.openclaw.app.chat 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.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.Question import ai.openclaw.app.gateway.Question
@@ -153,6 +153,43 @@ class ChatQuestionTest {
assertEquals(listOf("ask_new"), controller.questions.value.map { it.record.id }) 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 @Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun pendingRefreshPreservesSubmissionLock() = fun pendingRefreshPreservesSubmissionLock() =
@@ -775,7 +812,7 @@ class ChatQuestionTest {
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "question not found", message = "question not found",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
reason = "QUESTION_NOT_FOUND", reason = "QUESTION_NOT_FOUND",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
@@ -963,7 +1000,7 @@ class ChatQuestionTest {
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "question not found", message = "question not found",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
reason = "QUESTION_NOT_FOUND", reason = "QUESTION_NOT_FOUND",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
@@ -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(),
)
}
}
@@ -658,7 +658,7 @@ class GatewaySessionReconnectTest {
code = "NOT_PAIRED", code = "NOT_PAIRED",
message = "pairing required", message = "pairing required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "PAIRING_REQUIRED", code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry", recommendedNextStep = "wait_then_retry",
@@ -685,7 +685,7 @@ class GatewaySessionReconnectTest {
code = "NOT_PAIRED", code = "NOT_PAIRED",
message = "pairing required", message = "pairing required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "PAIRING_REQUIRED", code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -711,7 +711,7 @@ class GatewaySessionReconnectTest {
code = "NOT_PAIRED", code = "NOT_PAIRED",
message = "pairing required", message = "pairing required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "PAIRING_REQUIRED", code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry", recommendedNextStep = "wait_then_retry",
@@ -748,7 +748,7 @@ class GatewaySessionReconnectTest {
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "authentication failed", message = "authentication failed",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = code, code = code,
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -785,7 +785,7 @@ class GatewaySessionReconnectTest {
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "authentication failed", message = "authentication failed",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "AUTH_UNAUTHORIZED", code = "AUTH_UNAUTHORIZED",
canRetryWithDeviceToken = nextStep == "retry_with_device_token", canRetryWithDeviceToken = nextStep == "retry_with_device_token",
recommendedNextStep = nextStep, recommendedNextStep = nextStep,
@@ -811,7 +811,7 @@ class GatewaySessionReconnectTest {
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "authentication rate limited", message = "authentication rate limited",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "AUTH_RATE_LIMITED", code = "AUTH_RATE_LIMITED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry", recommendedNextStep = "wait_then_retry",
@@ -836,7 +836,7 @@ class GatewaySessionReconnectTest {
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "protocol mismatch", message = "protocol mismatch",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "PROTOCOL_MISMATCH", code = "PROTOCOL_MISMATCH",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -865,7 +865,7 @@ class GatewaySessionReconnectTest {
code = "NOT_PAIRED", code = "NOT_PAIRED",
message = "pairing required", message = "pairing required",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = "PAIRING_REQUIRED", code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -985,6 +985,41 @@ class GatewaySessionReconnectTest {
} }
} }
@Test
fun methodFailurePreservesMissingScopeDetails() =
runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
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( private fun createReconnectHarness(
onConnected: () -> Unit = {}, onConnected: () -> Unit = {},
onHello: (GatewayHelloSummary) -> Unit = {}, onHello: (GatewayHelloSummary) -> Unit = {},
@@ -1,6 +1,6 @@
package ai.openclaw.app.voice package ai.openclaw.app.voice
import ai.openclaw.app.gateway.GatewayConnectErrorDetails import ai.openclaw.app.gateway.GatewayErrorDetails
import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -64,7 +64,7 @@ class TalkSpeakClientTest {
code = "UNAVAILABLE", code = "UNAVAILABLE",
message = "talk unavailable", message = "talk unavailable",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
@@ -93,7 +93,7 @@ class TalkSpeakClientTest {
code = "UNAVAILABLE", code = "UNAVAILABLE",
message = "provider failed", message = "provider failed",
details = details =
GatewayConnectErrorDetails( GatewayErrorDetails(
code = null, code = null,
canRetryWithDeviceToken = false, canRetryWithDeviceToken = false,
recommendedNextStep = null, recommendedNextStep = null,
+10 -12
View File
@@ -2077,12 +2077,11 @@ final class NodeAppModel {
} }
return decoded.ok ?? false return decoded.ok ?? false
} catch { } catch {
if let gatewayError = error as? GatewayResponseError { if let gatewayError = error as? GatewayResponseError,
let lower = gatewayError.message.lowercased() gatewayError.isAuthorizationFailure
if lower.contains("unauthorized role") || lower.contains("missing scope") { {
await self.setGatewayHealthMonitorDisabled(true) await self.setGatewayHealthMonitorDisabled(true)
return true return true
}
} }
return false return false
} }
@@ -10160,12 +10159,11 @@ extension NodeAppModel {
VoiceWakePreferences.saveTriggerWords(triggers) VoiceWakePreferences.saveTriggerWords(triggers)
} catch { } catch {
guard shouldApply() else { return } guard shouldApply() else { return }
if let gatewayError = error as? GatewayResponseError { if let gatewayError = error as? GatewayResponseError,
let lower = gatewayError.message.lowercased() gatewayError.isAuthorizationFailure
if lower.contains("unauthorized role") || lower.contains("missing scope") { {
self.setGatewayHealthMonitorDisabled(true) self.setGatewayHealthMonitorDisabled(true)
return return
}
} }
// Best-effort only. // Best-effort only.
} }
+1 -8
View File
@@ -4273,14 +4273,7 @@ extension TalkModeManager {
private static func missingTalkScope(from error: Error) -> String? { private static func missingTalkScope(from error: Error) -> String? {
let targetScope = "operator.talk.secrets" let targetScope = "operator.talk.secrets"
if let gatewayError = error as? GatewayResponseError { if let gatewayError = error as? GatewayResponseError {
if Self.errorTextIndicatesMissingScope(gatewayError.message, scope: targetScope) { return gatewayError.missingScope == targetScope ? targetScope : nil
return targetScope
}
if let missingScope = gatewayError.details["missingScope"]?.value as? String,
missingScope == targetScope
{
return targetScope
}
} }
if Self.errorTextIndicatesMissingScope(error.localizedDescription, scope: targetScope) { if Self.errorTextIndicatesMissingScope(error.localizedDescription, scope: targetScope) {
return targetScope return targetScope
@@ -675,9 +675,8 @@ extension OpenClawChatViewModel {
} }
private nonisolated static func questionListIsUnavailable(_ error: GatewayResponseError) -> Bool { private nonisolated static func questionListIsUnavailable(_ error: GatewayResponseError) -> Bool {
guard error.code == "INVALID_REQUEST" else { return false } if error.missingScope == "operator.questions" { return true }
return error.message == "unknown method: question.list" || return error.code == "INVALID_REQUEST" && error.message == "unknown method: question.list"
error.message == "missing scope: operator.questions"
} }
private nonisolated static func questionIsNotFound(_ error: GatewayResponseError) -> Bool { private nonisolated static func questionIsNotFound(_ error: GatewayResponseError) -> Bool {
@@ -195,6 +195,16 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
} }
/// Structured error surfaced when the gateway responds with `{ ok: false }`. /// 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 struct GatewayResponseError: LocalizedError, @unchecked Sendable {
public let method: String public let method: String
public let code: String public let code: String
@@ -218,6 +228,40 @@ public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
return trimmed.isEmpty ? nil : trimmed 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? { public var errorDescription: String? {
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
return "\(self.method): [\(self.code)] \(self.message)" return "\(self.method): [\(self.code)] \(self.message)"
@@ -1392,6 +1392,28 @@ struct ChatViewModelTests {
#expect(viewModel.questionCards.isEmpty) #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 { @Test @MainActor func `transient question list rejection preserves event cards`() async {
let transport = TestChatTransport( let transport = TestChatTransport(
historyResponses: [], historyResponses: [],
@@ -3,6 +3,41 @@ import OpenClawKit
import Testing import Testing
struct GatewayErrorsTests { 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`() { @Test func `bootstrap token invalid is non recoverable`() {
let error = GatewayConnectAuthError( let error = GatewayConnectAuthError(
message: "setup code expired", message: "setup code expired",
@@ -122,7 +122,7 @@ b013053a61e7d9be3d0c683c02baf57fa7e4393ec54e0df6a46ab0f2fe2348fd module/extensi
e2d4a336857b4bdc98137162d16a683d8aa39b46028b74deb731b9f939efc62b module/file-access-runtime e2d4a336857b4bdc98137162d16a683d8aa39b46028b74deb731b9f939efc62b module/file-access-runtime
d13708bc224297df10a7f91e244f87e822fef9d71e2ca799442a49e42b6868c5 module/file-lock d13708bc224297df10a7f91e244f87e822fef9d71e2ca799442a49e42b6868c5 module/file-lock
ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway-method-runtime ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway-method-runtime
ce90af5578795d6e1cd2e9e3db5018071e57e0b21d67dfe8b8e847c0dfec5bcc module/gateway-runtime 1b8eea4bad785031864822f0f07dc03cbe8670e76e0c3ee8fa51c7a2ee5c5e15 module/gateway-runtime
4611de9835515711d641f0f3e48d3c4e528afc343d5e5934fe3ed62072955511 module/global-singleton 4611de9835515711d641f0f3e48d3c4e528afc343d5e5934fe3ed62072955511 module/global-singleton
6062869202e3d7fdaa1f6801d6c1df7bf7406b8118cc4c7167ed409f5257b05c module/group-access 6062869202e3d7fdaa1f6801d6c1df7bf7406b8118cc4c7167ed409f5257b05c module/group-access
5e672df8c679d90e2e61b30d08cc9de8f1a1cac439e7020869b088ee9ab4e5ce module/group-activation 5e672df8c679d90e2e61b30d08cc9de8f1a1cac439e7020869b088ee9ab4e5ce module/group-activation
+17 -6
View File
@@ -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.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.pairing` | Device and node pairing management: list, approve, reject, remove, rotate, revoke. |
| `operator.approvals` | Exec and plugin approval APIs. | | `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. | | `operator.talk.secrets` | Reading Talk configuration with secrets included. |
Unknown future `operator.*` scopes require an exact match unless the caller 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 ## Method scope is only the first gate
Each Gateway RPC has a least-privilege method scope that decides whether a 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 request reaches its handler. Params-aware methods derive that scope before
the concrete thing being approved or mutated: 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 - `device.pair.approve` is reachable with `operator.pairing`, but approving an
operator device can only mint or preserve scopes the caller already holds. 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.admin`, even though `device.pair.approve` itself only needs
`operator.pairing`. `operator.pairing`.
- A request for `operator.read`, `operator.write`, `operator.approvals`, - A request for `operator.read`, `operator.write`, `operator.approvals`,
`operator.pairing`, or `operator.talk.secrets` requires the caller to already `operator.questions`, `operator.pairing`, or `operator.talk.secrets` requires
hold that scope, or `operator.admin`. the caller to already hold that scope, or `operator.admin`.
- A request for `operator.admin` requires `operator.admin`. - A request for `operator.admin` requires `operator.admin`.
- A repair request with no explicit scopes can inherit the existing operator - A repair request with no explicit scopes can inherit the existing operator
token's scopes; if that token is admin-scoped, approval still requires 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 runtime allowlist gate. For example, approving a node that declares
`computer.act` requires pairing plus write scope, but only records the surface. `computer.act` requires pairing plus write scope, but only records the surface.
An administrator or owner must still arm `computer.act`. While it remains An administrator or owner must still arm `computer.act`. While it remains
armed, invoking it through the write-scoped `node.invoke` method does not armed, invoking it through `node.invoke` requires write scope, but not admin
require admin scope for each action. scope for each action.
Node pairing establishes identity and trust; it does not replace a node's own Node pairing establishes identity and trust; it does not replace a node's own
`system.run` exec approval policy. `system.run` exec approval policy.
+5
View File
@@ -40,6 +40,11 @@ missing-scope details:
`requiredScopes` is the complete known scope set for the requested operation. `requiredScopes` is the complete known scope set for the requested operation.
The legacy `missing scope: <scope>` message is retained for older clients. The legacy `missing scope: <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`, The schemas are exported as `GatewayErrorDetailsSchema`,
`MissingScopeErrorDetailsSchema` from `@openclaw/gateway-protocol/schema`. `MissingScopeErrorDetailsSchema` from `@openclaw/gateway-protocol/schema`.
HTTP scope failures mirror the `MISSING_SCOPE` object under `error.details` and HTTP scope failures mirror the `MISSING_SCOPE` object under `error.details` and
+1
View File
@@ -8,6 +8,7 @@ export * from "./reconnect-policy.js";
export { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS } from "./timeouts.js"; export { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS } from "./timeouts.js";
export * from "@openclaw/gateway-protocol/client-info"; export * from "@openclaw/gateway-protocol/client-info";
export * from "@openclaw/gateway-protocol/connect-error-details"; 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/startup-unavailable";
export * from "@openclaw/gateway-protocol/version"; export * from "@openclaw/gateway-protocol/version";
export type { ConnectParams, ErrorShape, EventFrame, HelloOk } from "@openclaw/gateway-protocol"; export type { ConnectParams, ErrorShape, EventFrame, HelloOk } from "@openclaw/gateway-protocol";
+6 -1
View File
@@ -29,6 +29,11 @@
"import": "./dist/frame-guards.mjs", "import": "./dist/frame-guards.mjs",
"default": "./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": { "./schema": {
"types": "./dist/schema.d.mts", "types": "./dist/schema.d.mts",
"import": "./dist/schema.mjs", "import": "./dist/schema.mjs",
@@ -46,7 +51,7 @@
} }
}, },
"scripts": { "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": { "dependencies": {
"typebox": "1.3.3" "typebox": "1.3.3"
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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;
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./clawhub-trust-error-details.js"; export * from "./clawhub-trust-error-details.js";
export { readMissingScopeError, readMissingScopeErrorDetails } from "./gateway-error-details.js";
export * from "./session-icon.js"; export * from "./session-icon.js";
export * from "./terminal-validators.js"; export * from "./terminal-validators.js";
export { export {
@@ -6,6 +6,8 @@ import {
GatewayErrorDetailsSchema, GatewayErrorDetailsSchema,
MissingScopeErrorDetailsSchema, MissingScopeErrorDetailsSchema,
missingScopeErrorShape, missingScopeErrorShape,
readMissingScopeError,
readMissingScopeErrorDetails,
} from "./error-codes.js"; } from "./error-codes.js";
describe("gateway error details", () => { describe("gateway error details", () => {
@@ -49,4 +51,58 @@ describe("gateway error details", () => {
requiredScopes: ["operator.admin"], 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();
});
}); });
@@ -1,35 +1,24 @@
// Gateway Protocol schema module defines protocol validation shapes. // Gateway Protocol schema module defines protocol validation shapes.
import type { Static } from "typebox";
import { Type } 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 { closedObject } from "./closed-object.js";
import type { ErrorShape } from "./frames.js"; import type { ErrorShape } from "./frames.js";
import { NonEmptyString } from "./primitives.js"; import { NonEmptyString } from "./primitives.js";
/** Gateway JSON-RPC style error codes shared by clients and server handlers. */ export {
export const ErrorCodes = { ErrorCodes,
/** Client has not completed account/device linking for this gateway. */ GatewayErrorDetailCodes,
NOT_LINKED: "NOT_LINKED", type ErrorCode,
/** Device exists but still needs an explicit pairing approval. */ type GatewayErrorDetails,
NOT_PAIRED: "NOT_PAIRED", type MissingScopeErrorDetails,
/** Agent turn exceeded the gateway wait window. */ readMissingScopeError,
AGENT_TIMEOUT: "AGENT_TIMEOUT", readMissingScopeErrorDetails,
/** Request payload failed protocol validation or method preconditions. */ } from "../gateway-error-details.js";
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. */ /** Missing operator-scope details shared by WebSocket and HTTP responses. */
export const MissingScopeErrorDetailsSchema = closedObject({ export const MissingScopeErrorDetailsSchema = closedObject({
@@ -41,9 +30,6 @@ export const MissingScopeErrorDetailsSchema = closedObject({
/** Structured details emitted by method-level authorization failures. */ /** Structured details emitted by method-level authorization failures. */
export const GatewayErrorDetailsSchema = MissingScopeErrorDetailsSchema; export const GatewayErrorDetailsSchema = MissingScopeErrorDetailsSchema;
export type MissingScopeErrorDetails = Static<typeof MissingScopeErrorDetailsSchema>;
export type GatewayErrorDetails = Static<typeof GatewayErrorDetailsSchema>;
/** Builds the canonical gateway error payload while preserving optional retry metadata. */ /** Builds the canonical gateway error payload while preserving optional retry metadata. */
export function errorShape( export function errorShape(
code: ErrorCode, code: ErrorCode,
+4 -6
View File
@@ -236,14 +236,12 @@ export const handleToolsCommand: CommandHandler = async (params, allowTextComman
shouldContinue: false, shouldContinue: false,
reply: { text: buildToolsMessage(result, { verbose }) }, reply: { text: buildToolsMessage(result, { verbose }) },
}; };
} catch (err) { } catch {
const message = String(err); // Inventory resolves in-process after sender authorization; this path cannot receive
const text = message.includes("missing scope:") // gateway RPC scope errors, so failures here are local discovery failures.
? "You do not have permission to view available tools."
: "Couldn't load available tools right now. Try again in a moment.";
return { return {
shouldContinue: false, shouldContinue: false,
reply: { text }, reply: { text: "Couldn't load available tools right now. Try again in a moment." },
}; };
} }
}; };
+8 -19
View File
@@ -2,6 +2,8 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander"; 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 type { OperatorScope } from "../../gateway/method-scopes.js";
import { import {
parseStrictFiniteNumber, parseStrictFiniteNumber,
@@ -32,18 +34,6 @@ const STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES = new Set([
"PAIRING_REQUIRED", "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 { function isDiagnosticsAuthFallbackError(value: unknown): value is Error {
if ( if (
value instanceof Error && value instanceof Error &&
@@ -53,16 +43,15 @@ function isDiagnosticsAuthFallbackError(value: unknown): value is Error {
) { ) {
return true; 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)) { if (detailCode !== null && STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES.has(detailCode)) {
return true; return true;
} }
return ( return readMissingScopeError(value)?.missingScope === "operator.read";
value instanceof Error &&
value.name === "GatewayClientRequestError" &&
(value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" &&
value.message.includes("missing scope: operator.read")
);
} }
function isUnknownGatewayMethodError(value: unknown, method: string): value is Error { function isUnknownGatewayMethodError(value: unknown, method: string): value is Error {
+7 -2
View File
@@ -728,9 +728,14 @@ describe("cli program (nodes basics)", () => {
useStoredDeviceAuth?: boolean; useStoredDeviceAuth?: boolean;
}; };
if (opts.method === "node.list" && opts.useStoredDeviceAuth) { 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", 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")) { if (opts.method === "node.list" && opts.scopes?.includes("operator.pairing")) {
@@ -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", () => { it("treats post-connect read failures as reachable with failed diagnostics", () => {
const probe = { const probe = {
ok: false, ok: false,
+5 -2
View File
@@ -11,7 +11,7 @@ import type { GatewayProbeCapability, GatewayProbeResult } from "../../gateway/p
import { inspectBestEffortPrimaryTailnetIPv4 } from "../../infra/network-discovery-display.js"; import { inspectBestEffortPrimaryTailnetIPv4 } from "../../infra/network-discovery-display.js";
import { parseStrictInteger } from "../../infra/parse-finite-number.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"; type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
@@ -296,7 +296,10 @@ export function isScopeLimitedProbeFailure(probe: GatewayProbeResult): boolean {
if (probe.ok || probe.connectLatencyMs == null) { if (probe.ok || probe.connectLatencyMs == null) {
return false; 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. */ /** Returns true when the gateway connection was established but a later probe failed. */
+7
View File
@@ -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);
}
+54
View File
@@ -117,6 +117,60 @@ describe("method scope resolution", () => {
expect(resolveLeastPrivilegeOperatorScopesForMethod("node.pending.drain")).toStrictEqual([]); 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", () => { it("classifies plugin session actions with a CLI-safe default operator scope", () => {
expect(resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction")).toEqual([ expect(resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction")).toEqual([
"operator.write", "operator.write",
+48 -32
View File
@@ -1,14 +1,17 @@
// Gateway method authorization scope resolver. // Gateway method authorization scope resolver.
// Maps static and plugin-defined gateway methods to operator scopes. // Maps static and plugin-defined gateway methods to operator scopes.
import { normalizeOptionalString as normalizeSessionActionParam } from "@openclaw/normalization-core/string-coerce"; 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 { getPluginRegistryState } from "../plugins/runtime-state.js";
import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js"; import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js";
import { isAgentSessionResetCommand } from "./agent-command-policy.js";
import { import {
isCoreGatewayMethodClassified, isCoreGatewayMethodClassified,
isCoreNodeGatewayMethod, isCoreNodeGatewayMethod,
isDynamicOperatorGatewayMethod, isDynamicOperatorGatewayMethod,
resolveCoreOperatorGatewayMethodScope, resolveCoreOperatorGatewayMethodScope,
} from "./methods/core-descriptors.js"; } from "./methods/core-descriptors.js";
import { isForbiddenBrowserProxyMutation } from "./node-browser-proxy-policy.js";
import { import {
ADMIN_SCOPE, ADMIN_SCOPE,
APPROVALS_SCOPE, APPROVALS_SCOPE,
@@ -161,6 +164,33 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod(
if (method === "plugins.sessionAction") { if (method === "plugins.sessionAction") {
return resolveSessionActionLeastPrivilegeScopes(params); 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") { if (method === "sessions.patch") {
return resolveSessionsPatchRequiredScopes(params); return resolveSessionsPatchRequiredScopes(params);
} }
@@ -237,40 +267,26 @@ export function authorizeOperatorScopesForMethod(
return { allowed: true }; return { allowed: true };
} }
if (isDynamicOperatorGatewayMethod(method)) { if (isDynamicOperatorGatewayMethod(method)) {
if (method === "sessions.create") { if (method === "plugins.sessionAction") {
const missingScope = findMissingOperatorScope( const registeredScopes = resolveSessionActionRegisteredScopes(params);
resolveSessionsCreateRequiredScopes(params), if (!registeredScopes && params && typeof params === "object" && !Array.isArray(params)) {
scopes, const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId);
); const actionId = normalizeSessionActionParam((params as { actionId?: unknown }).actionId);
return missingScope ? { allowed: false, missingScope } : { allowed: true }; if (!pluginId || !actionId) {
} // Malformed dynamic params cannot be matched to a plugin action. Any valid operator scope
if (method === "sessions.patch") { // may proceed so the handler can return the precise validation error.
const missingScope = findMissingOperatorScope( return scopes.some((scope) => isOperatorScope(scope))
resolveSessionsPatchRequiredScopes(params), ? { allowed: true }
scopes, : { allowed: false, missingScope: WRITE_SCOPE };
); }
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 };
} }
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 }; return missingScope ? { allowed: false, missingScope } : { allowed: true };
} }
const requiredScope = resolveRequiredOperatorScopeForMethod(method) ?? ADMIN_SCOPE; const requiredScope = resolveRequiredOperatorScopeForMethod(method) ?? ADMIN_SCOPE;
+6 -3
View File
@@ -84,7 +84,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "wizard.cancel", scope: "operator.admin" }, { name: "wizard.cancel", scope: "operator.admin" },
{ name: "wizard.status", scope: "operator.admin" }, { name: "wizard.status", scope: "operator.admin" },
{ name: "talk.catalog", scope: "operator.read" }, { 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.create", scope: "operator.write" },
{ name: "talk.client.toolCall", scope: "operator.write" }, { name: "talk.client.toolCall", scope: "operator.write" },
{ name: "talk.client.steer", 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.skills.update", scope: "node" },
{ name: "node.pending.drain", scope: "node" }, { name: "node.pending.drain", scope: "node" },
{ name: "node.pending.enqueue", scope: "operator.write" }, { 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.pull", scope: "node" },
{ name: "node.pending.ack", scope: "node" }, { name: "node.pending.ack", scope: "node" },
{ name: "node.invoke.progress", 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", scope: "operator.admin" },
{ name: "conversations.turn.cancel", scope: "operator.admin" }, { name: "conversations.turn.cancel", scope: "operator.admin" },
{ name: "send", scope: "operator.write" }, { 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.identity.get", scope: "operator.read" },
{ name: "agent.wait", scope: "operator.write", startup: true }, { name: "agent.wait", scope: "operator.write", startup: true },
{ name: "chat.history", scope: "operator.read", startup: true }, { name: "chat.history", scope: "operator.read", startup: true },
+38 -1
View File
@@ -24,6 +24,7 @@ const gatewayClientState = vi.hoisted(() => ({
reason: "scope-upgrade", reason: "scope-upgrade",
requestId: "req-123", requestId: "req-123",
} as Record<string, unknown> | null, } as Record<string, unknown> | null,
requestError: null as { code: string; message: string; details?: unknown } | null,
stopCalls: 0, stopCalls: 0,
stopAndWaitCalls: [] as Array<{ timeoutMs?: number } | undefined>, stopAndWaitCalls: [] as Array<{ timeoutMs?: number } | undefined>,
stopAndWaitMode: "resolve" as "resolve" | "defer" | "reject", stopAndWaitMode: "resolve" as "resolve" | "defer" | "reject",
@@ -55,10 +56,15 @@ const eventLoopReadyState = vi.hoisted(() => ({
})); }));
class MockGatewayClientRequestError extends Error { class MockGatewayClientRequestError extends Error {
readonly code: string;
readonly gatewayCode: string;
readonly details?: unknown; readonly details?: unknown;
constructor(error: { message?: string; details?: unknown }) { constructor(error: { code?: string; message?: string; details?: unknown }) {
super(error.message ?? "gateway request failed"); super(error.message ?? "gateway request failed");
this.name = "GatewayClientRequestError";
this.code = error.code ?? "UNAVAILABLE";
this.gatewayCode = this.code;
this.details = error.details; this.details = error.details;
} }
} }
@@ -143,6 +149,9 @@ class MockGatewayClient {
async request(method: string): Promise<unknown> { async request(method: string): Promise<unknown> {
gatewayClientState.requests.push(method); gatewayClientState.requests.push(method);
if (gatewayClientState.requestError) {
throw new MockGatewayClientRequestError(gatewayClientState.requestError);
}
if (method === "system-presence") { if (method === "system-presence") {
return []; return [];
} }
@@ -303,6 +312,7 @@ describe("probeGateway", () => {
reason: "scope-upgrade", reason: "scope-upgrade",
requestId: "req-123", requestId: "req-123",
}; };
gatewayClientState.requestError = null;
gatewayClientState.stopCalls = 0; gatewayClientState.stopCalls = 0;
gatewayClientState.stopAndWaitCalls = []; gatewayClientState.stopAndWaitCalls = [];
gatewayClientState.stopAndWaitMode = "resolve"; 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 () => { it("loads probe identity and cached device auth from the provided env", async () => {
const env = { const env = {
...process.env, ...process.env,
+11
View File
@@ -5,6 +5,10 @@ import {
GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js"; } 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 { loadDeviceAuthToken } from "../infra/device-auth-store.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import type { SystemPresence } from "../infra/system-presence.js"; import type { SystemPresence } from "../infra/system-presence.js";
@@ -49,6 +53,7 @@ export type GatewayProbeResult = {
connectLatencyMs: number | null; connectLatencyMs: number | null;
error: string | null; error: string | null;
connectErrorDetails?: unknown; connectErrorDetails?: unknown;
missingScopeErrorDetails?: MissingScopeErrorDetails;
close: GatewayProbeClose | null; close: GatewayProbeClose | null;
auth: GatewayProbeAuthSummary; auth: GatewayProbeAuthSummary;
server?: GatewayProbeServerSummary; server?: GatewayProbeServerSummary;
@@ -330,6 +335,7 @@ export async function probeGateway(opts: {
const settleProbe = (params: { const settleProbe = (params: {
ok: boolean; ok: boolean;
error: string | null; error: string | null;
missingScopeErrorDetails?: MissingScopeErrorDetails;
verifiedRead?: boolean; verifiedRead?: boolean;
health: unknown; health: unknown;
status: unknown; status: unknown;
@@ -340,6 +346,9 @@ export async function probeGateway(opts: {
ok: params.ok, ok: params.ok,
connectLatencyMs, connectLatencyMs,
error: params.error, error: params.error,
...(params.missingScopeErrorDetails
? { missingScopeErrorDetails: params.missingScopeErrorDetails }
: {}),
connectErrorDetails, connectErrorDetails,
close, close,
auth: resolveProbeAuthSummary({ auth: resolveProbeAuthSummary({
@@ -477,9 +486,11 @@ export async function probeGateway(opts: {
}); });
} catch (err) { } catch (err) {
const error = formatErrorMessage(err); const error = formatErrorMessage(err);
const missingScopeErrorDetails = readMissingScopeError(err);
settleProbe({ settleProbe({
ok: false, ok: false,
error, error,
...(missingScopeErrorDetails ? { missingScopeErrorDetails } : {}),
health: null, health: null,
status: null, status: null,
presence: null, presence: null,
@@ -28,7 +28,6 @@ import {
import { loadSessionEntry, resolveDeletedAgentIdFromSessionKey } from "../session-utils.js"; import { loadSessionEntry, resolveDeletedAgentIdFromSessionKey } from "../session-utils.js";
import type { GatewayRequestHandlerOptions } from "./types.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 const CRON_CONTINUATION_RELEASE_RECOVERY_DELAYS_MS = [250, 1_000, 4_000, 15_000] as const;
export type RestoredCronContinuation = { export type RestoredCronContinuation = {
@@ -2,13 +2,18 @@ import {
normalizeOptionalLowercaseString, normalizeOptionalLowercaseString,
normalizeOptionalString, normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce"; } 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 type { OpenClawConfig } from "../../config/types.openclaw.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.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 { ADMIN_SCOPE } from "../method-scopes.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import { setGatewayDedupeEntries } from "./agent-dedupe.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 type { AgentRunRequest } from "./agent-request-types.js";
import { import {
buildBareSessionResetResponse, buildBareSessionResetResponse,
@@ -60,7 +65,7 @@ export async function runAgentResetPhase(params: {
effectiveTranscriptInputText: params.effectiveTranscriptInputText, effectiveTranscriptInputText: params.effectiveTranscriptInputText,
message: params.message, 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) { if (!resetCommandMatch || !params.requestedSessionKey) {
return { ...base, stop: false, accepted: false }; return { ...base, stop: false, accepted: false };
} }
@@ -77,7 +82,7 @@ export async function runAgentResetPhase(params: {
params.respond( params.respond(
false, false,
undefined, undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${ADMIN_SCOPE}`), missingScopeErrorShape({ missingScope: ADMIN_SCOPE, requiredScopes: [ADMIN_SCOPE] }),
); );
return { ...base, stop: true, accepted: false }; return { ...base, stop: true, accepted: false };
} }
@@ -703,7 +703,15 @@ describe("node.invoke APNs wake path", () => {
const call = firstRespondCall(respond); const call = firstRespondCall(respond);
expect(call[0]).toBe(false); 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(); expect(nodeRegistry.invoke).not.toHaveBeenCalled();
}); });
+4 -5
View File
@@ -34,7 +34,7 @@ import {
removePairedDeviceRole, removePairedDeviceRole,
} from "../../infra/device-pairing.js"; } from "../../infra/device-pairing.js";
import { formatErrorMessage } from "../../infra/errors.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 { import {
approveNodePairing, approveNodePairing,
getPendingNodePairing, getPendingNodePairing,
@@ -58,6 +58,7 @@ import {
refreshRemoteNodeBins, refreshRemoteNodeBins,
removeRemoteNodeInfo, removeRemoteNodeInfo,
} from "../../skills/runtime/remote.js"; } from "../../skills/runtime/remote.js";
import { isForbiddenBrowserProxyMutation } from "../node-browser-proxy-policy.js";
import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js"; import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js";
import { import {
isForegroundRestrictedPluginNodeCommand, isForegroundRestrictedPluginNodeCommand,
@@ -84,7 +85,6 @@ import {
type DeviceManagementAuthz, type DeviceManagementAuthz,
} from "./device-management-authz.js"; } from "./device-management-authz.js";
import { emitDeviceManagementSecurityEvent } from "./device-management-security.js"; import { emitDeviceManagementSecurityEvent } from "./device-management-security.js";
import { isForbiddenBrowserProxyMutation } from "./node-browser-proxy.js";
import { buildNodeCommandRejectionHint } from "./node-command-rejection-hint.js"; import { buildNodeCommandRejectionHint } from "./node-command-rejection-hint.js";
import { nodeInvokePolicy } from "./nodes-policy.js"; import { nodeInvokePolicy } from "./nodes-policy.js";
import { import {
@@ -118,7 +118,6 @@ const TALK_PTT_COMMANDS = new Set([
"talk.ptt.cancel", "talk.ptt.cancel",
"talk.ptt.once", "talk.ptt.once",
]); ]);
const ADMIN_ONLY_NODE_INVOKE_COMMANDS = new Set<string>(NODE_ADMIN_ONLY_INVOKE_COMMANDS);
const talkPttEventSeqBySessionId = new Map<string, number>(); const talkPttEventSeqBySessionId = new Map<string, number>();
type NodeWakeNudgeAttempt = { type NodeWakeNudgeAttempt = {
@@ -1451,13 +1450,13 @@ export const nodeHandlers: GatewayRequestHandlers = {
return; return;
} }
if ( if (
ADMIN_ONLY_NODE_INVOKE_COMMANDS.has(command) && isAdminOnlyNodeInvokeCommand(command) &&
!nodeInvokePolicy.clientHasOperatorAdminScope(client) !nodeInvokePolicy.clientHasOperatorAdminScope(client)
) { ) {
respond( respond(
false, false,
undefined, undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${ADMIN_SCOPE}`), missingScopeErrorShape({ missingScope: ADMIN_SCOPE, requiredScopes: [ADMIN_SCOPE] }),
); );
return; return;
} }
+6 -2
View File
@@ -9,6 +9,7 @@ import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
missingScopeErrorShape,
type TalkSpeakParams, type TalkSpeakParams,
validateTalkCatalogParams, validateTalkCatalogParams,
validateTalkConfigParams, validateTalkConfigParams,
@@ -51,7 +52,7 @@ import {
synthesizeSpeech, synthesizeSpeech,
type TtsDirectiveOverrides, type TtsDirectiveOverrides,
} from "../../tts/tts.js"; } 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 { resolveConfiguredSecretInputString } from "../resolve-configured-secret-input-string.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import { inferSpeechMimeType } from "./speech-mime.js"; import { inferSpeechMimeType } from "./speech-mime.js";
@@ -727,7 +728,10 @@ export const talkHandlers: GatewayRequestHandlers = {
respond( respond(
false, false,
undefined, undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${TALK_SECRETS_SCOPE}`), missingScopeErrorShape({
missingScope: TALK_SECRETS_SCOPE,
requiredScopes: [READ_SCOPE, TALK_SECRETS_SCOPE],
}),
); );
return; return;
} }
@@ -502,7 +502,15 @@ describe("gateway server agent", () => {
idempotencyKey: "idem-agent-write-reset", idempotencyKey: "idem-agent-write-reset",
}); });
expect(viaAgent.ok).toBe(false); 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 }); const stored = loadSessionEntry({ sessionKey: "agent:main:main", storePath });
expect(stored?.sessionId).toBe("sess-main-before-write-reset"); expect(stored?.sessionId).toBe("sess-main-before-write-reset");
@@ -618,7 +618,15 @@ describe("node.invoke approval bypass", () => {
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
}); });
expect(directProxy.ok).toBe(false); 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); await expectNoForwardedInvoke(() => sawInvoke);
} finally { } finally {
ws.close(); ws.close();
@@ -674,7 +682,14 @@ describe("node.invoke approval bypass", () => {
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
}); });
expect(directList.ok).toBe(false); 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); await expectNoForwardedInvoke(() => sawInvoke);
} finally { } finally {
ws.close(); ws.close();
+9 -1
View File
@@ -290,7 +290,15 @@ describe("gateway talk.config", () => {
await withTalkConfigConnection(["operator.read"], async (ws) => { await withTalkConfigConnection(["operator.read"], async (ws) => {
const res = await fetchTalkConfig(ws, { includeSecrets: true }); const res = await fetchTalkConfig(ws, { includeSecrets: true });
expect(res.ok).toBe(false); 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"],
},
});
}); });
}); });
+7 -7
View File
@@ -24,18 +24,18 @@ export const NODE_EXEC_APPROVALS_COMMANDS = [
] as const; ] as const;
// Direct node.invoke and pairing approval share this admin-only subset. // 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_BROWSER_PROXY_COMMAND,
NODE_FS_LIST_DIR_COMMAND, NODE_FS_LIST_DIR_COMMAND,
NODE_TERMINAL_UPLOAD_COMMAND, NODE_TERMINAL_UPLOAD_COMMAND,
] as const; ] as const;
// Pairing also protects commands whose execution uses a separate gated path. const NODE_ADMIN_ONLY_INVOKE_COMMAND_SET = new Set<string>(NODE_ADMIN_ONLY_INVOKE_COMMANDS);
export const NODE_ADMIN_PAIR_APPROVAL_COMMANDS = [
...NODE_SYSTEM_RUN_COMMANDS, /** Returns true when direct node invocation crosses an admin-only host boundary. */
...NODE_ADMIN_ONLY_INVOKE_COMMANDS, export function isAdminOnlyNodeInvokeCommand(command: unknown): boolean {
...NODE_EXEC_APPROVALS_COMMANDS, return typeof command === "string" && NODE_ADMIN_ONLY_INVOKE_COMMAND_SET.has(command);
] as const; }
export const NODE_MCP_TOOL_CALL_TIMEOUT_MS = 120_000; 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; export const NODE_MCP_TOOL_CALL_GATEWAY_TIMEOUT_MS = NODE_MCP_TOOL_CALL_TIMEOUT_MS + 5_000;
+14 -7
View File
@@ -1,12 +1,21 @@
// Covers scope requirements for node pairing approvals. // Covers scope requirements for node pairing approvals.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
NODE_ADMIN_ONLY_INVOKE_COMMANDS, NODE_BROWSER_PROXY_COMMAND,
NODE_ADMIN_PAIR_APPROVAL_COMMANDS,
NODE_EXEC_APPROVALS_COMMANDS, NODE_EXEC_APPROVALS_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
NODE_TERMINAL_UPLOAD_COMMAND,
isAdminOnlyNodeInvokeCommand,
} from "./node-commands.js"; } from "./node-commands.js";
import { resolveNodePairApprovalScopes } from "./node-pairing-authz.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", () => { describe("resolveNodePairApprovalScopes", () => {
it("requires operator.admin for system.run commands", () => { it("requires operator.admin for system.run commands", () => {
expect(resolveNodePairApprovalScopes(["system.run"])).toEqual([ 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", "requires operator.admin for %s commands",
(command) => { (command) => {
expect(resolveNodePairApprovalScopes([command])).toEqual([ expect(resolveNodePairApprovalScopes([command])).toEqual([
@@ -25,10 +34,8 @@ describe("resolveNodePairApprovalScopes", () => {
}, },
); );
it("keeps every direct-invoke admin command admin-gated at pairing", () => { it.each(ADMIN_ONLY_INVOKE_COMMANDS)("classifies %s as admin-only at invocation", (command) => {
for (const command of NODE_ADMIN_ONLY_INVOKE_COMMANDS) { expect(isAdminOnlyNodeInvokeCommand(command)).toBe(true);
expect(NODE_ADMIN_PAIR_APPROVAL_COMMANDS).toContain(command);
}
}); });
it("keeps dedicated exec-approval commands admin-gated at pairing", () => { it("keeps dedicated exec-approval commands admin-gated at pairing", () => {
+14 -6
View File
@@ -1,5 +1,9 @@
// Maps node pairing command declarations to required operator scopes. // 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. */ /** Operator scopes required to approve a pending node pairing surface. */
export type NodeApprovalScope = "operator.pairing" | "operator.write" | "operator.admin"; 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_WRITE_SCOPE: NodeApprovalScope = "operator.write";
const OPERATOR_ADMIN_SCOPE: NodeApprovalScope = "operator.admin"; 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. */ /** Map declared node commands to the least operator scopes needed for approval. */
export function resolveNodePairApprovalScopes(commands: unknown): NodeApprovalScope[] { export function resolveNodePairApprovalScopes(commands: unknown): NodeApprovalScope[] {
const normalized = Array.isArray(commands) const normalized = Array.isArray(commands)
? commands.filter((command): command is string => typeof command === "string") ? commands.filter((command): command is string => typeof command === "string")
: []; : [];
if ( if (normalized.some(isAdminPairApprovalCommand)) {
normalized.some((command) =>
NODE_ADMIN_PAIR_APPROVAL_COMMANDS.some((allowed) => allowed === command),
)
) {
return [OPERATOR_PAIRING_SCOPE, OPERATOR_ADMIN_SCOPE]; return [OPERATOR_PAIRING_SCOPE, OPERATOR_ADMIN_SCOPE];
} }
if (normalized.length > 0) { if (normalized.length > 0) {
+10
View File
@@ -231,6 +231,16 @@ export const sharedVitestConfig = {
find: "@openclaw/gateway-protocol/frame-guards", find: "@openclaw/gateway-protocol/frame-guards",
replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "frame-guards.ts"), 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", find: "@openclaw/gateway-protocol/schema",
replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "schema.ts"), replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "schema.ts"),
+3
View File
@@ -75,6 +75,9 @@
"@openclaw/gateway-protocol/connect-error-details": [ "@openclaw/gateway-protocol/connect-error-details": [
"./packages/gateway-protocol/src/connect-error-details.ts" "./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": [ "@openclaw/gateway-protocol/frame-guards": [
"./packages/gateway-protocol/src/frame-guards.ts" "./packages/gateway-protocol/src/frame-guards.ts"
], ],
+65
View File
@@ -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);
});
});
+6 -6
View File
@@ -1,4 +1,5 @@
// Control UI shared Gateway error helpers. // 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 { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
import { resolveGatewayErrorDetailCode } from "../api/gateway.ts"; import { resolveGatewayErrorDetailCode } from "../api/gateway.ts";
@@ -9,13 +10,12 @@ export function isMissingOperatorReadScopeError(err: unknown): boolean {
if (!(err instanceof Error) || err.name !== "GatewayRequestError") { if (!(err instanceof Error) || err.name !== "GatewayRequestError") {
return false; return false;
} }
if (readMissingScopeError(err)?.missingScope === "operator.read") {
return true;
}
const detailCode = resolveGatewayErrorDetailCode(err as { details?: unknown }); const detailCode = resolveGatewayErrorDetailCode(err as { details?: unknown });
// AUTH_UNAUTHORIZED is the current server signal for scope failures in RPC responses. // Older gateways sometimes reused the connect-time authorization detail for RPC failures.
// The message-based branch catches responses that do not include a structured detail code yet. return detailCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED;
return (
detailCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED ||
err.message.includes("missing scope: operator.read")
);
} }
export function formatMissingOperatorReadScopeMessage(feature: string): string { export function formatMissingOperatorReadScopeMessage(feature: string): string {