mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 09:47:53 +00:00
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:
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
-8
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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." },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user