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