refactor(protocol): pre-publish cheat-window cleanup and vintage tracking (#111041)

* fix(codex): drain dynamic-tool handlers before side-thread cleanup

* refactor(protocol): rename question ids to questionId and flatten answer maps

* refactor(protocol): slim worker stack and unify session-catalog shapes

* refactor(protocol): delete dead public surface and polish packaging

* feat(protocol): track release-train vintage on gateway methods and schemas

* fix(apps): align question surfaces merged from main with reshaped protocol

* test(health): shape secret fixtures to scanner-safe token names

* test(health): use scanner-safe token fixtures

* fix(apps): align question surfaces merged from main with reshaped protocol

* fix(ci): prove reshaped protocol in shallow checks

* fix(ui): align sidebar question fixtures with protocol

* fix(apps): read flat Swift question answers

* fix(apps): align question surfaces merged from main with reshaped protocol

* fix(apps): refresh native question inventory

* fix(apps): align macOS snapshot fixtures with protocol

* fix(ui): align narration question fixture with protocol
This commit is contained in:
Peter Steinberger
2026-07-19 04:07:15 -07:00
committed by GitHub
parent 104691f822
commit 3b84a55d99
125 changed files with 1648 additions and 2015 deletions
+8
View File
@@ -1433,6 +1433,7 @@ jobs:
env:
GH_TOKEN: ${{ matrix.task == 'max-lines-ratchet' && github.token || '' }}
OPENCLAW_TEST_PROJECTS_PARALLEL: 3
PROTOCOL_SINCE_BASE_SHA: ${{ needs.preflight.outputs.diff_base_revision }}
RATCHET_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RATCHET_EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
RATCHET_MANUAL_TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.checkout_revision || '' }}
@@ -1450,6 +1451,13 @@ jobs:
case "$TASK" in
bundled-protocol)
pnpm test:bundled
if [[ -n "${PROTOCOL_SINCE_BASE_SHA:-}" ]]; then
git fetch --no-tags --no-recurse-submodules --depth=1 origin \
"+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base"
else
git fetch --no-tags --no-recurse-submodules --depth=1 origin \
"+refs/heads/main:refs/remotes/origin/main"
fi
pnpm protocol:check
;;
contracts-plugins-ci-routing)
+28 -28
View File
@@ -1755,7 +1755,7 @@
},
{
"kind": "ui-call",
"line": 1041,
"line": 1040,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Wait for the current response to finish before starting a new chat.",
"surface": "android",
@@ -1763,7 +1763,7 @@
},
{
"kind": "ui-call",
"line": 1174,
"line": 1173,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not update model.",
"surface": "android",
@@ -1771,7 +1771,7 @@
},
{
"kind": "ui-call",
"line": 1239,
"line": 1238,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not update thinking level.",
"surface": "android",
@@ -1779,7 +1779,7 @@
},
{
"kind": "ui-call",
"line": 1732,
"line": 1731,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Chat failed before the run started; try again.",
"surface": "android",
@@ -1787,7 +1787,7 @@
},
{
"kind": "ui-call",
"line": 3173,
"line": 3169,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not stage an attachment for sending.",
"surface": "android",
@@ -1795,7 +1795,7 @@
},
{
"kind": "ui-call",
"line": 3206,
"line": 3202,
"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",
@@ -1803,7 +1803,7 @@
},
{
"kind": "ui-call",
"line": 3212,
"line": 3208,
"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",
@@ -1811,7 +1811,7 @@
},
{
"kind": "ui-call",
"line": 3218,
"line": 3214,
"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",
@@ -1819,7 +1819,7 @@
},
{
"kind": "ui-call",
"line": 3223,
"line": 3219,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Gateway health not OK; cannot send",
"surface": "android",
@@ -1827,7 +1827,7 @@
},
{
"kind": "ui-call",
"line": 3230,
"line": 3226,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not queue message for later delivery.",
"surface": "android",
@@ -1835,7 +1835,7 @@
},
{
"kind": "ui-call",
"line": 3947,
"line": 3943,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Chat failed",
"surface": "android",
@@ -1843,7 +1843,7 @@
},
{
"kind": "ui-call",
"line": 4085,
"line": 4081,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Event stream interrupted; try refreshing.",
"surface": "android",
@@ -1851,7 +1851,7 @@
},
{
"kind": "ui-call",
"line": 4222,
"line": 4218,
"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",
@@ -1859,7 +1859,7 @@
},
{
"kind": "ui-call",
"line": 4432,
"line": 4428,
"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",
@@ -37643,7 +37643,7 @@
},
{
"kind": "ui-localized-call",
"line": 258,
"line": 256,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Answered",
"surface": "apple",
@@ -37651,7 +37651,7 @@
},
{
"kind": "ui-localized-call",
"line": 261,
"line": 259,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Answered elsewhere",
"surface": "apple",
@@ -37659,7 +37659,7 @@
},
{
"kind": "ui-localized-call",
"line": 263,
"line": 261,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Skipped",
"surface": "apple",
@@ -37667,7 +37667,7 @@
},
{
"kind": "ui-localized-call",
"line": 265,
"line": 263,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Expired",
"surface": "apple",
@@ -37675,7 +37675,7 @@
},
{
"kind": "ui-localized-call",
"line": 267,
"line": 265,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Unavailable",
"surface": "apple",
@@ -37683,7 +37683,7 @@
},
{
"kind": "ui-localized-call",
"line": 269,
"line": 267,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Pending",
"surface": "apple",
@@ -37691,7 +37691,7 @@
},
{
"kind": "ui-modifier",
"line": 367,
"line": 361,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Question summary",
"surface": "apple",
@@ -37699,7 +37699,7 @@
},
{
"kind": "ui-call",
"line": 381,
"line": 375,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Other answer",
"surface": "apple",
@@ -37707,7 +37707,7 @@
},
{
"kind": "conditional-branch",
"line": 439,
"line": 433,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Not selected",
"surface": "apple",
@@ -37715,7 +37715,7 @@
},
{
"kind": "conditional-branch",
"line": 439,
"line": 433,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Selected",
"surface": "apple",
@@ -37723,7 +37723,7 @@
},
{
"kind": "ui-call",
"line": 456,
"line": 450,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Skipping…",
"surface": "apple",
@@ -37731,7 +37731,7 @@
},
{
"kind": "ui-call",
"line": 459,
"line": 453,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Skip",
"surface": "apple",
@@ -37739,7 +37739,7 @@
},
{
"kind": "ui-call",
"line": 470,
"line": 464,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Submitting…",
"surface": "apple",
@@ -37747,7 +37747,7 @@
},
{
"kind": "ui-call",
"line": 473,
"line": 467,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift",
"source": "Submit",
"surface": "apple",
@@ -7,7 +7,6 @@ import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown
import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.QuestionAnswers
import ai.openclaw.app.gateway.QuestionAnswersAnswersValue
import ai.openclaw.app.gateway.QuestionGetResult
import ai.openclaw.app.gateway.QuestionListResult
import ai.openclaw.app.gateway.QuestionRecord
@@ -2144,7 +2143,7 @@ class ChatController internal constructor(
"answers",
buildJsonObject {
answers.orEmpty().forEach { (questionId, values) ->
put(questionId, buildJsonObject { put("answers", JsonArray(values.map(::JsonPrimitive))) })
put(questionId, JsonArray(values.map(::JsonPrimitive)))
}
},
)
@@ -2160,10 +2159,7 @@ class ChatController internal constructor(
record =
prompt.record.copy(
status = if (cancel) "cancelled" else "answered",
answers =
answers?.let { values ->
QuestionAnswers(values.mapValues { QuestionAnswersAnswersValue(it.value) })
},
answers = answers?.let(::QuestionAnswers),
),
submitting = false,
skipping = false,
@@ -49,7 +49,7 @@ data class ChatQuestionDraft(
label: String,
): ChatQuestionDraft {
if (question.options.none { it.label == label }) return this
val selected = selectedOptions[question.id].orEmpty()
val selected = selectedOptions[question.questionId].orEmpty()
val next =
if (question.multiSelect == true) {
if (label in selected) selected - label else selected + label
@@ -59,8 +59,8 @@ data class ChatQuestionDraft(
setOf(label)
}
return copy(
selectedOptions = selectedOptions + (question.id to next),
otherText = if (question.multiSelect != true && next.isNotEmpty()) otherText + (question.id to "") else otherText,
selectedOptions = selectedOptions + (question.questionId to next),
otherText = if (question.multiSelect != true && next.isNotEmpty()) otherText + (question.questionId to "") else otherText,
)
}
@@ -71,19 +71,19 @@ data class ChatQuestionDraft(
if (question.options.isNotEmpty() && question.isOther != true) return this
val clearOptions = question.multiSelect != true && value.isNotBlank()
return copy(
selectedOptions = if (clearOptions) selectedOptions + (question.id to emptySet()) else selectedOptions,
otherText = otherText + (question.id to value),
selectedOptions = if (clearOptions) selectedOptions + (question.questionId to emptySet()) else selectedOptions,
otherText = otherText + (question.questionId to value),
)
}
fun answers(questions: List<Question>): Map<String, List<String>>? {
val result = linkedMapOf<String, List<String>>()
for (question in questions) {
val selected = selectedOptions[question.id].orEmpty()
val selected = selectedOptions[question.questionId].orEmpty()
val values = question.options.mapNotNull { option -> option.label.takeIf { it in selected } }.toMutableList()
otherText[question.id]?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add)
otherText[question.questionId]?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add)
if (values.isEmpty()) return null
result[question.id] = values
result[question.questionId] = values
}
return result
}
@@ -81,7 +81,7 @@ data class QuestionOption(
@Serializable
data class Question(
val id: String,
val questionId: String,
val header: String,
val question: String,
val options: List<QuestionOption>,
@@ -92,7 +92,7 @@ data class Question(
@Serializable
data class QuestionAnswers(
val answers: Map<String, QuestionAnswersAnswersValue>,
val answers: Map<String, List<String>>,
)
@Serializable
@@ -130,11 +130,6 @@ data class GatewayNodeInvokeResultParamsError(
val message: String? = null,
)
@Serializable
data class QuestionAnswersAnswersValue(
val answers: List<String>,
)
enum class GatewayMethod(
val rawValue: String,
) {
@@ -138,7 +138,7 @@ private fun QuestionSection(
)
Text(text = question.question, style = ClawTheme.type.body, color = ClawTheme.colors.text)
question.options.forEach { option ->
val selected = option.label in draft.selectedOptions[question.id].orEmpty()
val selected = option.label in draft.selectedOptions[question.questionId].orEmpty()
Surface(
onClick = { onDraftChanged(draft.toggle(question, option.label)) },
enabled = enabled,
@@ -166,7 +166,7 @@ private fun QuestionSection(
}
if (question.options.isEmpty() || question.isOther == true) {
OutlinedTextField(
value = draft.otherText[question.id].orEmpty(),
value = draft.otherText[question.questionId].orEmpty(),
onValueChange = { onDraftChanged(draft.setOther(question, it)) },
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
@@ -229,7 +229,7 @@ internal fun terminalQuestionAnswer(
if (status == ChatQuestionStatus.Cancelled) return nativeString("Skipped")
if (status == ChatQuestionStatus.Expired) return nativeString("Expired")
if (status == ChatQuestionStatus.Unavailable) return nativeString("Unavailable")
prompt.record.answers?.answers?.get(question.id)?.answers?.takeIf { it.isNotEmpty() }?.let {
prompt.record.answers?.answers?.get(question.questionId)?.takeIf { it.isNotEmpty() }?.let {
return it.joinToString(", ")
}
return if (status == ChatQuestionStatus.AnsweredElsewhere) nativeString("Answered elsewhere") else nativeString("Answered")
@@ -5,7 +5,6 @@ import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.Question
import ai.openclaw.app.gateway.QuestionAnswers
import ai.openclaw.app.gateway.QuestionAnswersAnswersValue
import ai.openclaw.app.gateway.QuestionGetResult
import ai.openclaw.app.gateway.QuestionListResult
import ai.openclaw.app.gateway.QuestionOption
@@ -30,7 +29,7 @@ import org.junit.Test
class ChatQuestionTest {
private val question =
Question(
id = "meal",
questionId = "meal",
header = "Meal",
question = "Choose dinner",
options = listOf(QuestionOption("Pizza"), QuestionOption("Tacos")),
@@ -87,7 +86,7 @@ class ChatQuestionTest {
ChatQuestionPrompt(
record =
record(status = "answered").copy(
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Pizza", "Salad")))),
answers = QuestionAnswers(mapOf("meal" to listOf("Pizza", "Salad"))),
),
answeredLocally = true,
)
@@ -430,7 +429,7 @@ class ChatQuestionTest {
val answered =
pending.copy(
status = "answered",
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))),
answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))),
)
var getParams: String? = null
val controller =
@@ -466,8 +465,7 @@ class ChatQuestionTest {
.single()
.record.answers
?.answers
?.get("meal")
?.answers,
?.get("meal"),
)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
@@ -489,7 +487,7 @@ class ChatQuestionTest {
val listedAnswered =
listedPending.copy(
status = "answered",
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))),
answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))),
)
val recoveredAnswered = recoveredPending.copy(status = "answered")
val failingAnswered = failingPending.copy(status = "answered")
@@ -558,8 +556,7 @@ class ChatQuestionTest {
.record
.answers
?.answers
?.get("meal")
?.answers,
?.get("meal"),
)
assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_recovered").status())
assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_failing").status())
@@ -709,7 +706,7 @@ class ChatQuestionTest {
val answered =
pending.copy(
status = "answered",
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))),
answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))),
)
var getCalls = 0
val controller =
@@ -738,8 +735,7 @@ class ChatQuestionTest {
.single()
.record.answers
?.answers
?.get("meal")
?.answers,
?.get("meal"),
)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
@@ -4,7 +4,6 @@ import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatQuestionPrompt
import ai.openclaw.app.gateway.QuestionAnswers
import ai.openclaw.app.gateway.QuestionAnswersAnswersValue
import ai.openclaw.app.gateway.QuestionRecord
import androidx.compose.runtime.saveable.SaverScope
import org.junit.Assert.assertEquals
@@ -326,7 +325,7 @@ class ChatReaderScrollControllerTest {
answered.record.copy(
answers =
QuestionAnswers(
mapOf("choice" to QuestionAnswersAnswersValue(listOf("Yes"))),
mapOf("choice" to listOf("Yes")),
),
),
),
@@ -14,7 +14,7 @@ private func makeGatewayGenerationSnapshot(version: String) -> HelloOk {
features: [:],
snapshot: Snapshot(
presence: [],
health: OpenClawProtocol.AnyCodable([String: OpenClawProtocol.AnyCodable]()),
health: [String: OpenClawProtocol.AnyCodable](),
stateversion: StateVersion(presence: 0, health: 0),
uptimems: 0,
configpath: nil,
@@ -107,7 +107,7 @@ struct MacGatewayChatTransportMappingTests {
@Test func `snapshot maps to health`() {
let snapshot = Snapshot(
presence: [],
health: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(false)]),
health: ["ok": OpenClawProtocol.AnyCodable(false)],
stateversion: StateVersion(presence: 1, health: 1),
uptimems: 123,
configpath: nil,
@@ -95,12 +95,12 @@ public enum OpenClawChatGatewayRequests {
id: String,
answers: [String: [String]]) -> OpenClawChatGatewayRequest
{
let values = answers.mapValues { AnyCodable(["answers": AnyCodable($0)]) }
let values = answers.mapValues(AnyCodable.init)
return OpenClawChatGatewayRequest(
method: "question.resolve",
params: [
"id": AnyCodable(id),
"answers": AnyCodable(["answers": AnyCodable(values)]),
"answers": AnyCodable(values),
],
timeoutMs: self.mutationTimeoutMs)
}
@@ -86,7 +86,7 @@ public final class OpenClawQuestionCardModel: Identifiable {
}
public func toggleOption(questionID: String, label: String) {
guard let question = self.record.questions.first(where: { $0.id == questionID }),
guard let question = self.record.questions.first(where: { $0.questionid == questionID }),
question.options.contains(where: { $0.label == label }),
self.status() == .pending
else { return }
@@ -109,7 +109,7 @@ public final class OpenClawQuestionCardModel: Identifiable {
@discardableResult
public func toggleOption(questionID: String, optionNumber: Int) -> Bool {
guard let question = self.record.questions.first(where: { $0.id == questionID }),
guard let question = self.record.questions.first(where: { $0.questionid == questionID }),
self.status() == .pending,
(1...4).contains(optionNumber),
question.options.indices.contains(optionNumber - 1)
@@ -119,7 +119,7 @@ public final class OpenClawQuestionCardModel: Identifiable {
}
public func setOtherText(questionID: String, value: String) {
guard let question = self.record.questions.first(where: { $0.id == questionID }),
guard let question = self.record.questions.first(where: { $0.questionid == questionID }),
question.options.isEmpty || question.isother == true,
self.status() == .pending
else { return }
@@ -164,9 +164,7 @@ public final class OpenClawQuestionCardModel: Identifiable {
createdatms: self.record.createdatms,
expiresatms: self.record.expiresatms,
status: .answered,
answers: QuestionAnswers(answers: answers.mapValues { values in
AnyCodable(["answers": values])
}),
answers: QuestionAnswers(answers: answers.mapValues(AnyCodable.init)),
resolvedby: self.record.resolvedby)
}
@@ -255,9 +253,9 @@ public final class OpenClawQuestionCardModel: Identifiable {
public func terminalSummaryText(for question: Question) -> String {
switch self.status() {
case .answered:
self.answerValues(questionID: question.id)?.joined(separator: ", ") ?? String(localized: "Answered")
self.answerValues(questionID: question.questionid)?.joined(separator: ", ") ?? String(localized: "Answered")
case .answeredElsewhere:
self.answerValues(questionID: question.id)?.joined(separator: ", ")
self.answerValues(questionID: question.questionid)?.joined(separator: ", ")
?? String(localized: "Answered elsewhere")
case .cancelled:
String(localized: "Skipped")
@@ -273,15 +271,15 @@ public final class OpenClawQuestionCardModel: Identifiable {
private func answers() -> [String: [String]]? {
var result: [String: [String]] = [:]
for question in self.record.questions {
let selected = self.selectedOptions[question.id] ?? []
let selected = self.selectedOptions[question.questionid] ?? []
var values = question.options.compactMap { selected.contains($0.label) ? $0.label : nil }
if let other = self.otherText[question.id]?.trimmingCharacters(in: .whitespacesAndNewlines),
if let other = self.otherText[question.questionid]?.trimmingCharacters(in: .whitespacesAndNewlines),
!other.isEmpty
{
values.append(other)
}
guard !values.isEmpty else { return nil }
result[question.id] = values
result[question.questionid] = values
}
return result
}
@@ -289,14 +287,10 @@ public final class OpenClawQuestionCardModel: Identifiable {
private func answerValues(questionID: String) -> [String]? {
guard let answer = self.record.answers?.answers[questionID],
let data = try? JSONEncoder().encode(answer),
let decoded = try? JSONDecoder().decode(ResolvedAnswer.self, from: data),
!decoded.answers.isEmpty
let decoded = try? JSONDecoder().decode([String].self, from: data),
!decoded.isEmpty
else { return nil }
return decoded.answers
}
private struct ResolvedAnswer: Codable {
let answers: [String]
return decoded
}
private static func recordsMatch(_ lhs: QuestionRecord, _ rhs: QuestionRecord) -> Bool {
@@ -336,7 +330,7 @@ struct OpenClawQuestionCard: View {
private var pendingCard: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
VStack(alignment: .leading, spacing: 14) {
ForEach(self.model.record.questions, id: \.id) { question in
ForEach(self.model.record.questions, id: \.questionid) { question in
self.questionSection(question, now: context.date)
}
self.footer(now: context.date)
@@ -349,7 +343,7 @@ struct OpenClawQuestionCard: View {
private var terminalSummary: some View {
VStack(alignment: .leading, spacing: 5) {
ForEach(self.model.record.questions, id: \.id) { question in
ForEach(self.model.record.questions, id: \.questionid) { question in
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(verbatim: "\(question.header):")
.font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
@@ -381,8 +375,8 @@ struct OpenClawQuestionCard: View {
TextField(
"Other answer",
text: Binding(
get: { self.model.otherText[question.id] ?? "" },
set: { self.model.setOtherText(questionID: question.id, value: $0) }),
get: { self.model.otherText[question.questionid] ?? "" },
set: { self.model.setOtherText(questionID: question.questionid, value: $0) }),
axis: .vertical)
.font(OpenClawChatTypography.body)
.textFieldStyle(.roundedBorder)
@@ -392,13 +386,13 @@ struct OpenClawQuestionCard: View {
}
#if os(macOS)
.focusable()
.focused(self.$focusedQuestionID, equals: question.id)
.focused(self.$focusedQuestionID, equals: question.questionid)
.onKeyPress(characters: .decimalDigits) { keyPress in
guard self.focusedQuestionID == question.id else { return .ignored }
guard self.focusedQuestionID == question.questionid else { return .ignored }
return self.handleNumberKey(keyPress, question: question, now: now)
}
.onKeyPress(.return) {
guard self.focusedQuestionID == question.id,
guard self.focusedQuestionID == question.questionid,
self.model.status(at: now) == .pending,
self.model.canSubmit
else { return .ignored }
@@ -409,12 +403,12 @@ struct OpenClawQuestionCard: View {
}
private func optionRow(question: Question, option: QuestionOption, now: Date) -> some View {
let selected = self.model.selectedOptions[question.id]?.contains(option.label) == true
let selected = self.model.selectedOptions[question.questionid]?.contains(option.label) == true
return Button {
#if os(macOS)
self.focusedQuestionID = question.id
self.focusedQuestionID = question.questionid
#endif
self.model.toggleOption(questionID: question.id, label: option.label)
self.model.toggleOption(questionID: question.questionid, label: option.label)
} label: {
HStack(alignment: .top, spacing: 10) {
Image(systemName: selected
@@ -498,7 +492,7 @@ struct OpenClawQuestionCard: View {
{
guard self.model.status(at: now) == .pending,
let digit = keyPress.characters.first?.wholeNumberValue,
self.model.toggleOption(questionID: question.id, optionNumber: digit)
self.model.toggleOption(questionID: question.questionid, optionNumber: digit)
else { return .ignored }
return .handled
}
@@ -1048,7 +1048,7 @@ public struct StateVersion: Codable, Sendable {
public struct Snapshot: Codable, Sendable {
public let presence: [PresenceEntry]
public let health: AnyCodable
public let health: [String: AnyCodable]
public let stateversion: StateVersion
public let uptimems: Int
public let appliedconfighash: AnyCodable?
@@ -1060,7 +1060,7 @@ public struct Snapshot: Codable, Sendable {
public init(
presence: [PresenceEntry],
health: AnyCodable,
health: [String: AnyCodable],
stateversion: StateVersion,
uptimems: Int,
appliedconfighash: AnyCodable? = nil,
@@ -3779,7 +3779,7 @@ public struct SessionCatalogSession: Codable, Sendable {
public let cliversion: String?
public let gitbranch: String?
public let archived: Bool
public let openclawsessionkey: String?
public let sessionkey: String?
public let cancontinue: Bool
public let canarchive: Bool
public let canopenterminal: Bool?
@@ -3797,7 +3797,7 @@ public struct SessionCatalogSession: Codable, Sendable {
cliversion: String? = nil,
gitbranch: String? = nil,
archived: Bool,
openclawsessionkey: String? = nil,
sessionkey: String? = nil,
cancontinue: Bool,
canarchive: Bool,
canopenterminal: Bool? = nil)
@@ -3814,7 +3814,7 @@ public struct SessionCatalogSession: Codable, Sendable {
self.cliversion = cliversion
self.gitbranch = gitbranch
self.archived = archived
self.openclawsessionkey = openclawsessionkey
self.sessionkey = sessionkey
self.cancontinue = cancontinue
self.canarchive = canarchive
self.canopenterminal = canopenterminal
@@ -3833,7 +3833,7 @@ public struct SessionCatalogSession: Codable, Sendable {
case cliversion = "cliVersion"
case gitbranch = "gitBranch"
case archived
case openclawsessionkey = "openClawSessionKey"
case sessionkey = "sessionKey"
case cancontinue = "canContinue"
case canarchive = "canArchive"
case canopenterminal = "canOpenTerminal"
@@ -3952,39 +3952,39 @@ public struct SessionCatalogTranscriptItem: Codable, Sendable {
public struct SessionsCatalogListParams: Codable, Sendable {
public let catalogid: String?
public let cursors: [String: AnyCodable]?
public let agentid: String?
public let progressid: String?
public let search: String?
public let limitperhost: Int?
public let hostids: [String]?
public let cursors: [String: AnyCodable]?
public init(
catalogid: String? = nil,
cursors: [String: AnyCodable]? = nil,
agentid: String? = nil,
progressid: String? = nil,
search: String? = nil,
limitperhost: Int? = nil,
hostids: [String]? = nil,
cursors: [String: AnyCodable]? = nil)
hostids: [String]? = nil)
{
self.catalogid = catalogid
self.cursors = cursors
self.agentid = agentid
self.progressid = progressid
self.search = search
self.limitperhost = limitperhost
self.hostids = hostids
self.cursors = cursors
}
private enum CodingKeys: String, CodingKey {
case catalogid = "catalogId"
case cursors
case agentid = "agentId"
case progressid = "progressId"
case search
case limitperhost = "limitPerHost"
case hostids = "hostIds"
case cursors
}
}
@@ -13525,7 +13525,7 @@ public struct QuestionOption: Codable, Sendable {
}
public struct Question: Codable, Sendable {
public let id: String
public let questionid: String
public let header: String
public let question: String
public let options: [QuestionOption]
@@ -13534,7 +13534,7 @@ public struct Question: Codable, Sendable {
public let issecret: Bool?
public init(
id: String,
questionid: String,
header: String,
question: String,
options: [QuestionOption],
@@ -13542,7 +13542,7 @@ public struct Question: Codable, Sendable {
isother: Bool? = nil,
issecret: Bool? = nil)
{
self.id = id
self.questionid = questionid
self.header = header
self.question = question
self.options = options
@@ -13552,7 +13552,7 @@ public struct Question: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case id
case questionid = "questionId"
case header
case question
case options
@@ -13563,7 +13563,7 @@ public struct Question: Codable, Sendable {
}
public struct QuestionRequestQuestion: Codable, Sendable {
public let id: String
public let questionid: String
public let header: String
public let question: String
public let options: [QuestionOption]
@@ -13572,7 +13572,7 @@ public struct QuestionRequestQuestion: Codable, Sendable {
public let issecret: Bool?
public init(
id: String,
questionid: String,
header: String,
question: String,
options: [QuestionOption],
@@ -13580,7 +13580,7 @@ public struct QuestionRequestQuestion: Codable, Sendable {
isother: Bool? = nil,
issecret: Bool? = nil)
{
self.id = id
self.questionid = questionid
self.header = header
self.question = question
self.options = options
@@ -13590,7 +13590,7 @@ public struct QuestionRequestQuestion: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case id
case questionid = "questionId"
case header
case question
case options
@@ -239,7 +239,7 @@ struct ChatGatewayRequestTests {
#expect(String(decoding: encoded, as: UTF8.self).contains("a.png"))
}
@Test func `question resolve request preserves nested answer contract`() throws {
@Test func `question resolve request preserves answer arrays`() throws {
let request = OpenClawChatGatewayRequests.resolveQuestion(
id: "ask_123",
answers: ["meal": ["Pizza", "Salad"]])
@@ -248,9 +248,7 @@ struct ChatGatewayRequestTests {
let data = try JSONEncoder().encode(request.params)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let answers = try #require(object["answers"] as? [String: Any])
let values = try #require(answers["answers"] as? [String: Any])
let meal = try #require(values["meal"] as? [String: Any])
#expect(meal["answers"] as? [String] == ["Pizza", "Salad"])
#expect(answers["meal"] as? [String] == ["Pizza", "Salad"])
}
@Test func `question get request carries id`() {
@@ -16,7 +16,7 @@ private func questionRecord(
id: "ask_123",
questions: [
Question(
id: "meal",
questionid: "meal",
header: "Meal",
question: "Choose dinner",
options: [
@@ -145,7 +145,7 @@ private func questionRecord(
let data = try JSONEncoder().encode(model.record.answers)
let json = try #require(String(data: data, encoding: .utf8))
#expect(json.contains("\"meal\":{\"answers\":[\"Pizza\"]}"))
#expect(json.contains("\"meal\":[\"Pizza\"]"))
#expect(model.terminalSummaryText(for: model.record.questions[0]) == "Pizza")
}
@@ -173,7 +173,7 @@ private func questionRecord(
@MainActor
@Test func `question card terminal summaries prefer resolved answers`() {
let answers = QuestionAnswers(answers: [
"meal": AnyCodable(["answers": ["Pizza", "extra hot"]]),
"meal": AnyCodable(["Pizza", "extra hot"]),
])
let answered = OpenClawQuestionCardModel(record: questionRecord(status: .answered, answers: answers))
let question = answered.record.questions[0]
@@ -1325,7 +1325,7 @@ private func chatQuestionRecord(
id: id,
questions: [
Question(
id: "choice",
questionid: "choice",
header: "Choice",
question: "Choose",
options: [QuestionOption(label: "One"), QuestionOption(label: "Two")]),
@@ -1494,7 +1494,7 @@ struct ChatViewModelTests {
@Test @MainActor func `missing pending question uses question get fallback`() async {
let answers = QuestionAnswers(answers: [
"choice": AnyCodable(["answers": ["Two"]]),
"choice": AnyCodable(["Two"]),
])
let transport = TestChatTransport(
historyResponses: [],
+2
View File
@@ -68,6 +68,8 @@ const repositoryScriptEntries = [
// Oxlint loads this JS plugin by path from config/oxlint/boundary-guards.json.
"scripts/oxlint-boundary-guards.mjs!",
"scripts/plugin-prerelease-liveish-matrix.mjs!",
// Generates the checked-in native protocol models from core descriptor metadata.
"scripts/protocol-gen.ts!",
"scripts/pr-gates-lock.mjs!",
"scripts/pr-lib/ci-dispatch.mjs!",
"scripts/pr-lib/review-artifacts.mjs!",
@@ -272,7 +272,7 @@ e9c4398b04d04fead0e46ec618589d8f597a0b6087805ab132138e941b76e190 module/sandbox
596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime
50beebb77e461deaccdbff038f6a461dff1d5773426322dc6d7991f6e05a7c37 module/self-hosted-provider-setup
250476d121ffa4ed67d497c59d9c7bb1886973e92ebed39325a02609217bade0 module/session-binding-runtime
e3cfdf7ff5181ecd517bcb965c11f78b8363c8adf9f7f53f18bdbb5e0ceca1a6 module/session-catalog
ed35a448c8f7650acb34739a591334267371f1ffe352505fc2808330d78580bd module/session-catalog
f07839f5b8929a179857a0b4b89f8448864078cd5972dd53f9a30e50bf55408d module/session-key-runtime
f59099aa2d536246d4b1297f01bcea66796351a9259debdd45909afeb7a42d86 module/session-store-runtime
b1d0a76337122cb9dbb0c89fbb8bfacc1a88ce689270d3d684019c9a03ce669a module/session-transcript-hit
@@ -77,7 +77,7 @@ function isNodeSession(value: unknown): value is SessionCatalogSession {
isOptionalString(value.modelProvider) &&
isOptionalString(value.cliVersion) &&
isOptionalString(value.gitBranch) &&
isOptionalString(value.openClawSessionKey) &&
isOptionalString(value.sessionKey) &&
isOptionalNumber(value.createdAt) &&
isOptionalNumber(value.updatedAt) &&
isOptionalNumber(value.recencyAt)
+1 -1
View File
@@ -511,7 +511,7 @@ describe("Claude session catalog", () => {
registerClaudeSessionCatalog(api);
const hosts = await provider?.list({});
expect(hosts?.[0]?.sessions[0]?.openClawSessionKey).toBe("agent:main:claude-bound");
expect(hosts?.[0]?.sessions[0]?.sessionKey).toBe("agent:main:claude-bound");
});
it("continues a local Desktop-app row and lists it as continuable", async () => {
+1 -1
View File
@@ -1449,7 +1449,7 @@ function toGenericClaudeHost(
...(session.cliVersion ? { cliVersion: session.cliVersion } : {}),
...(session.gitBranch ? { gitBranch: session.gitBranch } : {}),
archived: session.archived,
...(continuable && existingSessionKey ? { openClawSessionKey: existingSessionKey } : {}),
...(continuable && existingSessionKey ? { sessionKey: existingSessionKey } : {}),
canContinue: continuable,
canArchive: false,
canOpenTerminal: terminal.canOpenTerminal,
@@ -365,6 +365,7 @@ export async function runCodexAppServerSideQuestion(
let turnId: string | undefined;
let removeRequestHandler: (() => void) | undefined;
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
const activeDynamicToolCalls = new Set<Promise<unknown>>();
try {
const modelScopedAppServer = resolveCodexAppServerForModelProvider({
@@ -484,14 +485,16 @@ export async function runCodexAppServerSideQuestion(
sessionKey: params.sessionKey,
};
emitDynamicToolStartedDiagnostic(diagnosticContext);
const toolCall = handleDynamicToolCallWithTimeout({
call,
toolBridge,
signal: runAbortController.signal,
timeoutMs,
observeToolTerminal: sideRunParams.observeToolTerminal,
});
activeDynamicToolCalls.add(toolCall);
try {
const response = await handleDynamicToolCallWithTimeout({
call,
toolBridge,
signal: runAbortController.signal,
timeoutMs,
observeToolTerminal: sideRunParams.observeToolTerminal,
});
const response = await toolCall;
emitDynamicToolTerminalDiagnostic({
...diagnosticContext,
response,
@@ -510,6 +513,8 @@ export async function runCodexAppServerSideQuestion(
: "failed",
});
throw error;
} finally {
activeDynamicToolCalls.delete(toolCall);
}
});
removeRequestHandler = registerRequestHandler(client);
@@ -730,6 +735,10 @@ export async function runCodexAppServerSideQuestion(
if (!runAbortController.signal.aborted) {
runAbortController.abort("codex_side_question_finished");
}
// Request handlers can still be finishing after the terminal turn event.
// Drain their abort races before unsubscribe so late diagnostics cannot leak
// into the next side run.
await Promise.allSettled(activeDynamicToolCalls);
try {
await cleanupCodexSideThread(client, {
threadId: childThreadId,
@@ -37,7 +37,7 @@ function createGatewayStub() {
}
if (method === "question.resolve") {
const resolveParams = params as {
answers?: { answers: Record<string, { answers: string[] }> };
answers?: { answers: Record<string, string[]> };
cancel?: boolean;
};
const result = resolveParams.cancel
@@ -95,7 +95,7 @@ describe("Codex app-server user input bridge", () => {
sessionKey: "agent:main:session-1",
agentId: "main",
timeoutMs: 90_000,
questions: requestParams().questions,
questions: [expect.objectContaining({ questionId: "choice" })],
});
const payload = vi.mocked(params.onBlockReply!).mock.calls[0]![0];
expect(payload.channelData).toEqual({
@@ -247,7 +247,7 @@ describe("Codex app-server user input bridge", () => {
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce());
expect(gateway.calls[0]?.params).toMatchObject({
timeoutMs: 60_000,
questions: [expect.objectContaining({ id: "notes", options: [] })],
questions: [expect.objectContaining({ questionId: "notes", options: [] })],
});
await claimPendingAgentQuestionAnswer({ sessionKey: params.sessionKey, text: "Refactor it" });
await expect(response).resolves.toEqual({
@@ -153,7 +153,7 @@ export function createCodexUserInputBridge(params: {
signal: abort.signal,
});
return result.status === "answered"
? (result.answers as unknown as JsonObject)
? gatewayAnswersToCodexResponse(result.answers.answers)
: emptyUserInputResponse();
} catch (error) {
embeddedAgentLog.warn("failed to bridge codex user input through gateway", { error });
@@ -291,6 +291,14 @@ function buildUserInputResponse(
return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject;
}
function gatewayAnswersToCodexResponse(answers: Record<string, string[]>): JsonObject {
return {
answers: Object.fromEntries(
Object.entries(answers).map(([questionId, values]) => [questionId, { answers: values }]),
),
};
}
function emptyUserInputResponse(): JsonObject {
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
}
@@ -126,9 +126,7 @@ export async function listPairedNode(params: {
...page,
sessions: page.sessions.map((session) => {
const adopted = params.adoptedSessions.get(adoptedSourceKey(hostId, session.threadId));
return adopted
? Object.assign({}, session, { openClawSessionKey: adopted.key })
: session;
return adopted ? Object.assign({}, session, { sessionKey: adopted.key }) : session;
}),
};
})
@@ -313,7 +313,7 @@ function parseOptionalCatalogString(
function parseCatalogSession(
value: unknown,
options: { allowOpenClawSessionKey?: boolean } = {},
options: { allowSessionKey?: boolean } = {},
): CodexSessionCatalogSession {
if (
!isRecord(value) ||
@@ -365,12 +365,8 @@ function parseCatalogSession(
MAX_METADATA_LENGTH,
);
const gitBranch = parseOptionalCatalogString(value.gitBranch, "Git branch", MAX_METADATA_LENGTH);
const openClawSessionKey = options.allowOpenClawSessionKey
? parseOptionalCatalogString(
value.openClawSessionKey,
"OpenClaw session key",
MAX_SESSION_KEY_LENGTH,
)
const sessionKey = options.allowSessionKey
? parseOptionalCatalogString(value.sessionKey, "OpenClaw session key", MAX_SESSION_KEY_LENGTH)
: undefined;
const createdAt = readFiniteNumber(value.createdAt);
const updatedAt = readFiniteNumber(value.updatedAt);
@@ -390,13 +386,13 @@ function parseCatalogSession(
...(modelProvider !== undefined ? { modelProvider } : {}),
...(cliVersion !== undefined ? { cliVersion } : {}),
...(gitBranch !== undefined ? { gitBranch } : {}),
...(openClawSessionKey !== undefined ? { openClawSessionKey } : {}),
...(sessionKey !== undefined ? { sessionKey } : {}),
};
}
export function parseCatalogPage(
value: unknown,
options: { allowOpenClawSessionKey?: boolean } = {},
options: { allowSessionKey?: boolean } = {},
): CodexSessionCatalogPage {
if (
!isRecord(value) ||
@@ -24,7 +24,7 @@ export type CodexSessionCatalogSession = {
cliVersion?: string;
gitBranch?: string;
/** Existing locked OpenClaw chat already mapped to this native source thread. */
openClawSessionKey?: string;
sessionKey?: string;
archived: boolean;
};
+8 -8
View File
@@ -1367,7 +1367,7 @@ describe("Codex supervision catalog", () => {
expect(result.hosts[0]?.sessions[0]).toMatchObject({
threadId: "source-thread",
openClawSessionKey: sessionKey,
sessionKey,
});
expect(result.hosts[1]?.sessions[0]).toEqual({
threadId: "source-thread",
@@ -1403,7 +1403,7 @@ describe("Codex supervision catalog", () => {
const result = await listCodexSessionCatalog({ bindingStore, config, runtime, control });
expect(result.hosts[0]?.sessions[0]).not.toHaveProperty("openClawSessionKey");
expect(result.hosts[0]?.sessions[0]).not.toHaveProperty("sessionKey");
});
it("ignores a public marker retarget and trusts the private source binding", async () => {
@@ -1440,7 +1440,7 @@ describe("Codex supervision catalog", () => {
threadId: "source-thread",
status: "idle",
archived: false,
openClawSessionKey: sessionKey,
sessionKey,
},
{ threadId: "forged-thread", status: "idle", archived: false },
]);
@@ -1497,7 +1497,7 @@ describe("Codex supervision catalog", () => {
(candidate) => candidate.threadId === source.threadId,
);
expect(session).toBeDefined();
expect(session).not.toHaveProperty("openClawSessionKey");
expect(session).not.toHaveProperty("sessionKey");
}
for (const source of sources) {
await expect(
@@ -1735,7 +1735,7 @@ describe("Codex supervision actions", () => {
expect(createSessionEntry).toHaveBeenCalledWith(expect.objectContaining({ agentId: "alpha" }));
expect(catalog.hosts[0]?.sessions[0]).toMatchObject({
threadId: "thread-1",
openClawSessionKey: created.sessionKey,
sessionKey: created.sessionKey,
});
});
@@ -1765,7 +1765,7 @@ describe("Codex supervision actions", () => {
});
const duringImport = await listCodexSessionCatalog({ bindingStore, config, runtime, control });
expect(duringImport.hosts[0]?.sessions[0]).not.toHaveProperty("openClawSessionKey");
expect(duringImport.hosts[0]?.sessions[0]).not.toHaveProperty("sessionKey");
expect(entries[0]?.entry.initializationPending).toBe(true);
let secondSettled = false;
const secondContinue = continueLocalCodexSession({
@@ -3062,7 +3062,7 @@ describe("Codex supervision actions", () => {
clientScopes: ["operator.admin"],
});
const pendingList = await provider?.list({ hostIds: ["node:devbox"] });
expect(pendingList?.[0]?.sessions[0]?.openClawSessionKey).toBeUndefined();
expect(pendingList?.[0]?.sessions[0]?.sessionKey).toBeUndefined();
await first?.afterConversationBound?.();
runtimeConfig = {
agents: { list: [{ id: "alpha" }, { id: "beta", default: true }] },
@@ -3129,7 +3129,7 @@ describe("Codex supervision actions", () => {
const listed = await provider?.list({ hostIds: ["node:devbox"] });
expect(listed?.[0]?.sessions[0]).toMatchObject({
threadId: "thread-remote",
openClawSessionKey: first?.sessionKey,
sessionKey: first?.sessionKey,
});
});
+2 -2
View File
@@ -391,7 +391,7 @@ async function listGatewayHost(params: {
...page,
sessions: page.sessions.map((session) => {
const adopted = adoptedSessions.get(session.threadId);
return adopted ? Object.assign({}, session, { openClawSessionKey: adopted.key }) : session;
return adopted ? Object.assign({}, session, { sessionKey: adopted.key }) : session;
}),
};
} catch (error) {
@@ -1230,7 +1230,7 @@ function toGenericCatalogHost(
...(session.cliVersion ? { cliVersion: session.cliVersion } : {}),
...(session.gitBranch ? { gitBranch: session.gitBranch } : {}),
archived: session.archived,
...(session.openClawSessionKey ? { openClawSessionKey: session.openClawSessionKey } : {}),
...(session.sessionKey ? { sessionKey: session.sessionKey } : {}),
canContinue,
canArchive,
canOpenTerminal,
+3 -3
View File
@@ -1157,12 +1157,12 @@ export async function runCopilotAttempt(
// its own private files; OpenClaw-side audit state is addressed only by
// session identity so missing identity cannot silently recreate JSONL state.
const openClawSessionIdForMirror = readString(input.sessionId);
const openClawSessionKeyForMirror = readString((input as { sessionKey?: unknown }).sessionKey);
const sessionKeyForMirror = readString((input as { sessionKey?: unknown }).sessionKey);
const openClawStorePathForMirror = readString(input.sessionTarget?.storePath);
const mirrorScopeSessionId = sessionIdUsed ?? openClawSessionIdForMirror;
if (
openClawSessionIdForMirror &&
openClawSessionKeyForMirror &&
sessionKeyForMirror &&
openClawStorePathForMirror &&
messagesSnapshot.length > 0
) {
@@ -1191,7 +1191,7 @@ export async function runCopilotAttempt(
});
await dualWriteCopilotTranscriptBestEffort({
sessionId: openClawSessionIdForMirror,
sessionKey: openClawSessionKeyForMirror,
sessionKey: sessionKeyForMirror,
agentId: readString(input.agentId),
storePath: openClawStorePathForMirror,
messages: taggedMessages,
@@ -35,7 +35,7 @@ function createGatewayStub() {
}
if (method === "question.resolve") {
const resolved = params as {
answers?: { answers: Record<string, { answers: string[] }> };
answers?: { answers: Record<string, string[]> };
cancel?: boolean;
};
const result = resolved.cancel
@@ -72,7 +72,10 @@ describe("Copilot user input bridge", () => {
agentId: "main",
timeoutMs: 75_000,
questions: [
expect.objectContaining({ id: "answer", options: [{ label: "Fast" }, { label: "Deep" }] }),
expect.objectContaining({
questionId: "answer",
options: [{ label: "Fast" }, { label: "Deep" }],
}),
],
});
const payload = vi.mocked(params.onBlockReply!).mock.calls[0]![0];
+1 -1
View File
@@ -56,7 +56,7 @@ export function createCopilotUserInputBridge(params: {
if (result.status !== "answered") {
return emptyCopilotUserInputResponse();
}
const selected = result.answers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? "";
const selected = result.answers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.[0] ?? "";
return {
answer: selected,
wasFreeform: !isChoiceAnswer(question, selected),
@@ -71,7 +71,7 @@ function isNodeSession(value: unknown): value is SessionCatalogSession {
isOptionalString(value.modelProvider) &&
isOptionalString(value.cliVersion) &&
isOptionalString(value.gitBranch) &&
isOptionalString(value.openClawSessionKey) &&
isOptionalString(value.sessionKey) &&
isOptionalNumber(value.createdAt) &&
isOptionalNumber(value.updatedAt) &&
isOptionalNumber(value.recencyAt)
+1 -1
View File
@@ -1778,7 +1778,7 @@
"prompt:snapshots:check": "node --import tsx scripts/generate-prompt-snapshots.ts --check",
"prompt:snapshots:gen": "node --import tsx scripts/generate-prompt-snapshots.ts --write",
"prompt:snapshots:sync-codex-model": "node --import tsx scripts/sync-codex-model-prompt-fixture.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && pnpm protocol:gen:kotlin && git diff --exit-code -- dist/protocol.schema.json apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && pnpm protocol:gen:kotlin && node scripts/check-protocol-since.mjs && git diff --exit-code -- dist/protocol.schema.json apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
"protocol:check:kotlin": "pnpm protocol:gen:kotlin && git diff --exit-code -- apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:kotlin": "node --import tsx scripts/protocol-gen-kotlin.ts",
+1
View File
@@ -5,6 +5,7 @@
"files": [
"dist"
],
"sideEffects": false,
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
@@ -1,21 +1,12 @@
import { describe, expect, it } from "vitest";
import {
validateApprovalAllowDecision,
validateApprovalGetParams,
validateApprovalGetResult,
validateApprovalHistoryParams,
validateApprovalHistoryResult,
validateApprovalDecision,
validateApprovalKind,
validateApprovalPresentation,
validateApprovalResolveParams,
validateApprovalResolveResult,
validateApprovalSnapshot,
validateApprovalTerminalReason,
validateExecApprovalPresentation,
validatePluginApprovalPresentation,
validatePluginApprovalSeverity,
validateTerminalApprovalSnapshot,
} from "./index.js";
const execPresentation = {
@@ -66,41 +57,14 @@ const pluginRecord = {
} as const;
describe("unified approval protocol validators", () => {
it("keeps approval kinds and decisions closed", () => {
expect(validateApprovalKind("exec")).toBe(true);
expect(validateApprovalKind("plugin")).toBe(true);
expect(validateApprovalKind("system-agent")).toBe(true);
expect(validateApprovalKind("tool")).toBe(false);
expect(validateApprovalDecision("deny")).toBe(true);
expect(validateApprovalDecision("accept")).toBe(false);
expect(validateApprovalAllowDecision("allow-once")).toBe(true);
expect(validateApprovalAllowDecision("deny")).toBe(false);
for (const reason of [
"user",
"timeout",
"malformed-verdict",
"no-route",
"run-aborted",
"gateway-restart",
"storage-corrupt",
] as const) {
expect(validateApprovalTerminalReason(reason)).toBe(true);
}
expect(validateApprovalTerminalReason("reviewer-decision")).toBe(false);
expect(validatePluginApprovalSeverity("critical")).toBe(true);
expect(validatePluginApprovalSeverity("blocker")).toBe(false);
});
it("accepts only reviewer-safe approval presentations", () => {
expect(validateExecApprovalPresentation(execPresentation)).toBe(true);
expect(validatePluginApprovalPresentation(pluginPresentation)).toBe(true);
expect(validateApprovalPresentation(execPresentation)).toBe(true);
expect(validateApprovalPresentation(pluginPresentation)).toBe(true);
expect(validateApprovalPresentation(systemAgentPresentation)).toBe(true);
for (const forbiddenField of ["cwd", "env", "systemRunBinding", "systemRunPlan"] as const) {
expect(
validateExecApprovalPresentation({
validateApprovalPresentation({
...execPresentation,
[forbiddenField]: forbiddenField === "env" ? { TOKEN: "secret" } : "private",
}),
@@ -110,13 +74,13 @@ describe("unified approval protocol validators", () => {
it("keeps deny available on every presentation and resolve request", () => {
expect(
validateExecApprovalPresentation({
validateApprovalPresentation({
...execPresentation,
allowedDecisions: ["allow-once"],
}),
).toBe(false);
expect(
validatePluginApprovalPresentation({
validateApprovalPresentation({
...pluginPresentation,
allowedDecisions: ["allow-always"],
}),
@@ -160,19 +124,22 @@ describe("unified approval protocol validators", () => {
} as const;
for (const snapshot of [pending, allowed, denied, expired, cancelled]) {
expect(validateApprovalSnapshot(snapshot)).toBe(true);
expect(validateApprovalGetResult({ approval: snapshot })).toBe(true);
}
expect(validateApprovalSnapshot({ ...allowed, decision: "deny" })).toBe(false);
expect(validateApprovalSnapshot({ ...allowed, resolvedBy: "device:phone" })).toBe(false);
expect(validateApprovalSnapshot({ ...denied, reason: "" })).toBe(false);
expect(validateApprovalSnapshot({ ...expired, decision: "deny" })).toBe(false);
expect(validateApprovalGetResult({ approval: { ...allowed, decision: "deny" } })).toBe(false);
expect(
validateApprovalSnapshot({
...execRecord,
presentation: { ...execPresentation, kind: "plugin" },
status: "pending",
validateApprovalGetResult({ approval: { ...allowed, resolvedBy: "device:phone" } }),
).toBe(false);
expect(validateApprovalGetResult({ approval: { ...denied, reason: "" } })).toBe(false);
expect(validateApprovalGetResult({ approval: { ...expired, decision: "deny" } })).toBe(false);
expect(
validateApprovalGetResult({
approval: {
...execRecord,
presentation: { ...execPresentation, kind: "plugin" },
status: "pending",
},
}),
).toBe(false);
});
@@ -204,17 +171,21 @@ describe("unified approval protocol validators", () => {
expect(validateApprovalGetParams({ id: "approval:🦞/percent%" })).toBe(true);
expect(
validateApprovalSnapshot({
...execRecord,
status: "pending",
sourceSessionKey: "agent:worker:subagent:123",
validateApprovalGetResult({
approval: {
...execRecord,
status: "pending",
sourceSessionKey: "agent:worker:subagent:123",
},
}),
).toBe(false);
expect(
validateApprovalSnapshot({
...execRecord,
status: "pending",
audienceSessionKeys: ["agent:worker:subagent:123", "agent:main"],
validateApprovalGetResult({
approval: {
...execRecord,
status: "pending",
audienceSessionKeys: ["agent:worker:subagent:123", "agent:main"],
},
}),
).toBe(false);
});
@@ -256,7 +227,6 @@ describe("unified approval protocol validators", () => {
expect(validateApprovalResolveResult({ applied: true, approval: recorded })).toBe(true);
expect(validateApprovalResolveResult({ applied: false, approval: recorded })).toBe(true);
expect(validateTerminalApprovalSnapshot(recorded)).toBe(true);
expect(
validateApprovalResolveResult({
applied: false,
@@ -5,8 +5,7 @@ export const ClawHubTrustErrorCodes = {
DOWNLOAD_BLOCKED: "clawhub_download_blocked",
} as const;
export type ClawHubTrustErrorCode =
(typeof ClawHubTrustErrorCodes)[keyof typeof ClawHubTrustErrorCodes];
type ClawHubTrustErrorCode = (typeof ClawHubTrustErrorCodes)[keyof typeof ClawHubTrustErrorCodes];
export type ClawHubTrustErrorDetails = {
clawhubTrustCode?: ClawHubTrustErrorCode;
@@ -4,7 +4,6 @@ import {
buildPairingConnectCloseReason,
buildPairingConnectErrorDetails,
buildPairingConnectErrorMessage,
ConnectPairingRequiredReasons,
describePairingConnectRequirement,
formatConnectErrorMessage,
formatConnectPairingRequiredMessage,
@@ -76,18 +75,16 @@ describe("resolveAuthConnectErrorDetailCode", () => {
describe("pairing connect details", () => {
it("builds reason-specific pairing messages", () => {
expect(buildPairingConnectErrorMessage(ConnectPairingRequiredReasons.SCOPE_UPGRADE)).toBe(
expect(buildPairingConnectErrorMessage("scope-upgrade")).toBe(
"pairing required: device is asking for more scopes than currently approved",
);
expect(describePairingConnectRequirement(ConnectPairingRequiredReasons.NOT_PAIRED)).toBe(
"device is not approved yet",
);
expect(describePairingConnectRequirement("not-paired")).toBe("device is not approved yet");
});
it("builds structured pairing details with remediation", () => {
expect(
buildPairingConnectErrorDetails({
reason: ConnectPairingRequiredReasons.NOT_PAIRED,
reason: "not-paired",
requestId: "req-123",
recommendedNextStep: "wait_then_retry",
retryable: true,
@@ -122,7 +119,7 @@ describe("pairing connect details", () => {
it("includes request ids in close reasons when available", () => {
expect(
buildPairingConnectCloseReason({
reason: ConnectPairingRequiredReasons.ROLE_UPGRADE,
reason: "role-upgrade",
requestId: "req-789",
}),
).toBe(
@@ -57,11 +57,11 @@ export const ConnectErrorDetailCodes = {
CLIENT_VERSION_MISMATCH: "CLIENT_VERSION_MISMATCH",
} as const;
export type ConnectErrorDetailCode =
type ConnectErrorDetailCode =
(typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes];
/** Pairing-specific reasons clients can display and use for reconnect policy. */
export const ConnectPairingRequiredReasons = {
const ConnectPairingRequiredReasons = {
NOT_PAIRED: "not-paired",
ROLE_UPGRADE: "role-upgrade",
SCOPE_UPGRADE: "scope-upgrade",
@@ -72,7 +72,7 @@ export type ConnectPairingRequiredReason =
(typeof ConnectPairingRequiredReasons)[keyof typeof ConnectPairingRequiredReasons];
/** Suggested client-side recovery action for structured connect errors. */
export type ConnectRecoveryNextStep =
type ConnectRecoveryNextStep =
| "retry_with_device_token"
| "update_auth_configuration"
| "update_auth_credentials"
@@ -80,13 +80,13 @@ export type ConnectRecoveryNextStep =
| "review_auth_configuration";
/** Optional retry guidance extracted from gateway connect-error details. */
export type ConnectErrorRecoveryAdvice = {
type ConnectErrorRecoveryAdvice = {
canRetryWithDeviceToken?: boolean;
recommendedNextStep?: ConnectRecoveryNextStep;
};
/** Full structured details for pairing-required connect failures. */
export type PairingConnectErrorDetails = {
type PairingConnectErrorDetails = {
code: typeof ConnectErrorDetailCodes.PAIRING_REQUIRED;
reason?: ConnectPairingRequiredReason;
requestId?: string;
@@ -1,10 +1,5 @@
import { describe, expect, it } from "vitest";
import {
validateGatewaySuspendPrepareParams,
validateGatewaySuspendPrepareResult,
validateGatewaySuspendResumeResult,
validateGatewaySuspendStatusResult,
} from "./index.js";
import { validateGatewaySuspendPrepareParams } from "./index.js";
describe("gateway suspension protocol", () => {
it("keeps prepare params closed and bounded", () => {
@@ -14,90 +9,4 @@ describe("gateway suspension protocol", () => {
false,
);
});
it("validates busy and ready prepare results", () => {
expect(
validateGatewaySuspendPrepareResult({
status: "busy",
reason: "active-work",
retryAfterMs: 20_000,
activeCount: 2,
blockers: [
{ kind: "queue", count: 1, message: "one queued operation" },
{
kind: "task",
count: 1,
message: "one active task",
task: { taskId: "task-1", status: "running", runtime: "subagent" },
},
],
}),
).toBe(true);
expect(
validateGatewaySuspendPrepareResult({
status: "ready",
suspensionId: "suspension-id",
expiresAtMs: 123,
activeCount: 0,
blockers: [],
}),
).toBe(true);
});
it("keeps background exec blockers count-only", () => {
const result = {
status: "busy",
reason: "active-work",
retryAfterMs: 20_000,
activeCount: 1,
blockers: [
{
kind: "background-exec",
count: 1,
message: "1 active background exec session(s)",
},
],
};
expect(validateGatewaySuspendPrepareResult(result)).toBe(true);
expect(
validateGatewaySuspendPrepareResult({
...result,
blockers: [{ ...result.blockers[0], sessionIds: ["private-session-id"] }],
}),
).toBe(false);
expect(
validateGatewaySuspendPrepareResult({
...result,
blockers: [{ ...result.blockers[0], command: "private command" }],
}),
).toBe(false);
});
it("validates status and resume results", () => {
expect(validateGatewaySuspendStatusResult({ status: "running" })).toBe(true);
expect(validateGatewaySuspendStatusResult({ status: "ready", expiresAtMs: 123 })).toBe(true);
expect(
validateGatewaySuspendResumeResult({ ok: true, status: "running", resumed: false }),
).toBe(true);
expect(
validateGatewaySuspendResumeResult({
ok: true,
status: "running",
resumed: false,
warnings: [],
}),
).toBe(false);
});
it("keeps scheduler recovery on the error frame instead of success results", () => {
const recovering = {
status: "recovering",
reason: "scheduler-resume-failed",
retryAfterMs: 1_000,
};
expect(validateGatewaySuspendPrepareResult(recovering)).toBe(false);
expect(validateGatewaySuspendStatusResult(recovering)).toBe(false);
});
});
+2 -305
View File
@@ -8,37 +8,29 @@ import {
validateChatHistoryParams,
validateChatMetadataParams,
validateChatSendParams,
validateChatEvent,
validateCommandsListParams,
validateConnectParams,
validateModelsListParams,
validateModelsProbeParams,
validateNodeEventResult,
validateNodePluginToolsUpdateParams,
validateNodeSkillsUpdateParams,
validateNodePresenceActivityPayload,
validateNodePresenceAlivePayload,
validateSessionsSearchParams,
validateSessionsUsageParams,
validateTasksCancelParams,
validateTasksListParams,
validateTalkCatalogResult,
validateTalkConfigResult,
validateTalkEvent,
validateTalkClientCreateParams,
validateTalkClientSteerParams,
validateTalkClientToolCallParams,
validateTalkAgentControlResult,
validateTalkSessionAppendAudioParams,
validateTalkSessionCancelOutputParams,
validateTalkSessionCancelTurnParams,
validateTalkSessionCreateParams,
validateTalkSessionJoinParams,
validateTalkSessionJoinResult,
validateTalkSessionSubmitToolResultParams,
validateTalkSessionSteerParams,
validateTalkSessionTurnParams,
validateTalkSessionTurnResult,
validateWakeParams,
type ValidationError,
} from "./index.js";
@@ -472,32 +464,6 @@ describe("validateTalkConfigResult", () => {
});
});
describe("validateTalkCatalogResult", () => {
it("accepts provider registry aliases", () => {
expect(
validateTalkCatalogResult({
modes: ["realtime"],
transports: ["gateway-relay"],
brains: ["agent-consult"],
speech: { providers: [] },
transcription: { providers: [] },
realtime: {
ready: true,
activeProvider: "google",
providers: [
{
id: "google",
aliases: ["gemini-live"],
label: "Google Live Voice",
configured: true,
},
],
},
}),
).toBe(true);
});
});
describe("validateTalkClientCreateParams", () => {
it("accepts provider, model, voice, mode, transport, and brain overrides", () => {
expect(
@@ -536,80 +502,6 @@ describe("validateTalkClientCreateParams", () => {
});
});
describe("validateTalkEvent", () => {
it("pins the common Talk event envelope used by relay and surface adapters", () => {
expect(
validateTalkEvent({
id: "talk-session:1",
type: "capture.started",
sessionId: "talk-session",
turnId: "turn-1",
captureId: "capture-1",
seq: 1,
timestamp: "2026-05-05T12:00:00.000Z",
mode: "stt-tts",
transport: "managed-room",
brain: "agent-consult",
provider: "openai",
final: false,
callId: "call-1",
itemId: "item-1",
parentId: "parent-1",
payload: { source: "ptt" },
}),
).toBe(true);
});
it("rejects stale or vendor-shaped event payloads without required correlation", () => {
expect(
validateTalkEvent({
type: "output.audio.delta",
sessionId: "talk-session",
seq: 0,
timestamp: "2026-05-05T12:00:00.000Z",
mode: "realtime-duplex",
transport: "webrtc-sdp",
brain: "agent-consult",
payload: { byteLength: 12 },
}),
).toBe(false);
expect(formatValidationErrors(validateTalkEvent.errors)).toContain("must have required");
});
it("requires turnId and captureId for scoped Talk events", () => {
expect(
validateTalkEvent({
id: "talk-session:1",
type: "turn.started",
sessionId: "talk-session",
seq: 1,
timestamp: "2026-05-05T12:00:00.000Z",
mode: "stt-tts",
transport: "managed-room",
brain: "agent-consult",
payload: {},
}),
).toBe(false);
expect(formatValidationErrors(validateTalkEvent.errors)).toContain("must have required");
expect(
validateTalkEvent({
id: "talk-session:2",
type: "capture.started",
sessionId: "talk-session",
turnId: "turn-1",
seq: 2,
timestamp: "2026-05-05T12:00:01.000Z",
mode: "stt-tts",
transport: "managed-room",
brain: "agent-consult",
payload: {},
}),
).toBe(false);
expect(formatValidationErrors(validateTalkEvent.errors)).toContain("must have required");
});
});
describe("validateTalkSession", () => {
it("accepts session-scoped provider, model, and voice selection", () => {
expect(
@@ -625,38 +517,6 @@ describe("validateTalkSession", () => {
brain: "agent-consult",
}),
).toBe(true);
expect(
validateTalkSessionJoinResult({
id: "session-1",
roomId: "talk_room-1",
roomUrl: "/talk/rooms/talk_handoff-1",
sessionKey: "agent:main:main",
provider: "openai",
model: "gpt-realtime-2",
voice: "alloy",
mode: "realtime",
transport: "managed-room",
brain: "agent-consult",
createdAt: 1,
expiresAt: 2,
room: {
activeClientId: "conn-1",
recentTalkEvents: [
{
id: "talk_handoff-1:1",
type: "session.ready",
sessionId: "talk_handoff-1",
seq: 1,
timestamp: "2026-05-05T12:00:00.000Z",
mode: "realtime",
transport: "managed-room",
brain: "agent-consult",
payload: {},
},
],
},
}),
).toBe(true);
});
it("rejects request-time instruction overrides for Talk session creation", () => {
@@ -672,7 +532,7 @@ describe("validateTalkSession", () => {
expect(validateTalkSessionCreateParams({ mode: "realtime", language: "de-DE" })).toBe(false);
});
it("accepts managed-room join, turn lifecycle params, and results", () => {
it("accepts managed-room join and turn lifecycle params", () => {
expect(
validateTalkSessionJoinParams({
sessionId: "session-1",
@@ -692,26 +552,6 @@ describe("validateTalkSession", () => {
reason: "barge-in",
}),
).toBe(true);
expect(
validateTalkSessionTurnResult({
ok: true,
turnId: "turn-1",
events: [
{
id: "talk_handoff-1:2",
type: "turn.started",
sessionId: "talk_handoff-1",
turnId: "turn-1",
seq: 2,
timestamp: "2026-05-05T12:00:00.000Z",
mode: "realtime",
transport: "managed-room",
brain: "agent-consult",
payload: {},
},
],
}),
).toBe(true);
});
});
@@ -730,7 +570,7 @@ describe("validateTalkClientToolCallParams", () => {
});
describe("validateTalkAgentControlParams", () => {
it("accepts client and session steering params plus structured outcomes", () => {
it("accepts client and session steering params", () => {
expect(
validateTalkClientSteerParams({
sessionKey: "agent:main:main",
@@ -746,24 +586,6 @@ describe("validateTalkAgentControlParams", () => {
mode: "status",
}),
).toBe(true);
expect(
validateTalkAgentControlResult({
ok: true,
mode: "cancel",
sessionKey: "agent:main:main",
sessionId: "session-1",
active: true,
aborted: true,
message: "Cancelled the active OpenClaw run.",
speak: true,
show: true,
suppress: false,
providerResult: {
status: "cancelled",
message: "Cancelled the active OpenClaw run.",
},
}),
).toBe(true);
});
});
@@ -860,91 +682,6 @@ describe("validateWakeParams", () => {
});
});
describe("validateChatEvent", () => {
it("accepts an explicitly yielded final turn", () => {
expect(
validateChatEvent({
runId: "run-yielded",
sessionKey: "agent:main:main",
seq: 1,
state: "final",
stopReason: "end_turn",
yielded: true,
}),
).toBe(true);
});
it("accepts v4 chat delta text and replacement markers", () => {
expect(
validateChatEvent({
runId: "run-chat",
sessionKey: "agent:main:main",
seq: 1,
state: "delta",
deltaText: "hello",
message: {
role: "assistant",
content: [{ type: "text", text: "hello" }],
},
}),
).toBe(true);
expect(
validateChatEvent({
runId: "run-chat",
sessionKey: "agent:main:main",
seq: 2,
state: "delta",
deltaText: "replacement",
replace: true,
message: {
role: "assistant",
content: [{ type: "text", text: "replacement" }],
},
}),
).toBe(true);
});
it("accepts selected-agent chat events", () => {
expect(
validateChatEvent({
runId: "run-chat",
sessionKey: "global",
agentId: "work",
seq: 1,
state: "delta",
deltaText: "hello",
}),
).toBe(true);
});
it("accepts an argument-free diagnostic on aborted chat events", () => {
expect(
validateChatEvent({
runId: "run-chat",
sessionKey: "agent:main:main",
seq: 2,
state: "aborted",
errorMessage: "edit tool validation failed: path: must be string",
}),
).toBe(true);
});
it("rejects v3-style chat deltas without deltaText", () => {
expect(
validateChatEvent({
runId: "run-chat",
sessionKey: "agent:main:main",
seq: 1,
state: "delta",
message: {
role: "assistant",
content: [{ type: "text", text: "hello" }],
},
}),
).toBe(false);
});
});
describe("validateChatSendParams", () => {
it("accepts one-turn fast:auto cutoff seconds", () => {
const base = {
@@ -1027,33 +764,6 @@ describe("validateTasksListParams", () => {
});
});
describe("validateNodePresenceAlivePayload", () => {
it("accepts a closed trigger and known metadata fields", () => {
expect(
validateNodePresenceAlivePayload({
trigger: "silent_push",
sentAtMs: 123,
displayName: "Peter's iPhone",
version: "2026.4.28",
platform: "iOS 18.4.0",
deviceFamily: "iPhone",
modelIdentifier: "iPhone17,1",
pushTransport: "relay",
}),
).toBe(true);
});
it("rejects unknown triggers and extra fields", () => {
expect(validateNodePresenceAlivePayload({ trigger: "push", sentAtMs: 123 })).toBe(false);
expect(
validateNodePresenceAlivePayload({
trigger: "silent_push",
arbitrary: true,
}),
).toBe(false);
});
});
describe("validateNodePresenceActivityPayload", () => {
it("accepts bounded input idle time", () => {
expect(validateNodePresenceActivityPayload({ idleSeconds: 12 })).toBe(true);
@@ -1068,16 +778,3 @@ describe("validateNodePresenceActivityPayload", () => {
expect(validateNodePresenceActivityPayload({ idleSeconds: 1, active: true })).toBe(false);
});
});
describe("validateNodeEventResult", () => {
it("accepts structured handled results", () => {
expect(
validateNodeEventResult({
ok: true,
event: "node.presence.alive",
handled: true,
reason: "persisted",
}),
).toBe(true);
});
});
+2 -54
View File
@@ -131,7 +131,6 @@ import {
ChatEventSchema,
ChatHistoryParamsSchema,
ChatMetadataParamsSchema,
ChatMessageGetResultSchema,
ChatMessageGetParamsSchema,
ChatInjectParamsSchema,
ChatSendParamsSchema,
@@ -442,6 +441,7 @@ import {
SessionCatalogCapabilitiesSchema,
SessionCatalogDescriptorSchema,
SessionCatalogHostSchema,
SessionCatalogLocatorSchema,
SessionCatalogSessionSchema,
SessionCatalogTranscriptItemSchema,
SessionsCatalogArchiveParamsSchema,
@@ -628,23 +628,15 @@ export const validateWorkerLiveEventParams = lazyCompile(
checkWorkerProtocolJson,
);
export const validateGatewaySuspendPrepareParams = lazyCompile(GatewaySuspendPrepareParamsSchema);
export const validateGatewaySuspendPrepareResult = lazyCompile(GatewaySuspendPrepareResultSchema);
export const validateGatewaySuspendStatusParams = lazyCompile(GatewaySuspendStatusParamsSchema);
export const validateGatewaySuspendStatusResult = lazyCompile(GatewaySuspendStatusResultSchema);
export const validateGatewaySuspendResumeParams = lazyCompile(GatewaySuspendResumeParamsSchema);
export const validateGatewaySuspendResumeResult = lazyCompile(GatewaySuspendResumeResultSchema);
export const validateRequestFrame = lazyCompile(RequestFrameSchema);
export const validateResponseFrame = lazyCompile(ResponseFrameSchema);
export const validateEventFrame = lazyCompile(EventFrameSchema);
export const validateMessageActionParams = lazyCompile(MessageActionParamsSchema);
export const validateSendParams = lazyCompile(SendParamsSchema);
export const validateConversationListParams = lazyCompile(ConversationListParamsSchema);
export const validateConversationListResult = lazyCompile(ConversationListResultSchema);
export const validateConversationSendParams = lazyCompile(ConversationSendParamsSchema);
export const validateConversationSendResult = lazyCompile(ConversationSendResultSchema);
export const validateConversationTurnCancelParams = lazyCompile(ConversationTurnCancelParamsSchema);
export const validateConversationTurnParams = lazyCompile(ConversationTurnParamsSchema);
export const validateConversationTurnResult = lazyCompile(ConversationTurnResultSchema);
export const validatePollParams = lazyCompile(PollParamsSchema);
export const validateAgentParams = lazyCompile(AgentParamsSchema);
export const validateAuditActivityListParams = lazyCompile<AuditActivityListParams>(
@@ -705,12 +697,9 @@ export const validateSystemInfoResult = lazyCompile(SystemInfoResultSchema);
export const validateNodePendingAckParams = lazyCompile(NodePendingAckParamsSchema);
export const validateNodeDescribeParams = lazyCompile(NodeDescribeParamsSchema);
export const validateNodeInvokeParams = lazyCompile(NodeInvokeParamsSchema);
export const validateNodeInvokeInputEvent = lazyCompile(NodeInvokeInputEventSchema);
export const validateNodeInvokeResultParams = lazyCompile(NodeInvokeResultParamsSchema);
export const validateNodeInvokeProgressParams = lazyCompile(NodeInvokeProgressParamsSchema);
export const validateNodeEventParams = lazyCompile(NodeEventParamsSchema);
export const validateNodeEventResult = lazyCompile(NodeEventResultSchema);
export const validateNodePresenceAlivePayload = lazyCompile(NodePresenceAlivePayloadSchema);
export const validateNodePresenceActivityPayload = lazyCompile(NodePresenceActivityPayloadSchema);
export const validateNodePendingDrainParams = lazyCompile(NodePendingDrainParamsSchema);
export const validateNodePendingEnqueueParams = lazyCompile(NodePendingEnqueueParamsSchema);
@@ -729,14 +718,12 @@ export const validateSecretsResolveParams = lazyCompile(SecretsResolveParamsSche
export const validateSecretsResolveResult = lazyCompile(SecretsResolveResultSchema);
export const validateSessionsListParams = lazyCompile(SessionsListParamsSchema);
export const validateSessionsCatalogListParams = lazyCompile(SessionsCatalogListParamsSchema);
export const validateSessionsCatalogHostEvent = lazyCompile(SessionsCatalogHostEventSchema);
export const validateSessionsCatalogReadParams = lazyCompile(SessionsCatalogReadParamsSchema);
export const validateSessionsCatalogContinueParams = lazyCompile(
SessionsCatalogContinueParamsSchema,
);
export const validateSessionsCatalogArchiveParams = lazyCompile(SessionsCatalogArchiveParamsSchema);
export const validateSessionsSearchParams = lazyCompile(SessionsSearchParamsSchema);
export const validateSessionsSearchResult = lazyCompile(SessionsSearchResultSchema);
export const validateSessionsCleanupParams = lazyCompile(SessionsCleanupParamsSchema);
export const validateSessionsPreviewParams = lazyCompile(SessionsPreviewParamsSchema);
export const validateSessionsDescribeParams = lazyCompile(SessionsDescribeParamsSchema);
@@ -749,9 +736,7 @@ export const validateSessionsDiffParams = lazyCompile(SessionsDiffParamsSchema);
export const validateSessionsCreateParams = lazyCompile(SessionsCreateParamsSchema);
export const validateSessionsSendParams = lazyCompile(SessionsSendParamsSchema);
export const validateSessionsDispatchParams = lazyCompile(SessionsDispatchParamsSchema);
export const validateSessionsDispatchResult = lazyCompile(SessionsDispatchResultSchema);
export const validateSessionsReclaimParams = lazyCompile(SessionsReclaimParamsSchema);
export const validateSessionsReclaimResult = lazyCompile(SessionsReclaimResultSchema);
export const validateSessionsMessagesSubscribeParams = lazyCompile(
SessionsMessagesSubscribeParamsSchema,
);
@@ -810,9 +795,7 @@ export const validateWizardNextParams = lazyCompile(WizardNextParamsSchema);
export const validateWizardCancelParams = lazyCompile(WizardCancelParamsSchema);
export const validateWizardStatusParams = lazyCompile(WizardStatusParamsSchema);
export const validateTalkModeParams = lazyCompile(TalkModeParamsSchema);
export const validateTalkEvent = lazyCompile(TalkEventSchema);
export const validateTalkCatalogParams = lazyCompile(TalkCatalogParamsSchema);
export const validateTalkCatalogResult = lazyCompile(TalkCatalogResultSchema);
export const validateTalkConfigParams = lazyCompile(TalkConfigParamsSchema);
export const validateTalkConfigResult = lazyCompile(TalkConfigResultSchema);
export const validateTalkClientCreateParams = lazyCompile(TalkClientCreateParamsSchema);
@@ -823,11 +806,8 @@ export const validateTalkClientToolCallParams = lazyCompile(TalkClientToolCallPa
export const validateTalkClientToolCallResult = lazyCompile(TalkClientToolCallResultSchema);
export const validateTalkClientTranscriptParams = lazyCompile(TalkClientTranscriptParamsSchema);
export const validateTalkClientSteerParams = lazyCompile(TalkClientSteerParamsSchema);
export const validateTalkAgentControlResult = lazyCompile(TalkAgentControlResultSchema);
export const validateTalkSessionCreateParams = lazyCompile(TalkSessionCreateParamsSchema);
export const validateTalkSessionCreateResult = lazyCompile(TalkSessionCreateResultSchema);
export const validateTalkSessionJoinParams = lazyCompile(TalkSessionJoinParamsSchema);
export const validateTalkSessionJoinResult = lazyCompile(TalkSessionJoinResultSchema);
export const validateTalkSessionAppendAudioParams = lazyCompile(TalkSessionAppendAudioParamsSchema);
export const validateTalkSessionAcknowledgeMarkParams = lazyCompile(
TalkSessionAcknowledgeMarkParamsSchema,
@@ -837,17 +817,13 @@ export const validateTalkSessionCancelTurnParams = lazyCompile(TalkSessionCancel
export const validateTalkSessionCancelOutputParams = lazyCompile(
TalkSessionCancelOutputParamsSchema,
);
export const validateTalkSessionTurnResult = lazyCompile(TalkSessionTurnResultSchema);
export const validateTalkSessionSteerParams = lazyCompile(TalkSessionSteerParamsSchema);
export const validateTalkSessionSubmitToolResultParams = lazyCompile(
TalkSessionSubmitToolResultParamsSchema,
);
export const validateTalkSessionCloseParams = lazyCompile(TalkSessionCloseParamsSchema);
export const validateTalkSessionOkResult = lazyCompile(TalkSessionOkResultSchema);
export const validateTalkSpeakParams = lazyCompile(TalkSpeakParamsSchema);
export const validateTalkSpeakResult = lazyCompile(TalkSpeakResultSchema);
export const validateTtsSpeakParams = lazyCompile(TtsSpeakParamsSchema);
export const validateTtsSpeakResult = lazyCompile(TtsSpeakResultSchema);
export const validateChannelsStatusParams = lazyCompile(ChannelsStatusParamsSchema);
export const validateChannelsStartParams = lazyCompile(ChannelsStartParamsSchema);
export const validateChannelsStopParams = lazyCompile(ChannelsStopParamsSchema);
@@ -894,21 +870,7 @@ export const validateDevicePairSetupCodeParams = lazyCompile(DevicePairSetupCode
export const validateDevicePairRenameParams = lazyCompile(DevicePairRenameParamsSchema);
export const validateDeviceTokenRotateParams = lazyCompile(DeviceTokenRotateParamsSchema);
export const validateDeviceTokenRevokeParams = lazyCompile(DeviceTokenRevokeParamsSchema);
export const validateApprovalKind = lazyCompile(ApprovalKindSchema);
export const validateApprovalDecision = lazyCompile(ApprovalDecisionSchema);
export const validateApprovalAllowDecision = lazyCompile(ApprovalAllowDecisionSchema);
export const validateApprovalTerminalReason = lazyCompile(ApprovalTerminalReasonSchema);
export const validatePluginApprovalSeverity = lazyCompile(PluginApprovalSeveritySchema);
export const validateExecApprovalPresentation = lazyCompile(ExecApprovalPresentationSchema);
export const validatePluginApprovalPresentation = lazyCompile(PluginApprovalPresentationSchema);
export const validateApprovalPresentation = lazyCompile(ApprovalPresentationSchema);
export const validatePendingApprovalSnapshot = lazyCompile(PendingApprovalSnapshotSchema);
export const validateAllowedApprovalSnapshot = lazyCompile(AllowedApprovalSnapshotSchema);
export const validateDeniedApprovalSnapshot = lazyCompile(DeniedApprovalSnapshotSchema);
export const validateExpiredApprovalSnapshot = lazyCompile(ExpiredApprovalSnapshotSchema);
export const validateCancelledApprovalSnapshot = lazyCompile(CancelledApprovalSnapshotSchema);
export const validateApprovalSnapshot = lazyCompile(ApprovalSnapshotSchema);
export const validateTerminalApprovalSnapshot = lazyCompile(TerminalApprovalSnapshotSchema);
export const validateApprovalGetParams = lazyCompile(ApprovalGetParamsSchema);
export const validateApprovalHistoryParams = lazyCompile(ApprovalHistoryParamsSchema);
export const validateApprovalResolveParams = lazyCompile(ApprovalResolveParamsSchema);
@@ -918,31 +880,18 @@ export const validateExecApprovalGetParams = lazyCompile(ExecApprovalGetParamsSc
export const validateExecApprovalRequestParams = lazyCompile(ExecApprovalRequestParamsSchema);
export const validateExecApprovalResolveParams = lazyCompile(ExecApprovalResolveParamsSchema);
export const validateQuestionRequestParams = lazyCompile(QuestionRequestParamsSchema);
export const validateQuestionRequestResult = lazyCompile(QuestionRequestResultSchema);
export const validateQuestionWaitAnswerParams = lazyCompile(QuestionWaitAnswerParamsSchema);
export const validateQuestionWaitAnswerResult = lazyCompile(QuestionWaitAnswerResultSchema);
export const validateQuestionResolveParams = lazyCompile(QuestionResolveParamsSchema);
export const validateQuestionResolveResult = lazyCompile(QuestionResolveResultSchema);
export const validateQuestionGetParams = lazyCompile(QuestionGetParamsSchema);
export const validateQuestionGetResult = lazyCompile(QuestionGetResultSchema);
export const validateQuestionListParams = lazyCompile(QuestionListParamsSchema);
export const validateQuestionListResult = lazyCompile(QuestionListResultSchema);
export const validateQuestionRequestedEvent = lazyCompile(QuestionRequestedEventSchema);
export const validateQuestionResolvedEvent = lazyCompile(QuestionResolvedEventSchema);
export const validatePluginApprovalRequestParams = lazyCompile(PluginApprovalRequestParamsSchema);
export const validatePluginApprovalResolveParams = lazyCompile(PluginApprovalResolveParamsSchema);
export const validatePluginsListParams = lazyCompile(PluginsListParamsSchema);
export const validatePluginsListResult = lazyCompile(PluginsListResultSchema);
export const validatePluginsRefreshParams = lazyCompile(PluginsRefreshParamsSchema);
export const validatePluginsRefreshResult = lazyCompile(PluginsRefreshResultSchema);
export const validatePluginsSearchParams = lazyCompile(PluginsSearchParamsSchema);
export const validatePluginsSearchResult = lazyCompile(PluginsSearchResultSchema);
export const validatePluginsInstallParams = lazyCompile(PluginsInstallParamsSchema);
export const validatePluginsInstallResult = lazyCompile(PluginsInstallResultSchema);
export const validatePluginsSetEnabledParams = lazyCompile(PluginsSetEnabledParamsSchema);
export const validatePluginsSetEnabledResult = lazyCompile(PluginsSetEnabledResultSchema);
export const validatePluginsUninstallParams = lazyCompile(PluginsUninstallParamsSchema);
export const validatePluginsUninstallResult = lazyCompile(PluginsUninstallResultSchema);
export const validatePluginsUiDescriptorsParams = lazyCompile(PluginsUiDescriptorsParamsSchema);
export const validatePluginsUiDescriptorsResult = lazyCompile(PluginsUiDescriptorsResultSchema);
export const validatePluginsSessionActionParams = lazyCompile(PluginsSessionActionParamsSchema);
@@ -959,8 +908,6 @@ export const validateChatToolTitlesParams = lazyCompile(ChatToolTitlesParamsSche
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
export const validateChatAbortParams = lazyCompile(ChatAbortParamsSchema);
export const validateChatInjectParams = lazyCompile(ChatInjectParamsSchema);
export const validateChatEvent = lazyCompile(ChatEventSchema);
export const validateChatMessageGetResult = lazyCompile(ChatMessageGetResultSchema);
export const validateUpdateStatusParams = lazyCompile(UpdateStatusParamsSchema);
export const validateUpdateRunParams = lazyCompile(UpdateRunParamsSchema);
export const validateUiCommandParams = lazyCompile(UiCommandParamsSchema);
@@ -1098,6 +1045,7 @@ export {
SessionCatalogDescriptorSchema,
SessionCatalogSessionSchema,
SessionCatalogHostSchema,
SessionCatalogLocatorSchema,
SessionCatalogSchema,
SessionCatalogTranscriptItemSchema,
SessionsCatalogListParamsSchema,
@@ -1,89 +1,27 @@
import { describe, expect, it } from "vitest";
import {
validatePluginsInstallParams,
validatePluginsInstallResult,
validatePluginsListParams,
validatePluginsListResult,
validatePluginsRefreshParams,
validatePluginsRefreshResult,
validatePluginsSearchParams,
validatePluginsSearchResult,
validatePluginsSetEnabledParams,
validatePluginsSetEnabledResult,
validatePluginsUninstallParams,
validatePluginsUninstallResult,
} from "./index.js";
const installedPlugin = {
id: "workboard",
name: "Workboard",
packageName: "@openclaw/workboard",
description: "Coordinate work across agents",
version: "1.0.0",
kind: ["tool"],
origin: "bundled",
installed: true,
enabled: false,
state: "disabled",
featured: true,
featuredAt: 1_784_280_000_000,
order: 10,
install: { source: "official", pluginId: "workboard" },
category: "tool",
removable: false,
} as const;
describe("plugin lifecycle protocol validators", () => {
it("validates plugin metadata refresh payloads", () => {
it("validates plugin metadata refresh params", () => {
expect(validatePluginsRefreshParams({})).toBe(true);
expect(validatePluginsRefreshParams({ unexpected: true })).toBe(false);
expect(validatePluginsRefreshResult({ ok: true })).toBe(true);
expect(validatePluginsRefreshResult({ ok: false })).toBe(false);
});
it("accepts cold catalog payloads and rejects runtime-only states", () => {
it("keeps list params closed", () => {
expect(validatePluginsListParams({})).toBe(true);
expect(validatePluginsListParams({ unexpected: true })).toBe(false);
expect(
validatePluginsListResult({
plugins: [installedPlugin],
diagnostics: [],
mutationAllowed: true,
}),
).toBe(true);
expect(
validatePluginsListResult({
plugins: [{ ...installedPlugin, state: "loaded" }],
diagnostics: [],
mutationAllowed: true,
}),
).toBe(false);
});
it("validates bounded plugin search requests and projected results", () => {
it("validates bounded plugin search requests", () => {
expect(validatePluginsSearchParams({ query: "memory", limit: 20 })).toBe(true);
expect(validatePluginsSearchParams({ query: "memory", limit: 101 })).toBe(false);
expect(
validatePluginsSearchResult({
results: [
{
score: 0.95,
package: {
name: "memory-plus",
displayName: "Memory Plus",
family: "code-plugin",
channel: "community",
isOfficial: false,
summary: "Long-term memory tools",
latestVersion: "2.1.0",
runtimeId: "memory-plus",
downloads: 1420,
verificationTier: "source-linked",
},
},
],
}),
).toBe(true);
});
it("keeps official and ClawHub install requests distinct", () => {
@@ -103,49 +41,16 @@ describe("plugin lifecycle protocol validators", () => {
packageName: "memory-plus",
}),
).toBe(false);
expect(
validatePluginsInstallResult({
ok: true,
plugin: { ...installedPlugin, enabled: true, state: "enabled" },
restartRequired: true,
warnings: ["Restart the gateway to load this plugin."],
}),
).toBe(true);
});
it("validates uninstall requests and removal summaries", () => {
it("validates uninstall requests", () => {
expect(validatePluginsUninstallParams({ pluginId: "memory-plus" })).toBe(true);
expect(validatePluginsUninstallParams({ pluginId: "" })).toBe(false);
expect(validatePluginsUninstallParams({})).toBe(false);
expect(
validatePluginsUninstallResult({
ok: true,
pluginId: "memory-plus",
restartRequired: true,
removed: ["config entry", "install record", "directory"],
warnings: ["npm prune skipped"],
}),
).toBe(true);
expect(
validatePluginsUninstallResult({
ok: true,
pluginId: "memory-plus",
restartRequired: false,
removed: [],
}),
).toBe(false);
});
it("validates enablement mutations and dynamic restart metadata", () => {
it("validates enablement mutations", () => {
expect(validatePluginsSetEnabledParams({ pluginId: "workboard", enabled: true })).toBe(true);
expect(validatePluginsSetEnabledParams({ pluginId: "workboard", enabled: "yes" })).toBe(false);
expect(
validatePluginsSetEnabledResult({
ok: true,
plugin: { ...installedPlugin, enabled: true, state: "enabled" },
restartRequired: false,
warnings: ['Exclusive slot "memory" switched to "memory-plus".'],
}),
).toBe(true);
});
});
@@ -19,6 +19,7 @@ export function lazyCompile<T>(
schema: TSchema,
precheck?: (data: unknown) => ValidationError | undefined,
): ProtocolValidator<T>;
/* @__NO_SIDE_EFFECTS__ */
export function lazyCompile<T = unknown>(
schema: TSchema,
precheck?: (data: unknown) => ValidationError | undefined,
@@ -4,6 +4,7 @@ export type {
SessionCatalogCapabilities,
SessionCatalogDescriptor,
SessionCatalogHost,
SessionCatalogLocator,
SessionCatalogSession,
SessionCatalogTranscriptItem,
SessionsCatalogArchiveParams,
@@ -1,19 +1,12 @@
import { describe, expect, it } from "vitest";
import {
validateQuestionGetResult,
validateQuestionListResult,
validateQuestionRequestedEvent,
validateQuestionRequestParams,
validateQuestionRequestResult,
validateQuestionResolvedEvent,
validateQuestionResolveParams,
validateQuestionResolveResult,
validateQuestionWaitAnswerParams,
validateQuestionWaitAnswerResult,
} from "./index.js";
const question = {
id: "choice",
questionId: "choice",
header: "Choice",
question: "Which option?",
options: [{ label: "One", description: "First" }, { label: "Two" }],
@@ -21,19 +14,10 @@ const question = {
isOther: true,
isSecret: false,
};
const answers = { answers: { choice: { answers: ["Two"] } } };
const pendingRecord = {
id: "question-uuid",
questions: [question],
agentId: "main",
sessionKey: "agent:main:main",
createdAtMs: 1,
expiresAtMs: 2,
status: "pending",
};
const answers = { answers: { choice: ["Two"] } };
describe("question protocol validators", () => {
it("round-trips method params and results", () => {
it("validates method params", () => {
expect(
validateQuestionRequestParams({
id: "client-question-id",
@@ -41,36 +25,16 @@ describe("question protocol validators", () => {
timeoutMs: 100,
}),
).toBe(true);
expect(validateQuestionRequestResult({ id: "question-uuid", expiresAtMs: 2 })).toBe(true);
expect(validateQuestionWaitAnswerParams({ id: "question-uuid", timeoutMs: 50 })).toBe(true);
expect(validateQuestionWaitAnswerResult({ status: "pending" })).toBe(true);
expect(validateQuestionWaitAnswerResult({ status: "answered", answers })).toBe(true);
expect(validateQuestionResolveParams({ id: "question-uuid", answers })).toBe(true);
expect(validateQuestionResolveParams({ id: "question-uuid", cancel: true })).toBe(true);
expect(validateQuestionResolveResult({ status: "cancelled" })).toBe(true);
expect(validateQuestionGetResult({ question: pendingRecord })).toBe(true);
expect(validateQuestionListResult({ questions: [pendingRecord] })).toBe(true);
});
it("round-trips requested and resolved events", () => {
expect(validateQuestionRequestedEvent(pendingRecord)).toBe(true);
expect(
validateQuestionResolvedEvent({ id: "question-uuid", status: "answered", answers }),
).toBe(true);
expect(validateQuestionResolvedEvent({ id: "question-uuid", status: "expired" })).toBe(true);
});
it("keeps records normalized while allowing request-boundary header truncation", () => {
it("enforces the shared question header cap", () => {
expect(
validateQuestionRequestParams({
questions: [{ ...question, header: "longer than twelve" }],
}),
).toBe(true);
expect(
validateQuestionRequestedEvent({
...pendingRecord,
questions: [{ ...question, header: "longer than twelve" }],
}),
).toBe(false);
expect(validateQuestionRequestParams({ questions: [] })).toBe(false);
expect(
@@ -23,7 +23,7 @@ const AGENT_INTERNAL_EVENT_STATUSES = ["ok", "timeout", "error", "unknown"] as c
const CONVERSATION_REF_PATTERN = "^conv_[a-f0-9]{32}$";
/** Generated media/file attachment metadata carried by internal agent events. */
export const AgentGeneratedAttachmentSchema = closedObject({
const AgentGeneratedAttachmentSchema = closedObject({
type: Type.Optional(Type.String({ enum: ["image", "audio", "video", "file"] })),
path: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
@@ -34,7 +34,7 @@ export const AgentGeneratedAttachmentSchema = closedObject({
});
/** Internal completion event surfaced when child automation reports back to a parent run. */
export const AgentInternalEventSchema = closedObject({
const AgentInternalEventSchema = closedObject({
type: Type.Literal(AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION),
source: Type.String({ enum: [...AGENT_INTERNAL_EVENT_SOURCES] }),
childSessionKey: Type.String(),
@@ -62,7 +62,7 @@ export const AgentEventSchema = closedObject({
});
/** Caller-supplied routing hints. Authorization must use trusted runtime context. */
export const MessageActionToolContextSchema = closedObject({
const MessageActionToolContextSchema = closedObject({
currentChannelId: Type.Optional(Type.String()),
currentMessagingTarget: Type.Optional(Type.String()),
currentGraphChannelId: Type.Optional(Type.String()),
@@ -14,7 +14,7 @@ import { NonEmptyString } from "./primitives.js";
*/
/** Model option shown in selectors and model catalog results. */
export const GatewayAgentRuntimeSchema = closedObject({
const GatewayAgentRuntimeSchema = closedObject({
id: NonEmptyString,
fallback: Type.Optional(Type.Union([Type.Literal("openclaw"), Type.Literal("none")])),
source: Type.Union([
@@ -4,6 +4,7 @@ import { Type } from "typebox";
import { APPROVAL_ID_WELL_FORMED_UNICODE_PATTERN } from "./approval-id.js";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
import { withSince } from "./since.js";
export { isWellFormedApprovalId } from "./approval-id.js";
@@ -260,32 +261,41 @@ const SessionApprovalEventCommonFields = {
};
/** Sanitized pending transition delivered only to an opted-in session audience. */
export const PendingSessionApprovalEventSchema = closedObject({
...SessionApprovalEventCommonFields,
phase: Type.Literal("pending"),
approval: PendingApprovalSnapshotSchema,
});
export const PendingSessionApprovalEventSchema = withSince(
"2026.7",
closedObject({
...SessionApprovalEventCommonFields,
phase: Type.Literal("pending"),
approval: PendingApprovalSnapshotSchema,
}),
);
/** Sanitized terminal transition delivered only to an opted-in session audience. */
export const TerminalSessionApprovalEventSchema = closedObject({
...SessionApprovalEventCommonFields,
phase: Type.Literal("terminal"),
approval: TerminalApprovalSnapshotSchema,
});
export const TerminalSessionApprovalEventSchema = withSince(
"2026.7",
closedObject({
...SessionApprovalEventCommonFields,
phase: Type.Literal("terminal"),
approval: TerminalApprovalSnapshotSchema,
}),
);
/** Sanitized approval transition delivered only to an opted-in session audience. */
export const SessionApprovalEventSchema = Type.Union([
PendingSessionApprovalEventSchema,
TerminalSessionApprovalEventSchema,
]);
export const SessionApprovalEventSchema = withSince(
"2026.7",
Type.Union([PendingSessionApprovalEventSchema, TerminalSessionApprovalEventSchema]),
);
/** Authoritative pending approval set returned when a session stream subscribes. */
export const SessionApprovalReplaySchema = closedObject({
sessionKey: NonEmptyString,
updatedAtMs: Type.Integer({ minimum: 0 }),
approvals: Type.Array(PendingApprovalSnapshotSchema),
truncated: Type.Boolean(),
});
export const SessionApprovalReplaySchema = withSince(
"2026.7",
closedObject({
sessionKey: NonEmptyString,
updatedAtMs: Type.Integer({ minimum: 0 }),
approvals: Type.Array(PendingApprovalSnapshotSchema),
truncated: Type.Boolean(),
}),
);
// Owner-local wire types derived directly from local schema consts so the
// public plugin-sdk declaration graph never pulls in the ProtocolSchemas registry.
@@ -5,7 +5,7 @@ import { NonEmptyString } from "./primitives.js";
const AuditActivitySchemaVersionV1Schema = Type.Integer({ minimum: 1, maximum: 1 });
export const AuditActivityStatusV1Schema: TSchema = Type.Union([
const AuditActivityStatusV1Schema: TSchema = Type.Union([
Type.Literal("started"),
Type.Literal("succeeded"),
Type.Literal("failed"),
@@ -15,13 +15,13 @@ export const AuditActivityStatusV1Schema: TSchema = Type.Union([
Type.Literal("unknown"),
]);
export const AuditActivityKindV1Schema: TSchema = Type.Union([
const AuditActivityKindV1Schema: TSchema = Type.Union([
Type.Literal("agent_run"),
Type.Literal("tool_action"),
Type.Literal("message"),
]);
export const AuditActivityDirectionV1Schema: TSchema = Type.Union([
const AuditActivityDirectionV1Schema: TSchema = Type.Union([
Type.Literal("inbound"),
Type.Literal("outbound"),
]);
@@ -4,19 +4,16 @@ import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
export const AuditEventKindSchema = Type.Union([
Type.Literal("agent_run"),
Type.Literal("tool_action"),
]);
const AuditEventKindSchema = Type.Union([Type.Literal("agent_run"), Type.Literal("tool_action")]);
export const AuditEventActionSchema = Type.Union([
const AuditEventActionSchema = Type.Union([
Type.Literal("agent.run.started"),
Type.Literal("agent.run.finished"),
Type.Literal("tool.action.started"),
Type.Literal("tool.action.finished"),
]);
export const AuditEventStatusSchema = Type.Union([
const AuditEventStatusSchema = Type.Union([
Type.Literal("started"),
Type.Literal("succeeded"),
Type.Literal("failed"),
@@ -26,7 +23,7 @@ export const AuditEventStatusSchema = Type.Union([
Type.Literal("unknown"),
]);
export const AuditEventErrorCodeSchema = Type.Union([
const AuditEventErrorCodeSchema = Type.Union([
Type.Literal("run_failed"),
Type.Literal("run_cancelled"),
Type.Literal("run_timed_out"),
@@ -644,7 +644,7 @@ export const ChannelsStatusParamsSchema = closedObject({
* This is intentionally schema-light so new channel-specific metadata can ship
* without a gateway protocol update; known fields stay documented for UI use.
*/
export const ChannelAccountSnapshotSchema = Type.Object(
const ChannelAccountSnapshotSchema = Type.Object(
{
accountId: NonEmptyString,
name: Type.Optional(Type.String()),
@@ -685,7 +685,7 @@ export const ChannelAccountSnapshotSchema = Type.Object(
);
/** UI label and icon metadata for one channel. */
export const ChannelUiMetaSchema = closedObject({
const ChannelUiMetaSchema = closedObject({
id: NonEmptyString,
label: NonEmptyString,
detailLabel: NonEmptyString,
@@ -693,7 +693,7 @@ export const ChannelUiMetaSchema = closedObject({
});
/** Event-loop health snapshot included with channel status responses. */
export const ChannelEventLoopHealthSchema = closedObject({
const ChannelEventLoopHealthSchema = closedObject({
degraded: Type.Boolean(),
reasons: Type.Array(
Type.Union([
@@ -34,21 +34,21 @@ export const COMMAND_LIST_MAX_ITEMS = 500;
const BoundedNonEmptyString = (maxLength: number) => Type.String({ minLength: 1, maxLength });
/** Source system that contributed a command. */
export const CommandSourceSchema = Type.Union([
const CommandSourceSchema = Type.Union([
Type.Literal("native"),
Type.Literal("skill"),
Type.Literal("plugin"),
]);
/** Surfaces where a command may be invoked. */
export const CommandScopeSchema = Type.Union([
const CommandScopeSchema = Type.Union([
Type.Literal("text"),
Type.Literal("native"),
Type.Literal("both"),
]);
/** Coarse UI grouping for command catalog display. */
export const CommandCategorySchema = Type.Union([
const CommandCategorySchema = Type.Union([
Type.Literal("session"),
Type.Literal("options"),
Type.Literal("status"),
@@ -59,13 +59,13 @@ export const CommandCategorySchema = Type.Union([
]);
/** Static argument choice shown to clients. */
export const CommandArgChoiceSchema = closedObject({
const CommandArgChoiceSchema = closedObject({
value: Type.String({ maxLength: COMMAND_CHOICE_VALUE_MAX_LENGTH }),
label: Type.String({ maxLength: COMMAND_CHOICE_LABEL_MAX_LENGTH }),
});
/** One typed argument advertised for a command. */
export const CommandArgSchema = closedObject({
const CommandArgSchema = closedObject({
name: BoundedNonEmptyString(COMMAND_ARG_NAME_MAX_LENGTH),
description: Type.String({ maxLength: COMMAND_ARG_DESCRIPTION_MAX_LENGTH }),
type: Type.Union([Type.Literal("string"), Type.Literal("number"), Type.Literal("boolean")]),
@@ -74,7 +74,7 @@ export const UpdateRunParamsSchema = closedObject({
});
/** UI metadata attached to config schema paths. */
export const ConfigUiHintSchema = closedObject({
const ConfigUiHintSchema = closedObject({
label: Type.Optional(Type.String()),
help: Type.Optional(Type.String()),
tags: Type.Optional(Type.Array(Type.String())),
@@ -95,7 +95,7 @@ export const ConfigSchemaResponseSchema = closedObject({
});
/** Child entry returned when looking up a config schema path. */
export const ConfigSchemaLookupChildSchema = closedObject({
const ConfigSchemaLookupChildSchema = closedObject({
key: NonEmptyString,
path: NonEmptyString,
type: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])),
+9 -9
View File
@@ -203,7 +203,7 @@ const CronRunLogJobIdSchema = Type.String({
});
/** Schedule expression for one-time, interval, or cron-expression jobs. */
export const CronScheduleSchema = Type.Union([
const CronScheduleSchema = Type.Union([
closedObject({
kind: Type.Literal("at"),
at: NonEmptyString,
@@ -230,7 +230,7 @@ export const CronScheduleSchema = Type.Union([
]);
/** Headless condition script evaluated before a recurring cron payload runs. */
export const CronTriggerSchema = closedObject({
const CronTriggerSchema = closedObject({
script: Type.String({ minLength: 1, maxLength: 65_536 }),
once: Type.Optional(Type.Boolean()),
});
@@ -248,7 +248,7 @@ export const CronPacingSchema = Type.Object(
);
/** Full cron payload for new jobs. */
export const CronPayloadSchema = Type.Union([
const CronPayloadSchema = Type.Union([
closedObject({
kind: Type.Literal("systemEvent"),
text: NonEmptyString,
@@ -273,7 +273,7 @@ export const CronPayloadSchema = Type.Union([
]);
/** Partial cron payload for job updates. */
export const CronPayloadPatchSchema = Type.Union([
const CronPayloadPatchSchema = Type.Union([
closedObject({
kind: Type.Literal("systemEvent"),
text: Type.Optional(NonEmptyString),
@@ -298,7 +298,7 @@ export const CronPayloadPatchSchema = Type.Union([
]);
/** Failure alert policy for repeated cron run failures. */
export const CronFailureAlertSchema = closedObject({
const CronFailureAlertSchema = closedObject({
after: Type.Optional(Type.Integer({ minimum: 1 })),
channel: Type.Optional(CronAnnounceChannelSchema),
to: Type.Optional(NonBlankString),
@@ -319,7 +319,7 @@ const CronFailureAlertPatchSchema = closedObject({
});
/** Delivery destination used when failure alerts need a separate target. */
export const CronFailureDestinationSchema = closedObject({
const CronFailureDestinationSchema = closedObject({
channel: Type.Optional(CronAnnounceChannelSchema),
to: Type.Optional(NonBlankString),
accountId: Type.Optional(NonEmptyString),
@@ -333,7 +333,7 @@ const CronFailureDestinationPatchSchema = closedObject({
mode: Type.Optional(Type.Union([Type.Literal("announce"), Type.Literal("webhook"), Type.Null()])),
});
export const CronCompletionDestinationSchema = closedObject({
const CronCompletionDestinationSchema = closedObject({
mode: Type.Literal("webhook"),
to: NonBlankString,
});
@@ -381,7 +381,7 @@ export const CronDeliverySchema = Type.Union([
]);
/** Patch shape for cron delivery policy updates. */
export const CronDeliveryPatchSchema = closedObject({
const CronDeliveryPatchSchema = closedObject({
mode: Type.Optional(
Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("webhook")]),
),
@@ -533,7 +533,7 @@ export const CronDeclarativeAddResultSchema = closedObject({
export const CronAddResultSchema = Type.Union([CronJobSchema, CronDeclarativeAddResultSchema]);
/** Mutable cron job fields accepted by update APIs. */
export const CronJobPatchSchema = closedObject({
const CronJobPatchSchema = closedObject({
name: Type.Optional(NonEmptyString),
displayName: Type.Optional(Type.Union([CronDisplayNameSchema, Type.Null()])),
...CronCommonOptionalFields,
@@ -66,7 +66,7 @@ export const EnvironmentSummarySchema = createEnvironmentSummarySchema();
export const EnvironmentsListParamsSchema = closedObject({});
/** Configured worker target exposed without provider settings or credentials. */
export const WorkerEnvironmentProfileSummarySchema = closedObject({
const WorkerEnvironmentProfileSummarySchema = closedObject({
id: NonEmptyString,
providerId: NonEmptyString,
});
@@ -105,7 +105,6 @@ export type EnvironmentStatus = Static<typeof EnvironmentStatusSchema>;
export type WorkerEnvironmentState = Static<typeof WorkerEnvironmentStateSchema>;
export type WorkerTunnelStatus = Static<typeof WorkerTunnelStatusSchema>;
export type WorkerEnvironmentMetadata = Static<typeof WorkerEnvironmentMetadataSchema>;
export type WorkerEnvironmentProfileSummary = Static<typeof WorkerEnvironmentProfileSummarySchema>;
export type EnvironmentSummary = Static<typeof EnvironmentSummarySchema>;
export type EnvironmentsCreateParams = Static<typeof EnvironmentsCreateParamsSchema>;
export type EnvironmentsCreateResult = Static<typeof EnvironmentsCreateResultSchema>;
@@ -11,7 +11,7 @@ import { NonEmptyString } from "./primitives.js";
* persisted policy, request snapshots, and resolve decisions stay explicit.
*/
/** One persisted allowlist entry for a command pattern or resolved executable. */
export const ExecApprovalsAllowlistEntrySchema = closedObject({
const ExecApprovalsAllowlistEntrySchema = closedObject({
id: Type.Optional(NonEmptyString),
pattern: Type.String(),
source: Type.Optional(Type.Literal("allow-always")),
@@ -49,16 +49,16 @@ const ExecApprovalsResolvedDefaultsSchema = closedObject({
});
/** Default exec approval policy shared by all agents unless overridden. */
export const ExecApprovalsDefaultsSchema = closedObject(ExecApprovalsPolicyFields);
const ExecApprovalsDefaultsSchema = closedObject(ExecApprovalsPolicyFields);
/** Agent-specific exec approval policy and allowlist. */
export const ExecApprovalsAgentSchema = closedObject({
const ExecApprovalsAgentSchema = closedObject({
...ExecApprovalsPolicyFields,
allowlist: Type.Optional(Type.Array(ExecApprovalsAllowlistEntrySchema)),
});
/** Versioned exec approvals config file edited through gateway APIs. */
export const ExecApprovalsFileSchema = closedObject({
const ExecApprovalsFileSchema = closedObject({
version: Type.Literal(1),
socket: Type.Optional(
closedObject({
@@ -86,12 +86,11 @@ export type ChatMessageGetResult = Static<typeof ChatMessageGetResultSchema>;
export const ChatAttachmentsSchema = Type.Array(Type.Unknown());
/** Opaque, out-of-band plugin bindings carried separately from model input. */
export const RunToolBindingsSchema = Type.Record(
const RunToolBindingsSchema = Type.Record(
Type.String({ minLength: 1, maxLength: 128 }),
Type.Unknown(),
{ maxProperties: 16 },
);
export type RunToolBindings = Static<typeof RunToolBindingsSchema>;
/** User-to-agent send request; idempotency key lets clients safely retry transport failures. */
export const ChatSendParamsSchema = closedObject({
@@ -10,7 +10,7 @@ const MemoryMigrationPlanFingerprintSchema = Type.String({
pattern: "^[a-f0-9]{64}$",
});
export const MemoryMigrationItemStatusSchema = Type.Union([
const MemoryMigrationItemStatusSchema = Type.Union([
Type.Literal("planned"),
Type.Literal("migrated"),
Type.Literal("skipped"),
@@ -19,7 +19,7 @@ export const MemoryMigrationItemStatusSchema = Type.Union([
Type.Literal("error"),
]);
export const MemoryMigrationItemSchema = Type.Object(
const MemoryMigrationItemSchema = Type.Object(
{
id: NonEmptyString,
status: MemoryMigrationItemStatusSchema,
@@ -32,7 +32,7 @@ export const MemoryMigrationItemSchema = Type.Object(
{ additionalProperties: false },
);
export const MemoryMigrationSummarySchema = Type.Object(
const MemoryMigrationSummarySchema = Type.Object(
{
total: Type.Integer({ minimum: 0 }),
planned: Type.Integer({ minimum: 0 }),
@@ -45,7 +45,7 @@ export const MemoryMigrationSummarySchema = Type.Object(
{ additionalProperties: false },
);
export const MemoryMigrationProviderPlanSchema = Type.Object(
const MemoryMigrationProviderPlanSchema = Type.Object(
{
providerId: NonEmptyString,
label: NonEmptyString,
@@ -74,7 +74,7 @@ export const MigrationsMemoryPlanParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const MigrationsMemoryPlanResultSchema = Type.Object(
const MigrationsMemoryPlanResultSchema = Type.Object(
{
agentId: NonEmptyString,
workspace: NonEmptyString,
@@ -99,7 +99,7 @@ export const MigrationsMemoryApplyParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const MigrationsMemoryApplyResultSchema = Type.Object(
const MigrationsMemoryApplyResultSchema = Type.Object(
{
providerId: NonEmptyString,
source: NonEmptyString,
@@ -124,10 +124,7 @@ export const MigrationProtocolSchemas = {
MigrationsMemoryApplyResult: MigrationsMemoryApplyResultSchema,
} as const;
export type MemoryMigrationItemStatus = Static<typeof MemoryMigrationItemStatusSchema>;
export type MemoryMigrationItem = Static<typeof MemoryMigrationItemSchema>;
export type MemoryMigrationProviderPlan = Static<typeof MemoryMigrationProviderPlanSchema>;
export type MigrationsMemoryPlanParams = Static<typeof MigrationsMemoryPlanParamsSchema>;
export type MigrationsMemoryPlanResult = Static<typeof MigrationsMemoryPlanResultSchema>;
export type MigrationsMemoryApplyParams = Static<typeof MigrationsMemoryApplyParamsSchema>;
export type MigrationsMemoryApplyResult = Static<typeof MigrationsMemoryApplyResultSchema>;
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateNodeInvokeInputEvent } from "../index.js";
describe("node.invoke.input protocol", () => {
it("accepts ordered bounded frames and rejects oversized or open-ended payloads", () => {
expect(
validateNodeInvokeInputEvent({
id: "invoke-1",
nodeId: "node-1",
seq: 0,
payloadJSON: '{"kind":"data","data":"x"}',
}),
).toBe(true);
expect(
validateNodeInvokeInputEvent({
id: "invoke-1",
nodeId: "node-1",
seq: 0,
payloadJSON: "x".repeat(16 * 1024 + 1),
}),
).toBe(false);
expect(
validateNodeInvokeInputEvent({
id: "invoke-1",
nodeId: "node-1",
seq: 0,
payloadJSON: "{}",
argv: ["sh"],
}),
).toBe(false);
});
});
@@ -185,7 +185,7 @@ export const NodePendingDrainParamsSchema = closedObject({
});
/** One queued node-work item returned by pending-work drain calls. */
export const NodePendingDrainItemSchema = closedObject({
const NodePendingDrainItemSchema = closedObject({
id: NonEmptyString,
type: NodePendingWorkTypeSchema,
priority: Type.String({ enum: ["default", "normal", "high"] }),
@@ -49,13 +49,6 @@ export const GatewayClientIdSchema = Type.Enum(GATEWAY_CLIENT_IDS);
/** Closed gateway client mode schema aligned with `GATEWAY_CLIENT_MODES`. */
export const GatewayClientModeSchema = Type.Enum(GATEWAY_CLIENT_MODES);
/** Supported secret reference backing stores for protocol SecretRef payloads. */
export const SecretRefSourceSchema = Type.Union([
Type.Literal("env"),
Type.Literal("file"),
Type.Literal("exec"),
]);
const SecretProviderAliasString = Type.String({
pattern: SECRET_PROVIDER_ALIAS_PATTERN.source,
});
@@ -3,8 +3,10 @@ import type { Static } from "typebox";
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
import { withSince } from "./since.js";
const QuestionIdSchema = Type.String({ pattern: "^[a-z][a-z0-9_]*$" });
// UI chip/tag display cap shared by every question input and output shape.
const QuestionHeaderSchema = Type.String({ maxLength: 12 });
export const QuestionOptionSchema = closedObject({
@@ -13,8 +15,8 @@ export const QuestionOptionSchema = closedObject({
});
const QuestionInputFields = {
id: QuestionIdSchema,
header: Type.String(),
questionId: QuestionIdSchema,
header: QuestionHeaderSchema,
question: NonEmptyString,
options: Type.Array(QuestionOptionSchema, { maxItems: 4 }),
multiSelect: Type.Optional(Type.Boolean()),
@@ -27,14 +29,13 @@ export const QuestionRequestQuestionSchema = closedObject(QuestionInputFields);
const QuestionFields = {
...QuestionInputFields,
header: QuestionHeaderSchema,
};
/** Canonical normalized question shown to an operator. */
export const QuestionSchema = closedObject(QuestionFields);
export const QuestionAnswersSchema = closedObject({
answers: Type.Record(QuestionIdSchema, closedObject({ answers: Type.Array(Type.String()) })),
answers: Type.Record(QuestionIdSchema, Type.Array(Type.String())),
});
export const QuestionStatusSchema = Type.Union([
@@ -112,16 +113,21 @@ export const QuestionListResultSchema = closedObject({
questions: Type.Array(QuestionRecordSchema),
});
export const QuestionRequestedEventSchema = QuestionRecordSchema;
export const QuestionResolvedEventSchema = Type.Union([
closedObject({
id: NonEmptyString,
status: Type.Literal("answered"),
answers: QuestionAnswersSchema,
}),
closedObject({ id: NonEmptyString, status: Type.Literal("cancelled") }),
closedObject({ id: NonEmptyString, status: Type.Literal("expired") }),
]);
// Native codegen intentionally reuses QuestionRecord for this event instead of
// registering a duplicate alias, so stamp the shared schema at the event site.
export const QuestionRequestedEventSchema = withSince("2026.7", QuestionRecordSchema);
export const QuestionResolvedEventSchema = withSince(
"2026.7",
Type.Union([
closedObject({
id: NonEmptyString,
status: Type.Literal("answered"),
answers: QuestionAnswersSchema,
}),
closedObject({ id: NonEmptyString, status: Type.Literal("cancelled") }),
closedObject({ id: NonEmptyString, status: Type.Literal("expired") }),
]),
);
export type QuestionOption = Static<typeof QuestionOptionSchema>;
export type Question = Static<typeof QuestionSchema>;
@@ -4,9 +4,7 @@ import {
SessionPlacementSchema,
SessionPlacementStateSchema,
validateSessionsDispatchParams,
validateSessionsDispatchResult,
validateSessionsReclaimParams,
validateSessionsReclaimResult,
} from "../index.js";
const placementStates = [
@@ -239,53 +237,6 @@ describe("session dispatch protocol schemas", () => {
).toBe(false);
});
it("accepts only active worker ownership in successful dispatch results", () => {
const active = {
state: "active" as const,
...basePlacement,
...workerOwnedFields,
};
expect(
validateSessionsDispatchResult({
ok: true,
key: "agent:main:dispatch",
sessionId: "session-1",
placement: active,
}),
).toBe(true);
expect(
validateSessionsDispatchResult({
ok: true,
key: "agent:main:dispatch",
sessionId: "session-1",
placement: {
state: "failed",
...basePlacement,
recoveryError: "worker admission failed",
},
}),
).toBe(false);
});
it("accepts only reclaimed ownership in successful reclaim results", () => {
expect(
validateSessionsReclaimResult({
ok: true,
key: "agent:main:dispatch",
sessionId: "session-1",
placement: { state: "reclaimed", ...basePlacement, ...workerOwnedFields },
}),
).toBe(true);
expect(
validateSessionsReclaimResult({
ok: true,
key: "agent:main:dispatch",
sessionId: "session-1",
placement: { state: "active", ...basePlacement, ...workerOwnedFields },
}),
).toBe(false);
});
it("rejects unknown placement fields", () => {
expect(
Value.Check(SessionPlacementSchema, {
@@ -297,21 +248,7 @@ describe("session dispatch protocol schemas", () => {
).toBe(false);
});
it("rejects extra fields in dispatch params and results", () => {
const active = {
state: "active" as const,
...basePlacement,
...workerOwnedFields,
};
expect(
validateSessionsDispatchResult({
ok: true,
key: "agent:main:dispatch",
sessionId: "session-1",
placement: active,
extra: true,
}),
).toBe(false);
it("rejects extra fields in dispatch params", () => {
expect(
validateSessionsDispatchParams({
key: "agent:main:dispatch",
@@ -6,7 +6,6 @@ import { SESSION_PLACEMENT_STATES } from "./session-placement-state.js";
export {
isCloudWorkerPlacementState,
SESSION_PLACEMENT_STATES,
type SessionPlacementState,
} from "./session-placement-state.js";
@@ -107,23 +106,23 @@ function createWorkerOwnedSessionPlacementSchema<
});
}
export const LocalSessionPlacementSchema = createUnownedSessionPlacementSchema("local");
export const RequestedSessionPlacementSchema = createUnownedSessionPlacementSchema("requested");
const LocalSessionPlacementSchema = createUnownedSessionPlacementSchema("local");
const RequestedSessionPlacementSchema = createUnownedSessionPlacementSchema("requested");
export const ProvisioningSessionPlacementSchema = closedObject({
const ProvisioningSessionPlacementSchema = closedObject({
state: Type.Literal("provisioning"),
...SessionPlacementTimingProperties,
environmentId: Type.Optional(NonEmptyString),
});
export const SyncingSessionPlacementSchema = closedObject({
const SyncingSessionPlacementSchema = closedObject({
state: Type.Literal("syncing"),
...SessionPlacementTimingProperties,
environmentId: NonEmptyString,
workerBundleHash: WorkerBundleHashSchema,
});
export const StartingSessionPlacementSchema = closedObject({
const StartingSessionPlacementSchema = closedObject({
state: Type.Literal("starting"),
...SessionPlacementTimingProperties,
environmentId: NonEmptyString,
@@ -131,18 +130,17 @@ export const StartingSessionPlacementSchema = closedObject({
...SessionPlacementWorkspaceProperties,
});
export const ActiveWorkerSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("active");
export const DrainingSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("draining");
export const ReconcilingSessionPlacementSchema =
createWorkerOwnedSessionPlacementSchema("reconciling");
const ActiveWorkerSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("active");
const DrainingSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("draining");
const ReconcilingSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("reconciling");
export const ReclaimedSessionPlacementSchema = closedObject({
const ReclaimedSessionPlacementSchema = closedObject({
state: Type.Literal("reclaimed"),
...SessionPlacementTimingProperties,
...TerminalSessionPlacementProperties,
});
export const FailedSessionPlacementSchema = closedObject({
const FailedSessionPlacementSchema = closedObject({
state: Type.Literal("failed"),
...SessionPlacementTimingProperties,
...TerminalSessionPlacementProperties,
@@ -64,10 +64,10 @@ describe("SessionsCatalogListParamsSchema", () => {
).toBe(true);
});
it("requires a catalog selector for host cursors", () => {
it("accepts flat optional catalog cursor fields", () => {
expect(
Value.Check(SessionsCatalogListParamsSchema, { cursors: { "gateway:local": "1" } }),
).toBe(false);
).toBe(true);
expect(
Value.Check(SessionsCatalogListParamsSchema, {
catalogId: "claude",
@@ -6,6 +6,12 @@ import { NonEmptyString } from "./primitives.js";
const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString });
export const SessionCatalogLocatorSchema = closedObject({
catalogId: NonEmptyString,
hostId: NonEmptyString,
threadId: NonEmptyString,
});
export const SessionCatalogCapabilitiesSchema = closedObject({
continueSession: Type.Boolean(),
archive: Type.Boolean(),
@@ -32,7 +38,7 @@ export const SessionCatalogSessionSchema = closedObject({
cliVersion: Type.Optional(Type.String()),
gitBranch: Type.Optional(Type.String()),
archived: Type.Boolean(),
openClawSessionKey: Type.Optional(NonEmptyString),
sessionKey: Type.Optional(NonEmptyString),
canContinue: Type.Boolean(),
canArchive: Type.Boolean(),
canOpenTerminal: Type.Optional(Type.Boolean()),
@@ -65,17 +71,11 @@ const SessionsCatalogListCommonProperties = {
hostIds: Type.Optional(Type.Array(NonEmptyString)),
};
export const SessionsCatalogListParamsSchema = Type.Union([
closedObject({
catalogId: Type.Optional(NonEmptyString),
...SessionsCatalogListCommonProperties,
}),
closedObject({
catalogId: NonEmptyString,
cursors: Type.Record(NonEmptyString, Type.String()),
...SessionsCatalogListCommonProperties,
}),
]);
export const SessionsCatalogListParamsSchema = closedObject({
catalogId: Type.Optional(NonEmptyString),
cursors: Type.Optional(Type.Record(NonEmptyString, Type.String())),
...SessionsCatalogListCommonProperties,
});
export const SessionsCatalogListResultSchema = closedObject({
catalogs: Type.Array(SessionCatalogSchema),
@@ -110,9 +110,7 @@ export const SessionCatalogTranscriptItemSchema = closedObject({
});
export const SessionsCatalogReadParamsSchema = closedObject({
catalogId: NonEmptyString,
hostId: NonEmptyString,
threadId: NonEmptyString,
...SessionCatalogLocatorSchema.properties,
limit: Type.Optional(Type.Integer({ minimum: 1 })),
cursor: Type.Optional(Type.String()),
});
@@ -126,23 +124,20 @@ export const SessionsCatalogReadResultSchema = closedObject({
});
export const SessionsCatalogContinueParamsSchema = closedObject({
catalogId: NonEmptyString,
hostId: NonEmptyString,
threadId: NonEmptyString,
...SessionCatalogLocatorSchema.properties,
});
export const SessionsCatalogContinueResultSchema = closedObject({ sessionKey: NonEmptyString });
export const SessionsCatalogArchiveParamsSchema = closedObject({
catalogId: NonEmptyString,
hostId: NonEmptyString,
threadId: NonEmptyString,
...SessionCatalogLocatorSchema.properties,
confirmNoOtherRunner: Type.Literal(true),
});
export const SessionsCatalogArchiveResultSchema = closedObject({ ok: Type.Literal(true) });
export type SessionCatalogCapabilities = Static<typeof SessionCatalogCapabilitiesSchema>;
export type SessionCatalogLocator = Static<typeof SessionCatalogLocatorSchema>;
export type SessionCatalogDescriptor = Static<typeof SessionCatalogDescriptorSchema>;
export type SessionCatalogSession = Static<typeof SessionCatalogSessionSchema>;
export type SessionCatalogHost = Static<typeof SessionCatalogHostSchema>;
@@ -18,7 +18,7 @@ export { SessionsCreateParamsSchema };
*/
/** Reason a compaction checkpoint was created. */
export const SessionCompactionCheckpointReasonSchema = Type.Union([
const SessionCompactionCheckpointReasonSchema = Type.Union([
Type.Literal("manual"),
Type.Literal("auto-threshold"),
Type.Literal("overflow-retry"),
@@ -38,7 +38,7 @@ export const SessionOperationEventSchema = closedObject({
});
/** Reference to the transcript location before or after compaction. */
export const SessionCompactionTranscriptReferenceSchema = closedObject({
const SessionCompactionTranscriptReferenceSchema = closedObject({
sessionId: NonEmptyString,
sessionFile: Type.Optional(NonEmptyString),
leafId: Type.Optional(NonEmptyString),
@@ -649,9 +649,20 @@ export const SessionsUsageParamsSchema = closedObject({
/** Usage row grouping. `family` rolls up known rotated session ids for a logical key. */
groupBy: Type.Optional(Type.Union([Type.Literal("instance"), Type.Literal("family")])),
/** Backward-compatible alias for requesting family grouping. */
includeHistorical: Type.Optional(Type.Boolean()),
includeHistorical: Type.Optional(
Type.Boolean({
deprecated: true,
description: "Deprecated alias for groupBy: family.",
}),
),
/** UTC offset to use when mode is `specific` (for example, UTC-4 or UTC+5:30). */
utcOffset: Type.Optional(Type.String({ pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$" })),
utcOffset: Type.Optional(
Type.String({
pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$",
deprecated: true,
description: "Deprecated compatibility fallback; use timeZone.",
}),
),
/** IANA time zone for `specific`; preferred over `utcOffset`, which remains a compatibility fallback. */
timeZone: Type.Optional(NonEmptyString),
/** Maximum sessions to return (default 50). */
@@ -0,0 +1,7 @@
import type { TSchema } from "typebox";
/** Adds protocol-vintage metadata without changing the schema's validated value shape. */
export function withSince<T extends TSchema>(train: string, schema: T): T {
Object.assign(schema, { "x-openclaw-since": train });
return schema;
}
@@ -41,11 +41,150 @@ export const PresenceEntrySchema = closedObject({
watchedSessions: Type.Optional(Type.Array(NonEmptyString)),
});
/** Health snapshot is intentionally opaque because providers contribute nested shapes. */
export const HealthSnapshotSchema = Type.Any();
const HealthSessionSummarySchema = closedObject({
path: Type.String(),
count: Type.Integer({ minimum: 0 }),
recent: Type.Array(
closedObject({
key: Type.String(),
updatedAt: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
age: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
}),
),
});
const HealthSnapshotSchema = closedObject({
// Every field is optional because hello snapshots use an empty object until
// the asynchronous health producer has populated the cache.
ok: Type.Optional(Type.Literal(true)),
ts: Type.Optional(Type.Integer({ minimum: 0 })),
durationMs: Type.Optional(Type.Integer({ minimum: 0 })),
eventLoop: Type.Optional(
closedObject({
degraded: Type.Boolean(),
reasons: Type.Array(
Type.Union([
Type.Literal("event_loop_delay"),
Type.Literal("event_loop_utilization"),
Type.Literal("cpu"),
]),
),
intervalMs: Type.Number({ minimum: 0 }),
delayP99Ms: Type.Number({ minimum: 0 }),
delayMaxMs: Type.Number({ minimum: 0 }),
utilization: Type.Number({ minimum: 0 }),
cpuCoreRatio: Type.Number({ minimum: 0 }),
}),
),
plugins: Type.Optional(
closedObject({
loaded: Type.Array(Type.String()),
errors: Type.Array(
closedObject({
id: Type.String(),
origin: Type.String(),
activated: Type.Boolean(),
activationSource: Type.Optional(Type.String()),
activationReason: Type.Optional(Type.String()),
failurePhase: Type.Optional(Type.String()),
error: Type.String(),
}),
),
unavailable: Type.Optional(
Type.Array(
closedObject({
id: Type.String(),
state: Type.Literal("configured-unavailable"),
diagnostic: closedObject({
kind: Type.Literal("plugin-verification"),
reason: Type.String(),
detail: Type.String(),
}),
}),
),
),
}),
),
contextEngines: Type.Optional(
closedObject({
quarantined: Type.Array(
closedObject({
engineId: Type.String(),
owner: Type.Optional(Type.String()),
operation: Type.String(),
reason: Type.String(),
failedAt: Type.Integer({ minimum: 0 }),
}),
),
}),
),
deliveryQueues: Type.Optional(
closedObject({
failed: Type.Array(
closedObject({
queueName: Type.String(),
count: Type.Integer({ minimum: 0 }),
oldestFailedAt: Type.Optional(Type.Integer({ minimum: 0 })),
}),
),
}),
),
modelPricing: Type.Optional(
closedObject({
state: Type.Union([Type.Literal("ok"), Type.Literal("degraded"), Type.Literal("disabled")]),
sources: Type.Array(
closedObject({
source: Type.Union([
Type.Literal("openrouter"),
Type.Literal("litellm"),
Type.Literal("bootstrap"),
Type.Literal("refresh"),
]),
state: Type.Union([Type.Literal("ok"), Type.Literal("degraded")]),
lastFailureAt: Type.Optional(Type.Integer({ minimum: 0 })),
detail: Type.Optional(Type.String()),
}),
),
lastFailureAt: Type.Optional(Type.Integer({ minimum: 0 })),
detail: Type.Optional(Type.String()),
}),
),
configReload: Type.Optional(
closedObject({
hotReloadStatus: Type.Union([Type.Literal("active"), Type.Literal("disabled")]),
}),
),
// Channel plugins own their nested account/probe summaries, so this is the
// one provider-contributed bag that deliberately remains unknown.
channels: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
channelOrder: Type.Optional(Type.Array(Type.String())),
channelLabels: Type.Optional(Type.Record(Type.String(), Type.String())),
heartbeatSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
defaultAgentId: Type.Optional(Type.String()),
agents: Type.Optional(
Type.Array(
closedObject({
agentId: Type.String(),
name: Type.Optional(Type.String()),
isDefault: Type.Boolean(),
heartbeat: closedObject({
enabled: Type.Boolean(),
every: Type.String(),
everyMs: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
prompt: Type.String(),
target: Type.String(),
model: Type.Optional(Type.String()),
ackMaxChars: Type.Integer({ minimum: 0 }),
}),
sessions: HealthSessionSummarySchema,
}),
),
),
sessions: Type.Optional(HealthSessionSummarySchema),
});
/** Default session routing keys included in initial gateway snapshots. */
export const SessionDefaultsSchema = closedObject({
const SessionDefaultsSchema = closedObject({
defaultAgentId: NonEmptyString,
mainKey: NonEmptyString,
mainSessionKey: NonEmptyString,
@@ -1,11 +1,10 @@
// Gateway Protocol schema module defines operator-originated system events.
import type { Static } from "typebox";
import { Type } from "typebox";
import { lazyCompile } from "../protocol-validator.js";
import { closedObject } from "./closed-object.js";
/** Operator event plus optional presence metadata and exact-session wake routing. */
export const SystemEventParamsSchema = closedObject({
const SystemEventParamsSchema = closedObject({
text: Type.String(),
idempotencyKey: Type.Optional(Type.String({ minLength: 1 })),
sessionKey: Type.Optional(Type.String()),
@@ -26,5 +25,4 @@ export const SystemEventParamsSchema = closedObject({
tags: Type.Optional(Type.Array(Type.String())),
});
export type SystemEventParams = Static<typeof SystemEventParamsSchema>;
export const validateSystemEventParams = lazyCompile(SystemEventParamsSchema);
@@ -11,7 +11,7 @@ import { NonEmptyString } from "./primitives.js";
* these schemas keep list/get/cancel payloads bounded and status values closed.
*/
/** Closed task lifecycle statuses visible in the gateway task ledger. */
export const TaskLedgerStatusSchema = Type.Union([
const TaskLedgerStatusSchema = Type.Union([
Type.Literal("queued"),
Type.Literal("running"),
Type.Literal("completed"),
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { validateTerminalOpenParams, validateTerminalUploadParams } from "../index.js";
import { MAX_TERMINAL_UPLOAD_BASE64_LENGTH } from "../terminal-upload-constants.js";
import { MAX_TERMINAL_UPLOAD_BASE64_LENGTH } from "./terminal-constants.js";
describe("terminal protocol", () => {
it("accepts a typed catalog reference and rejects client command fields", () => {
@@ -3,13 +3,15 @@
// operator connection and stream its bytes back over the existing WebSocket.
import type { Static } from "typebox";
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
import { SessionCatalogLocatorSchema } from "./sessions-catalog.js";
import { withSince } from "./since.js";
import {
MAX_TERMINAL_UPLOAD_BASE64_LENGTH,
MAX_TERMINAL_UPLOAD_BYTES,
MAX_TERMINAL_UPLOAD_NAME_LENGTH,
} from "../terminal-upload-constants.js";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
} from "./terminal-constants.js";
// PTY grids are bounded so a hostile client cannot request an allocation that
// overflows the terminal backend's row/column math.
@@ -20,13 +22,7 @@ export const TerminalOpenParamsSchema = closedObject({
// Optional agent selector; defaults to the gateway's default agent. The
// session starts in that agent's workspace and inherits its isolation.
agentId: Type.Optional(NonEmptyString),
catalog: Type.Optional(
closedObject({
catalogId: NonEmptyString,
hostId: NonEmptyString,
threadId: NonEmptyString,
}),
),
catalog: Type.Optional(SessionCatalogLocatorSchema),
cols: TerminalDimension,
rows: TerminalDimension,
});
@@ -143,35 +139,44 @@ export const TerminalAckResultSchema = closedObject({ ok: Type.Boolean() });
export type TerminalAckResult = Static<typeof TerminalAckResultSchema>;
/** Streamed output chunk; seq is its cumulative UTF-16 end offset within the session. */
export const TerminalDataEventSchema = closedObject({
sessionId: NonEmptyString,
seq: Type.Integer({ minimum: 0 }),
data: Type.String(),
});
export const TerminalDataEventSchema = withSince(
"2026.7",
closedObject({
sessionId: NonEmptyString,
seq: Type.Integer({ minimum: 0 }),
data: Type.String(),
}),
);
export type TerminalDataEvent = Static<typeof TerminalDataEventSchema>;
/** Terminal end-of-life notice; the session id is invalid after this event. */
export const TerminalExitEventSchema = closedObject({
sessionId: NonEmptyString,
exitCode: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
signal: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
// Stable reason code so clients can distinguish process exit from a
// server-side teardown (disconnect, idle sweep, config disable).
reason: Type.Optional(
Type.Union([
Type.Literal("process_exit"),
Type.Literal("closed"),
Type.Literal("disconnected"),
// Another admin connection attached the session away; the session is
// still alive server-side, but no longer streams to this connection.
Type.Literal("detached"),
Type.Literal("error"),
]),
),
error: Type.Optional(Type.String()),
});
export const TerminalExitEventSchema = withSince(
"2026.7",
closedObject({
sessionId: NonEmptyString,
exitCode: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
signal: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
// Stable reason code so clients can distinguish process exit from a
// server-side teardown (disconnect, idle sweep, config disable).
reason: Type.Optional(
Type.Union([
Type.Literal("process_exit"),
Type.Literal("closed"),
Type.Literal("disconnected"),
// Another admin connection attached the session away; the session is
// still alive server-side, but no longer streams to this connection.
Type.Literal("detached"),
Type.Literal("error"),
]),
),
error: Type.Optional(Type.String()),
}),
);
export type TerminalExitEvent = Static<typeof TerminalExitEventSchema>;
/** Union of every event a terminal session can emit. */
export const TerminalEventSchema = Type.Union([TerminalDataEventSchema, TerminalExitEventSchema]);
export const TerminalEventSchema = withSince(
"2026.7",
Type.Union([TerminalDataEventSchema, TerminalExitEventSchema]),
);
export type TerminalEvent = Static<typeof TerminalEventSchema>;
@@ -24,7 +24,7 @@ export const WizardStartParamsSchema = closedObject({
});
/** Client answer payload for the current wizard step. */
export const WizardAnswerSchema = closedObject({
const WizardAnswerSchema = closedObject({
stepId: NonEmptyString,
value: Type.Optional(Type.Unknown()),
});
@@ -47,7 +47,7 @@ export const WizardCancelParamsSchema = WizardSessionIdParamsSchema;
export const WizardStatusParamsSchema = WizardSessionIdParamsSchema;
/** Selectable value shown in a choice-based wizard step. */
export const WizardStepOptionSchema = closedObject({
const WizardStepOptionSchema = closedObject({
value: Type.Unknown(),
label: NonEmptyString,
hint: Type.Optional(Type.String()),
@@ -1,6 +1,29 @@
import { Type, type Static, type TProperties } from "typebox";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js";
import { closedObject } from "./closed-object.js";
import { withSince } from "./since.js";
import {
LiveIntegerSchema,
LiveSequenceSchema,
LiveTextSchema,
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
WorkerAdmissionFailureReasonSchema,
WorkerErrorResponseFrameSchema,
WorkerErrorShapeSchema,
WorkerFrameIdSchema,
WorkerIdentifierSchema,
WorkerProtocolCloseReasonSchema,
WorkerTranscriptAssistantDiagnosticSchema,
WorkerTranscriptUsageSchema,
} from "./worker-protocol-primitives.js";
export {
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH,
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
WorkerAdmissionFailureReasonSchema,
WorkerProtocolCloseReasonSchema,
} from "./worker-protocol-primitives.js";
// Additive RPCs require exact build-bound features; bump only for an incompatible base set.
export const WORKER_RPC_SET_VERSION = 1;
@@ -18,26 +41,14 @@ export const WORKER_PROTOCOL_FEATURES = [
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
"worker-inference-v1",
] as const;
export const WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH = 256;
export const WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH = 128;
export const WORKER_PROTOCOL_MAX_METHOD_LENGTH = 64;
export const WORKER_PROTOCOL_MAX_PAYLOAD_BYTES = 64 * 1024;
export const WORKER_PROTOCOL_MAX_FEATURES = 64;
export const WORKER_PROTOCOL_MAX_FEATURE_LENGTH = 128;
export const WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES = 64;
export const WORKER_TRANSCRIPT_MAX_CONTENT_PARTS = 128;
export const WORKER_TRANSCRIPT_MAX_JSON_DEPTH = 32;
const WorkerIdentifierSchema = Type.String({
minLength: 1,
maxLength: WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH,
pattern: "^\\S(?:.*\\S)?$",
});
const WorkerCredentialSchema = Type.String({ minLength: 16, maxLength: 256 });
const WorkerFrameIdSchema = Type.String({
minLength: 1,
maxLength: WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
});
const WorkerProtocolFeatureSchema = Type.String({
minLength: 1,
maxLength: WORKER_PROTOCOL_MAX_FEATURE_LENGTH,
@@ -49,14 +60,17 @@ const WorkerBundleHashSchema = Type.String({
});
/** Build identity presented by a worker before the gateway admits it. */
export const WorkerAdmissionHandshakeSchema = closedObject({
bundleHash: WorkerBundleHashSchema,
openclawVersion: Type.String({ minLength: 1, maxLength: 128 }),
protocolFeatures: Type.Array(WorkerProtocolFeatureSchema, {
maxItems: WORKER_PROTOCOL_MAX_FEATURES,
uniqueItems: true,
export const WorkerAdmissionHandshakeSchema = withSince(
"2026.7",
closedObject({
bundleHash: WorkerBundleHashSchema,
openclawVersion: Type.String({ minLength: 1, maxLength: 128 }),
protocolFeatures: Type.Array(WorkerProtocolFeatureSchema, {
maxItems: WORKER_PROTOCOL_MAX_FEATURES,
uniqueItems: true,
}),
}),
});
);
const WorkerConnectAdmissionCommonProperties = {
environmentId: WorkerIdentifierSchema,
@@ -80,7 +94,7 @@ const WorkerConnectAdmissionSchema = Type.Union([
]);
/** Dedicated first-frame payload accepted only on the worker ingress. */
export const WorkerConnectParamsSchema = closedObject({
const WorkerConnectParamsSchema = closedObject({
minProtocol: Type.Integer({ minimum: 1 }),
maxProtocol: Type.Integer({ minimum: 1 }),
client: closedObject({
@@ -100,50 +114,8 @@ export const WorkerConnectRequestFrameSchema = closedObject({
params: WorkerConnectParamsSchema,
});
export const WorkerAdmissionFailureReasonSchema = Type.Union([
Type.Literal("invalid-credential"),
Type.Literal("credential-expired"),
Type.Literal("environment-mismatch"),
Type.Literal("environment-unavailable"),
Type.Literal("bundle-mismatch"),
Type.Literal("version-mismatch"),
Type.Literal("session-mismatch"),
Type.Literal("placement-mismatch"),
Type.Literal("owner-epoch-mismatch"),
Type.Literal("rpc-set-mismatch"),
Type.Literal("protocol-features-mismatch"),
]);
export const WorkerProtocolCloseReasonSchema = Type.Union([
WorkerAdmissionFailureReasonSchema,
Type.Literal("invalid-handshake"),
Type.Literal("protocol-mismatch"),
Type.Literal("gateway-unavailable"),
Type.Literal("invalid-frame"),
Type.Literal("slow-consumer"),
Type.Literal("method-not-allowed"),
Type.Literal("invalid-heartbeat"),
Type.Literal("credential-replaced"),
Type.Literal("gateway-shutdown"),
]);
const WorkerErrorCodeSchema = Type.Union([
Type.Literal("INVALID_REQUEST"),
Type.Literal("UNAVAILABLE"),
]);
const WorkerErrorDetailsSchema = closedObject({ reason: WorkerProtocolCloseReasonSchema });
export const WorkerErrorShapeSchema = closedObject({
code: WorkerErrorCodeSchema,
message: Type.String({ minLength: 1, maxLength: 256 }),
details: WorkerErrorDetailsSchema,
retryable: Type.Optional(Type.Boolean()),
retryAfterMs: Type.Optional(Type.Integer({ minimum: 0 })),
});
/** Minimal admission response; workers never receive the general gateway snapshot. */
export const WorkerHelloOkSchema = closedObject({
const WorkerHelloOkSchema = closedObject({
type: Type.Literal("worker-hello-ok"),
environmentId: WorkerIdentifierSchema,
sessionId: Type.Union([WorkerIdentifierSchema, Type.Null()]),
@@ -160,13 +132,6 @@ export const WorkerHelloOkSchema = closedObject({
}),
});
const WorkerErrorResponseFrameSchema = closedObject({
type: Type.Literal("res"),
id: WorkerFrameIdSchema,
ok: Type.Literal(false),
error: WorkerErrorShapeSchema,
});
const WorkerAdmissionSuccessResponseFrameSchema = closedObject({
type: Type.Literal("res"),
id: WorkerFrameIdSchema,
@@ -190,7 +155,7 @@ export const WorkerHeartbeatParamsSchema = closedObject({
status: WorkerStatusSchema,
});
export const WorkerHeartbeatResultSchema = closedObject({
const WorkerHeartbeatResultSchema = closedObject({
receivedAtMs: Type.Integer({ minimum: 0 }),
status: Type.Literal("ok"),
ownerEpoch: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
@@ -249,48 +214,6 @@ const WorkerTranscriptToolCallSchema = closedObject({
executionMode: Type.Optional(Type.Union([Type.Literal("sequential"), Type.Literal("parallel")])),
});
const WorkerTranscriptUsageSchema = closedObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
contextUsage: Type.Optional(
Type.Union([
closedObject({
state: Type.Literal("available"),
promptTokens: Type.Number({ minimum: 0 }),
totalTokens: Type.Number({ minimum: 0 }),
}),
closedObject({ state: Type.Literal("unavailable") }),
]),
),
totalTokens: Type.Number({ minimum: 0 }),
cost: closedObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
total: Type.Number({ minimum: 0 }),
totalOrigin: Type.Optional(Type.Literal("provider-billed")),
}),
});
const WorkerTranscriptAssistantDiagnosticSchema = closedObject({
type: WorkerIdentifierSchema,
timestamp: Type.Integer({ minimum: 0 }),
error: Type.Optional(
closedObject({
name: Type.Optional(Type.String({ maxLength: 256 })),
message: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
stack: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })),
code: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Number()])),
}),
),
details: Type.Optional(
Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()),
),
});
const WorkerTranscriptUserMessageSchema = closedObject({
role: Type.Literal("user"),
content: Type.Array(
@@ -416,19 +339,8 @@ function workerLiveObject<const Properties extends TProperties>(properties: Prop
return closedObject(properties);
}
const LiveTextSchema = Type.String({
maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
});
const OptionalLiveTextSchema = Type.Optional(LiveTextSchema);
const LiveIntegerSchema = Type.Integer({
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
});
const OptionalLiveIntegerSchema = Type.Optional(LiveIntegerSchema);
const LiveSequenceSchema = Type.Integer({
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
});
const LiveIdentifierSchema = Type.String({
minLength: 1,
@@ -1,16 +1,20 @@
import { Type, type TProperties, type TSchema } from "typebox";
import { Type, type Static, type TProperties } from "typebox";
import { Value } from "typebox/value";
import { closedObject } from "./closed-object.js";
import {
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH,
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
WORKER_TRANSCRIPT_MAX_CONTENT_PARTS,
WORKER_TRANSCRIPT_MAX_JSON_DEPTH,
type WorkerErrorShape,
WorkerErrorShapeSchema,
type WorkerTranscriptMessage,
} from "./worker-admission.js";
import {
LiveIntegerSchema,
LiveSequenceSchema,
LiveTextSchema,
WorkerErrorResponseFrameSchema,
WorkerFrameIdSchema,
WorkerIdentifierSchema,
WorkerTranscriptAssistantDiagnosticSchema,
WorkerTranscriptUsageSchema,
} from "./worker-protocol-primitives.js";
export const WORKER_INFERENCE_PROTOCOL_FEATURE = "worker-inference-v1";
export const WORKER_INFERENCE_METHODS = [
@@ -19,87 +23,17 @@ export const WORKER_INFERENCE_METHODS = [
] as const;
export const WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES = 25 * 1024 * 1024;
export const WORKER_INFERENCE_MAX_CONTEXT_MESSAGES = 1_024;
export const WORKER_INFERENCE_MAX_TOOLS = 256;
const WORKER_INFERENCE_MAX_TOOLS = 256;
export const WORKER_INFERENCE_MAX_OUTPUT_TOKENS = 1_000_000;
const WorkerIdentifierSchema = Type.String({
minLength: 1,
maxLength: WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH,
pattern: "^\\S(?:.*\\S)?$",
});
const WorkerFrameIdSchema = Type.String({
minLength: 1,
maxLength: WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
});
const WorkerErrorResponseFrameSchema = closedObject({
type: Type.Literal("res"),
id: WorkerFrameIdSchema,
ok: Type.Literal(false),
error: WorkerErrorShapeSchema,
});
function workerInferenceObject<const Properties extends TProperties>(properties: Properties) {
return closedObject(properties);
}
const LiveTextSchema = Type.String({
maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
});
const InferenceTextSchema = Type.String({
maxLength: WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES,
});
const OptionalInferenceTextSchema = Type.Optional(InferenceTextSchema);
const LiveIntegerSchema = Type.Integer({
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
});
const LiveSequenceSchema = Type.Integer({
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
});
const WorkerTranscriptUsageSchema = closedObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
contextUsage: Type.Optional(
Type.Union([
closedObject({
state: Type.Literal("available"),
promptTokens: Type.Number({ minimum: 0 }),
totalTokens: Type.Number({ minimum: 0 }),
}),
closedObject({ state: Type.Literal("unavailable") }),
]),
),
totalTokens: Type.Number({ minimum: 0 }),
cost: closedObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
total: Type.Number({ minimum: 0 }),
totalOrigin: Type.Optional(Type.Literal("provider-billed")),
}),
});
const WorkerTranscriptAssistantDiagnosticSchema = closedObject({
type: WorkerIdentifierSchema,
timestamp: Type.Integer({ minimum: 0 }),
error: Type.Optional(
closedObject({
name: Type.Optional(Type.String({ maxLength: 256 })),
message: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
stack: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })),
code: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Number()])),
}),
),
details: Type.Optional(
Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()),
),
});
const WorkerInferenceTextContentSchema = workerInferenceObject({
type: Type.Literal("text"),
text: InferenceTextSchema,
@@ -211,12 +145,12 @@ const WorkerInferenceToolSchema = workerInferenceObject({
parameters: Type.Unknown(),
});
export const WorkerInferenceModelRefSchema: TSchema = workerInferenceObject({
export const WorkerInferenceModelRefSchema = workerInferenceObject({
provider: WorkerIdentifierSchema,
model: WorkerIdentifierSchema,
});
export const WorkerInferenceContextSchema: TSchema = workerInferenceObject({
const WorkerInferenceContextSchema = workerInferenceObject({
systemPrompt: Type.Optional(InferenceTextSchema),
messages: Type.Array(WorkerInferenceMessageSchema, {
maxItems: WORKER_INFERENCE_MAX_CONTEXT_MESSAGES,
@@ -250,7 +184,7 @@ const WorkerInferenceThinkingBudgetsSchema = workerInferenceObject({
max: Type.Optional(WorkerInferenceThinkingBudgetSchema),
});
export const WorkerInferenceOptionsSchema: TSchema = workerInferenceObject({
export const WorkerInferenceOptionsSchema = workerInferenceObject({
temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })),
maxTokens: Type.Optional(
Type.Integer({ minimum: 1, maximum: WORKER_INFERENCE_MAX_OUTPUT_TOKENS }),
@@ -266,18 +200,18 @@ const WorkerInferenceIdentityProperties = {
turnId: WorkerIdentifierSchema,
};
export const WorkerInferenceStartParamsSchema: TSchema = workerInferenceObject({
const WorkerInferenceStartParamsSchema = workerInferenceObject({
...WorkerInferenceIdentityProperties,
modelRef: WorkerInferenceModelRefSchema,
context: WorkerInferenceContextSchema,
options: WorkerInferenceOptionsSchema,
});
export const WorkerInferenceStartResultSchema: TSchema = workerInferenceObject({
const WorkerInferenceStartResultSchema = workerInferenceObject({
status: Type.Union([Type.Literal("accepted"), Type.Literal("replayed")]),
});
export const WorkerInferenceErrorReasonSchema: TSchema = Type.Union([
const WorkerInferenceErrorReasonSchema = Type.Union([
Type.Literal("model-not-approved"),
Type.Literal("invalid-context"),
Type.Literal("epoch-mismatch"),
@@ -286,13 +220,13 @@ export const WorkerInferenceErrorReasonSchema: TSchema = Type.Union([
Type.Literal("cancelled"),
]);
export const WorkerInferenceErrorShapeSchema: TSchema = workerInferenceObject({
const WorkerInferenceErrorShapeSchema = workerInferenceObject({
code: Type.Union([Type.Literal("INVALID_REQUEST"), Type.Literal("UNAVAILABLE")]),
message: Type.String({ minLength: 1, maxLength: 256 }),
details: workerInferenceObject({ reason: WorkerInferenceErrorReasonSchema }),
});
export const WorkerInferenceStartRequestFrameSchema: TSchema = workerInferenceObject({
export const WorkerInferenceStartRequestFrameSchema = workerInferenceObject({
type: Type.Literal("req"),
id: WorkerFrameIdSchema,
method: Type.Literal(WORKER_INFERENCE_METHODS[0]),
@@ -313,21 +247,21 @@ const WorkerInferenceErrorResponseFrameSchema = workerInferenceObject({
error: WorkerInferenceErrorShapeSchema,
});
export const WorkerInferenceStartResponseFrameSchema: TSchema = Type.Union([
export const WorkerInferenceStartResponseFrameSchema = Type.Union([
WorkerInferenceStartSuccessResponseFrameSchema,
WorkerInferenceErrorResponseFrameSchema,
WorkerErrorResponseFrameSchema,
]);
export const WorkerInferenceCancelParamsSchema: TSchema = workerInferenceObject({
const WorkerInferenceCancelParamsSchema = workerInferenceObject({
...WorkerInferenceIdentityProperties,
});
export const WorkerInferenceCancelResultSchema: TSchema = workerInferenceObject({
const WorkerInferenceCancelResultSchema = workerInferenceObject({
status: Type.Literal("cancelled"),
});
export const WorkerInferenceCancelRequestFrameSchema: TSchema = workerInferenceObject({
export const WorkerInferenceCancelRequestFrameSchema = workerInferenceObject({
type: Type.Literal("req"),
id: WorkerFrameIdSchema,
method: Type.Literal(WORKER_INFERENCE_METHODS[1]),
@@ -341,13 +275,13 @@ const WorkerInferenceCancelSuccessResponseFrameSchema = workerInferenceObject({
payload: WorkerInferenceCancelResultSchema,
});
export const WorkerInferenceCancelResponseFrameSchema: TSchema = Type.Union([
export const WorkerInferenceCancelResponseFrameSchema = Type.Union([
WorkerInferenceCancelSuccessResponseFrameSchema,
WorkerInferenceErrorResponseFrameSchema,
WorkerErrorResponseFrameSchema,
]);
export const WorkerInferenceResolvedModelSchema: TSchema = workerInferenceObject({
const WorkerInferenceResolvedModelSchema = workerInferenceObject({
api: WorkerIdentifierSchema,
provider: WorkerIdentifierSchema,
model: WorkerIdentifierSchema,
@@ -399,13 +333,13 @@ const WorkerInferenceStreamEventSchema = Type.Union([
workerInferenceObject({ type: Type.Literal("toolcall_end"), contentIndex: LiveIntegerSchema }),
]);
export const WorkerInferenceEventParamsSchema: TSchema = workerInferenceObject({
const WorkerInferenceEventParamsSchema = workerInferenceObject({
...WorkerInferenceIdentityProperties,
seq: LiveSequenceSchema,
event: WorkerInferenceStreamEventSchema,
});
export const WorkerInferenceEventFrameSchema: TSchema = workerInferenceObject({
const WorkerInferenceEventFrameSchema = workerInferenceObject({
type: Type.Literal("event"),
event: Type.Literal("worker.inference.event"),
payload: WorkerInferenceEventParamsSchema,
@@ -423,147 +357,49 @@ const WorkerInferenceTerminalErrorSchema = workerInferenceObject({
usage: Type.Optional(WorkerTranscriptUsageSchema),
});
export const WorkerInferenceTerminalOutcomeSchema: TSchema = Type.Union([
const WorkerInferenceTerminalOutcomeSchema = Type.Union([
WorkerInferenceTerminalDoneSchema,
WorkerInferenceTerminalErrorSchema,
]);
export const WorkerInferenceTerminalParamsSchema: TSchema = workerInferenceObject({
const WorkerInferenceTerminalParamsSchema = workerInferenceObject({
...WorkerInferenceIdentityProperties,
seq: LiveSequenceSchema,
outcome: WorkerInferenceTerminalOutcomeSchema,
});
export const WorkerInferenceTerminalFrameSchema: TSchema = workerInferenceObject({
const WorkerInferenceTerminalFrameSchema = workerInferenceObject({
type: Type.Literal("event"),
event: Type.Literal("worker.inference.terminal"),
payload: WorkerInferenceTerminalParamsSchema,
});
type WorkerInferenceUserMessage = Omit<
Extract<WorkerTranscriptMessage, { role: "user" }>,
"content"
> & {
content: string | Extract<WorkerTranscriptMessage, { role: "user" }>["content"];
runtimeContextCarrier?: boolean;
};
type WorkerInferenceContextMessage =
| WorkerInferenceUserMessage
| Extract<WorkerTranscriptMessage, { role: "assistant" | "toolResult" }>;
type WorkerInferenceTool = { name: string; description: string; parameters: unknown };
type WorkerInferenceIdentity = {
runEpoch: number;
sessionId: string;
runId: string;
turnId: string;
};
type WorkerInferenceThinkingBudgets = {
minimal?: number;
low?: number;
medium?: number;
high?: number;
max?: number;
};
type WorkerInferenceUsage = Extract<WorkerTranscriptMessage, { role: "assistant" }>["usage"];
type WorkerInferenceAssistantMessage = Omit<
Extract<WorkerTranscriptMessage, { role: "assistant" }>,
"diagnostics" | "stopReason" | "errorMessage" | "errorCode" | "errorType" | "errorBody"
> & { stopReason: "stop" | "length" | "toolUse" };
export type WorkerInferenceModelRef = { provider: string; model: string };
export type WorkerInferenceContext = {
systemPrompt?: string;
messages: WorkerInferenceContextMessage[];
tools?: WorkerInferenceTool[];
};
export type WorkerInferenceOptions = {
temperature?: number;
maxTokens?: number;
reasoning?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max";
thinkingBudgets?: WorkerInferenceThinkingBudgets;
};
export type WorkerInferenceStartParams = WorkerInferenceIdentity & {
modelRef: WorkerInferenceModelRef;
context: WorkerInferenceContext;
options: WorkerInferenceOptions;
};
export type WorkerInferenceStartResult = { status: "accepted" | "replayed" };
export type WorkerInferenceErrorReason =
| "model-not-approved"
| "invalid-context"
| "epoch-mismatch"
| "session-not-attached"
| "provider-error"
| "cancelled";
export type WorkerInferenceErrorShape = {
code: "INVALID_REQUEST" | "UNAVAILABLE";
message: string;
details: { reason: WorkerInferenceErrorReason };
};
export type WorkerInferenceStartRequestFrame = {
type: "req";
id: string;
method: "worker.inference.start";
params: WorkerInferenceStartParams;
};
type WorkerInferenceResponseErrorFrame = {
type: "res";
id: string;
ok: false;
error: WorkerInferenceErrorShape | WorkerErrorShape;
};
export type WorkerInferenceStartResponseFrame =
| { type: "res"; id: string; ok: true; payload: WorkerInferenceStartResult }
| WorkerInferenceResponseErrorFrame;
export type WorkerInferenceCancelParams = WorkerInferenceIdentity;
export type WorkerInferenceCancelResult = { status: "cancelled" };
export type WorkerInferenceCancelRequestFrame = {
type: "req";
id: string;
method: "worker.inference.cancel";
params: WorkerInferenceCancelParams;
};
export type WorkerInferenceCancelResponseFrame =
| { type: "res"; id: string; ok: true; payload: WorkerInferenceCancelResult }
| WorkerInferenceResponseErrorFrame;
export type WorkerInferenceResolvedModel = { api: string; provider: string; model: string };
type WorkerInferenceStreamEvent =
| { type: "start"; resolvedModel: WorkerInferenceResolvedModel; timestamp: number }
| { type: "text_start"; contentIndex: number; contentSignature?: string }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; contentSignature?: string }
| { type: "thinking_start"; contentIndex: number }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| { type: "toolcall_end"; contentIndex: number };
export type WorkerInferenceEventParams = WorkerInferenceIdentity & {
seq: number;
event: WorkerInferenceStreamEvent;
};
export type WorkerInferenceEventFrame = {
type: "event";
event: "worker.inference.event";
payload: WorkerInferenceEventParams;
};
export type WorkerInferenceTerminalOutcome =
| { type: "done"; message: WorkerInferenceAssistantMessage }
| {
type: "error";
reason: WorkerInferenceErrorReason;
message: string;
usage?: WorkerInferenceUsage;
};
export type WorkerInferenceTerminalParams = WorkerInferenceIdentity & {
seq: number;
outcome: WorkerInferenceTerminalOutcome;
};
export type WorkerInferenceTerminalFrame = {
type: "event";
event: "worker.inference.terminal";
payload: WorkerInferenceTerminalParams;
};
export type WorkerInferenceModelRef = Static<typeof WorkerInferenceModelRefSchema>;
export type WorkerInferenceContext = Static<typeof WorkerInferenceContextSchema>;
export type WorkerInferenceOptions = Static<typeof WorkerInferenceOptionsSchema>;
export type WorkerInferenceStartParams = Static<typeof WorkerInferenceStartParamsSchema>;
export type WorkerInferenceStartResult = Static<typeof WorkerInferenceStartResultSchema>;
export type WorkerInferenceErrorReason = Static<typeof WorkerInferenceErrorReasonSchema>;
export type WorkerInferenceErrorShape = Static<typeof WorkerInferenceErrorShapeSchema>;
export type WorkerInferenceStartRequestFrame = Static<
typeof WorkerInferenceStartRequestFrameSchema
>;
export type WorkerInferenceStartResponseFrame = Static<
typeof WorkerInferenceStartResponseFrameSchema
>;
export type WorkerInferenceCancelParams = Static<typeof WorkerInferenceCancelParamsSchema>;
export type WorkerInferenceCancelResult = Static<typeof WorkerInferenceCancelResultSchema>;
export type WorkerInferenceCancelRequestFrame = Static<
typeof WorkerInferenceCancelRequestFrameSchema
>;
export type WorkerInferenceCancelResponseFrame = Static<
typeof WorkerInferenceCancelResponseFrameSchema
>;
export type WorkerInferenceEventParams = Static<typeof WorkerInferenceEventParamsSchema>;
export type WorkerInferenceEventFrame = Static<typeof WorkerInferenceEventFrameSchema>;
export type WorkerInferenceTerminalOutcome = Static<typeof WorkerInferenceTerminalOutcomeSchema>;
export type WorkerInferenceTerminalParams = Static<typeof WorkerInferenceTerminalParamsSchema>;
export type WorkerInferenceTerminalFrame = Static<typeof WorkerInferenceTerminalFrameSchema>;
function isSafeWorkerInferenceJson(data: unknown): boolean {
const stack: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: data }];
@@ -0,0 +1,122 @@
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
export const WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH = 256;
export const WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH = 128;
export const WORKER_PROTOCOL_MAX_PAYLOAD_BYTES = 64 * 1024;
export const WorkerIdentifierSchema = Type.String({
minLength: 1,
maxLength: WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH,
pattern: "^\\S(?:.*\\S)?$",
});
export const WorkerFrameIdSchema = Type.String({
minLength: 1,
maxLength: WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
});
export const WorkerAdmissionFailureReasonSchema = Type.Union([
Type.Literal("invalid-credential"),
Type.Literal("credential-expired"),
Type.Literal("environment-mismatch"),
Type.Literal("environment-unavailable"),
Type.Literal("bundle-mismatch"),
Type.Literal("version-mismatch"),
Type.Literal("session-mismatch"),
Type.Literal("placement-mismatch"),
Type.Literal("owner-epoch-mismatch"),
Type.Literal("rpc-set-mismatch"),
Type.Literal("protocol-features-mismatch"),
]);
export const WorkerProtocolCloseReasonSchema = Type.Union([
WorkerAdmissionFailureReasonSchema,
Type.Literal("invalid-handshake"),
Type.Literal("protocol-mismatch"),
Type.Literal("gateway-unavailable"),
Type.Literal("invalid-frame"),
Type.Literal("slow-consumer"),
Type.Literal("method-not-allowed"),
Type.Literal("invalid-heartbeat"),
Type.Literal("credential-replaced"),
Type.Literal("gateway-shutdown"),
]);
const WorkerErrorCodeSchema = Type.Union([
Type.Literal("INVALID_REQUEST"),
Type.Literal("UNAVAILABLE"),
]);
const WorkerErrorDetailsSchema = closedObject({ reason: WorkerProtocolCloseReasonSchema });
export const WorkerErrorShapeSchema = closedObject({
code: WorkerErrorCodeSchema,
message: Type.String({ minLength: 1, maxLength: 256 }),
details: WorkerErrorDetailsSchema,
retryable: Type.Optional(Type.Boolean()),
retryAfterMs: Type.Optional(Type.Integer({ minimum: 0 })),
});
export const WorkerErrorResponseFrameSchema = closedObject({
type: Type.Literal("res"),
id: WorkerFrameIdSchema,
ok: Type.Literal(false),
error: WorkerErrorShapeSchema,
});
export const WorkerTranscriptUsageSchema = closedObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
contextUsage: Type.Optional(
Type.Union([
closedObject({
state: Type.Literal("available"),
promptTokens: Type.Number({ minimum: 0 }),
totalTokens: Type.Number({ minimum: 0 }),
}),
closedObject({ state: Type.Literal("unavailable") }),
]),
),
totalTokens: Type.Number({ minimum: 0 }),
cost: closedObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
total: Type.Number({ minimum: 0 }),
totalOrigin: Type.Optional(Type.Literal("provider-billed")),
}),
});
export const WorkerTranscriptAssistantDiagnosticSchema = closedObject({
type: WorkerIdentifierSchema,
timestamp: Type.Integer({ minimum: 0 }),
error: Type.Optional(
closedObject({
name: Type.Optional(Type.String({ maxLength: 256 })),
message: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
stack: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })),
code: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Number()])),
}),
),
details: Type.Optional(
Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()),
),
});
export const LiveTextSchema = Type.String({
maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
});
export const LiveIntegerSchema = Type.Integer({
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
});
export const LiveSequenceSchema = Type.Integer({
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
});
@@ -12,7 +12,7 @@ const GATEWAY_STARTUP_RETRY_MIN_MS = 100;
const GATEWAY_STARTUP_RETRY_MAX_MS = 2_000;
/** Details payload attached to retryable startup-unavailable gateway errors. */
export type GatewayStartupUnavailableDetails = {
type GatewayStartupUnavailableDetails = {
reason: typeof GATEWAY_STARTUP_UNAVAILABLE_REASON;
};
@@ -2,7 +2,6 @@ import { lazyCompile } from "./protocol-validator.js";
import {
TerminalAttachParamsSchema,
TerminalCloseParamsSchema,
TerminalEventSchema,
TerminalInputParamsSchema,
TerminalOpenParamsSchema,
TerminalResizeParamsSchema,
@@ -19,4 +18,3 @@ export const validateTerminalAttachParams = lazyCompile(TerminalAttachParamsSche
export const validateTerminalTextParams = lazyCompile(TerminalTextParamsSchema);
export const validateTerminalUploadParams = lazyCompile(TerminalUploadParamsSchema);
export const validateTerminalUploadResult = lazyCompile(TerminalUploadResultSchema);
export const validateTerminalEvent = lazyCompile(TerminalEventSchema);
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
// Enforces release-train metadata on core gateway methods.
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const ts = require("typescript");
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const descriptorPath = "src/gateway/methods/core-descriptors.ts";
function runGit(args) {
const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
if (result.status !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `git exited ${result.status}`;
throw new Error(
`${detail}\nRun git fetch origin main so origin/main and its merge-base are available.`,
);
}
return result.stdout.trim();
}
function tryRunGit(args) {
const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
return result.status === 0 ? result.stdout.trim() : undefined;
}
function resolveBaseCommit() {
const explicitBase = process.env.PROTOCOL_SINCE_BASE_SHA?.trim();
if (explicitBase) {
if (!/^[0-9a-f]{40}$/u.test(explicitBase)) {
throw new Error("PROTOCOL_SINCE_BASE_SHA must be a full commit SHA.");
}
if (tryRunGit(["cat-file", "-e", `${explicitBase}:${descriptorPath}`]) === undefined) {
throw new Error(`PROTOCOL_SINCE_BASE_SHA ${explicitBase} is unavailable in this checkout.`);
}
return explicitBase;
}
const mainMergeBase = tryRunGit(["merge-base", "HEAD", "origin/main"]);
if (mainMergeBase) {
return mainMergeBase;
}
// Pull-request CI checks out a synthetic merge commit without creating origin/main.
// Its first parent is the exact base used to build the merge result.
const parents = (tryRunGit(["show", "-s", "--format=%P", "HEAD"]) ?? "").split(/\s+/u);
if (parents.length === 2 && parents[0]) {
return parents[0];
}
throw new Error(
"Could not resolve PROTOCOL_SINCE_BASE_SHA, origin/main, or a two-parent pull-request merge checkout.",
);
}
function unwrapExpression(expression) {
let current = expression;
while (
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isParenthesizedExpression(current)
) {
current = current.expression;
}
return current;
}
function stringProperty(object, key) {
for (const property of object.properties) {
if (!ts.isPropertyAssignment(property)) {
continue;
}
const propertyName = property.name;
const name =
ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName)
? propertyName.text
: undefined;
if (name === key && ts.isStringLiteralLike(property.initializer)) {
return property.initializer.text;
}
}
return undefined;
}
function collectMethodSpecs(sourceText, fileName) {
const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true);
let specs;
function visit(node) {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === "CORE_GATEWAY_METHOD_SPECS" &&
node.initializer
) {
const initializer = unwrapExpression(node.initializer);
if (ts.isArrayLiteralExpression(initializer)) {
specs = initializer.elements.map((element) => {
const line =
sourceFile.getLineAndCharacterOfPosition(element.getStart(sourceFile)).line + 1;
if (!ts.isObjectLiteralExpression(element)) {
throw new Error(
`${fileName}:${line} core method specs must be inline object literals so vintage metadata can be enforced.`,
);
}
const name = stringProperty(element, "name");
if (!name) {
throw new Error(
`${fileName}:${line} core method spec names must be string literals so additions can be compared with origin/main.`,
);
}
return {
name,
since: stringProperty(element, "since"),
line,
};
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (!specs) {
throw new Error(`Could not find CORE_GATEWAY_METHOD_SPECS in ${fileName}.`);
}
return specs;
}
function currentTrain() {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const match = /^(\d{4})\.(\d{1,2})(?:\.|$)/.exec(packageJson.version);
if (!match) {
throw new Error(`Root package version ${JSON.stringify(packageJson.version)} is not calver.`);
}
return `${match[1]}.${match[2]}`;
}
try {
const train = currentTrain();
const mergeBase = resolveBaseCommit();
const currentSource = fs.readFileSync(path.join(repoRoot, descriptorPath), "utf8");
const currentSpecs = collectMethodSpecs(currentSource, descriptorPath);
const baseSource = runGit(["show", `${mergeBase}:${descriptorPath}`]);
const baseNames = new Set(
collectMethodSpecs(baseSource, `${descriptorPath}@${mergeBase}`).map((s) => s.name),
);
const added = currentSpecs.filter((spec) => !baseNames.has(spec.name));
const violations = added.filter((spec) => spec.since !== train);
if (violations.length > 0) {
console.error(`Protocol since guard failed for current train ${train}:`);
for (const spec of violations) {
const problem = spec.since
? `has since ${JSON.stringify(spec.since)}`
: "is missing since metadata";
console.error(
`- ${descriptorPath}:${spec.line} ${spec.name} ${problem}; add since: ${JSON.stringify(train)}.`,
);
}
process.exitCode = 1;
} else {
console.log(
`protocol since guard passed: ${added.length} new core method${added.length === 1 ? "" : "s"} use train ${train}`,
);
}
} catch (error) {
console.error(
`Protocol since guard failed: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
+2 -1
View File
@@ -288,7 +288,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: config-backed main-session resolver for Gateway-hosted plugin services.
// +9: outbound echo identity type and record/query helpers across
// channel-outbound and its two compatibility barrels.
8189,
// Net +1: public session catalog locator types after the protocol cleanup harvest.
8190,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
+2 -39
View File
@@ -42,10 +42,6 @@ const STRICT_LITERAL_STRUCTS = new Set([
"PluginsSessionActionFailureResult",
]);
// Swift exposes these branch properties as optionals; the gateway schema still
// enforces cross-field requirements such as cursors requiring catalogId.
const MERGED_OBJECT_UNION_STRUCTS = new Set(["SessionsCatalogListParams"]);
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\npublic let GATEWAY_MIN_NODE_PROTOCOL_VERSION = ${MIN_NODE_PROTOCOL_VERSION}\n\nprivate struct GatewayAnyCodingKey: CodingKey, Hashable {\n let stringValue: String\n let intValue: Int?\n\n init?(stringValue: String) {\n self.stringValue = stringValue\n self.intValue = nil\n }\n\n init?(intValue: Int) {\n self.stringValue = String(intValue)\n self.intValue = intValue\n }\n}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(
ErrorCodes,
)
@@ -163,37 +159,6 @@ function registerNamedSchema(name: string, schema: JsonSchema): void {
schemaNameBySignature.set(signature, name);
}
function mergeObjectUnionForStruct(name: string, schema: JsonSchema): JsonSchema | undefined {
if (!MERGED_OBJECT_UNION_STRUCTS.has(name)) {
return undefined;
}
const branches = schema.oneOf ?? schema.anyOf;
if (!branches?.length || branches.some((branch) => branch.type !== "object")) {
throw new Error(`${name} must remain an object union for Swift generation`);
}
const properties: Record<string, JsonSchema> = {};
for (const branch of branches) {
for (const [key, property] of Object.entries(branch.properties ?? {})) {
const existing = properties[key];
if (existing && schemaSignature(existing) !== schemaSignature(property)) {
throw new Error(`${name}.${key} has incompatible object-union schemas`);
}
properties[key] = property;
}
}
const required = Object.keys(properties).filter((key) =>
branches.every((branch) => branch.required?.includes(key)),
);
return {
type: "object",
properties,
required,
additionalProperties: branches.every((branch) => branch.additionalProperties === false)
? false
: undefined,
};
}
function namedSchema(schema: JsonSchema, allowStructuralFallback = false): string | undefined {
return (
schemaNameByObject.get(schema as object) ??
@@ -776,10 +741,8 @@ async function generate() {
if (name === "GatewayFrame") {
continue;
}
const structSchema =
schema.type === "object" ? schema : mergeObjectUnionForStruct(name, schema);
if (structSchema) {
parts.push(emitStruct(name, structSchema));
if (schema.type === "object") {
parts.push(emitStruct(name, schema));
}
}
+5
View File
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ProtocolSchemas } from "../packages/gateway-protocol/src/schema.js";
import { listCoreGatewayMethodMetadata } from "../src/gateway/methods/core-descriptors.js";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
@@ -12,6 +13,9 @@ async function writeJsonSchema() {
for (const [name, schema] of Object.entries(ProtocolSchemas)) {
definitions[name] = schema;
}
const methods = Object.fromEntries(
listCoreGatewayMethodMetadata().map(({ name, scope, since }) => [name, { since, scope }]),
);
const rootSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
@@ -31,6 +35,7 @@ async function writeJsonSchema() {
event: "#/definitions/EventFrame",
},
},
methods,
definitions,
};
@@ -1232,7 +1232,10 @@ export function handleToolExecutionStart(
return ctx.params.onToolResult?.(
buildAgentHarnessQuestionPromptPayload({
questionId,
questions,
questions: questions.map(({ questionId: id, ...question }) => ({
...question,
id,
})),
options: { intro: "Question for you:" },
}),
);
+14 -17
View File
@@ -141,7 +141,7 @@ describe("gateway harness questions", () => {
it("returns an answer that wins a registration cancellation race", async () => {
const registration = deferred<{ id: string }>();
const answers = { answers: { answer: { answers: ["Continue"] } } };
const answers = { answers: { answer: ["Continue"] } };
let resolveCount = 0;
const gatewayCall: AgentHarnessQuestionGatewayCall = async (method) => {
if (method === "question.request") {
@@ -184,7 +184,7 @@ describe("gateway harness questions", () => {
});
it("returns an answer recovered after the request response is lost", async () => {
const answers = { answers: { answer: { answers: ["Continue"] } } };
const answers = { answers: { answer: ["Continue"] } };
const requestError = new Error("request response lost");
const gatewayCall: AgentHarnessQuestionGatewayCall = async (method) => {
if (method === "question.request") {
@@ -218,7 +218,7 @@ describe("gateway harness questions", () => {
const registration = deferred<{ id: string }>();
const answer = deferred<{
status: "answered";
answers: { answers: Record<string, { answers: string[] }> };
answers: { answers: Record<string, string[]> };
}>();
const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => {
if (method === "question.request") {
@@ -228,9 +228,8 @@ describe("gateway harness questions", () => {
return await answer.promise;
}
if (method === "question.resolve") {
const resolvedAnswers = (
params as { answers?: { answers: Record<string, { answers: string[] }> } }
).answers;
const resolvedAnswers = (params as { answers?: { answers: Record<string, string[]> } })
.answers;
if (!resolvedAnswers) {
return { status: "cancelled" };
}
@@ -261,7 +260,7 @@ describe("gateway harness questions", () => {
await expect(run).resolves.toEqual({
status: "answered",
answers: { answers: { answer: { answers: ["Continue"] } } },
answers: { answers: { answer: ["Continue"] } },
});
expect(onBlockReply).not.toHaveBeenCalled();
});
@@ -298,7 +297,7 @@ describe("gateway harness questions", () => {
it("accepts a later text answer after cancellation fails", async () => {
const answer = deferred<{
status: "answered";
answers: { answers: Record<string, { answers: string[] }> };
answers: { answers: Record<string, string[]> };
}>();
let cancelAttempts = 0;
const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => {
@@ -309,9 +308,8 @@ describe("gateway harness questions", () => {
return await answer.promise;
}
if (method === "question.resolve") {
const resolvedAnswers = (
params as { answers?: { answers: Record<string, { answers: string[] }> } }
).answers;
const resolvedAnswers = (params as { answers?: { answers: Record<string, string[]> } })
.answers;
if (!resolvedAnswers) {
cancelAttempts += 1;
throw new Error("temporary gateway failure");
@@ -348,7 +346,7 @@ describe("gateway harness questions", () => {
await expect(run).resolves.toEqual({
status: "answered",
answers: { answers: { answer: { answers: ["Continue"] } } },
answers: { answers: { answer: ["Continue"] } },
});
expect(cancelAttempts).toBe(1);
});
@@ -356,7 +354,7 @@ describe("gateway harness questions", () => {
it("returns a gateway answer without waiting for stalled prompt delivery", async () => {
const answer = deferred<{
status: "answered";
answers: { answers: Record<string, { answers: string[] }> };
answers: { answers: Record<string, string[]> };
}>();
const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => {
if (method === "question.request") {
@@ -366,8 +364,7 @@ describe("gateway harness questions", () => {
return await answer.promise;
}
if (method === "question.resolve") {
const answers = (params as { answers?: { answers: Record<string, { answers: string[] }> } })
.answers;
const answers = (params as { answers?: { answers: Record<string, string[]> } }).answers;
if (answers) {
answer.resolve({ status: "answered", answers });
return { status: "answered", answers };
@@ -398,14 +395,14 @@ describe("gateway harness questions", () => {
text: "Continue",
}),
).resolves.toBe(true);
const answers = { answers: { answer: { answers: ["Continue"] } } };
const answers = { answers: { answer: ["Continue"] } };
await expect(run).resolves.toEqual({ status: "answered", answers });
expect(deliverySignal?.aborted).toBe(true);
});
it("does not deliver a prompt for an already-terminal gateway question", async () => {
const answers = { answers: { answer: { answers: ["Continue"] } } };
const answers = { answers: { answer: ["Continue"] } };
const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => {
if (method === "question.request") {
return { id: (params as { id: string }).id };
+9 -2
View File
@@ -1,5 +1,6 @@
import { randomBytes } from "node:crypto";
import type {
QuestionAnswers,
QuestionRequestQuestion,
QuestionWaitAnswerResult,
} from "../../../packages/gateway-protocol/src/schema/questions.js";
@@ -91,11 +92,16 @@ async function resolvePendingAgentQuestionAnswers(
state: PendingAgentQuestion,
answers: AgentHarnessUserInputAnswers,
): Promise<boolean> {
const gatewayAnswers: QuestionAnswers = {
answers: Object.fromEntries(
Object.entries(answers.answers).map(([questionId, answer]) => [questionId, answer.answers]),
),
};
try {
await state.gatewayCall(
"question.resolve",
{},
{ id: state.questionId, answers, resolvedBy: "plain-text" },
{ id: state.questionId, answers: gatewayAnswers, resolvedBy: "plain-text" },
);
return true;
} catch (error) {
@@ -277,8 +283,9 @@ export async function runAgentHarnessGatewayQuestion(
params: RunAgentHarnessGatewayQuestionParams,
): Promise<QuestionWaitAnswerResult> {
const questionId = params.questionId ?? `ask_${randomBytes(16).toString("hex")}`;
const questions: QuestionRequestQuestion[] = params.questions.map((question) => ({
const questions: QuestionRequestQuestion[] = params.questions.map(({ id, ...question }) => ({
...question,
questionId: id,
options: [...(question.options ?? [])],
}));
let aborted = false;
+4 -4
View File
@@ -60,7 +60,7 @@ describe("ask_user normalization", () => {
expect(normalized.timeoutSeconds).toBe(30);
expect(normalized.questions[0]).toMatchObject({
id: "deploy_target",
questionId: "deploy_target",
header: "Deployment t",
isOther: true,
});
@@ -260,7 +260,7 @@ describe("ask_user prompt delivery", () => {
describe("ask_user execution", () => {
it("returns answered details plus readable answer lines", async () => {
const answers = { answers: { deploy_target: { answers: ["Staging (Recommended)"] } } };
const answers = { answers: { deploy_target: ["Staging (Recommended)"] } };
const gateway = gatewayStub(async (method, _opts, params) => {
if (method === "question.request") {
return { id: params.id, expiresAtMs: Date.now() + 30_000 };
@@ -599,7 +599,7 @@ describe("ask_user execution", () => {
if (!reservation) {
throw new Error("expected prompt reservation");
}
const answers = { answers: { deploy_target: { answers: ["Production"] } } };
const answers = { answers: { deploy_target: ["Production"] } };
let waitCalls = 0;
const gateway = gatewayStub(async (method, _opts, params) => {
if (method === "question.request") {
@@ -735,7 +735,7 @@ describe("ask_user execution", () => {
{},
{
id: questionId,
answers: { answers: { deploy_target: { answers: ["A custom destination"] } } },
answers: { answers: { deploy_target: ["A custom destination"] } },
resolvedBy: "plain-text",
},
);
+7 -7
View File
@@ -177,7 +177,7 @@ export function normalizeAskUserParams(value: unknown): NormalizedAskUserParams
throw new ToolInputError(`${prefix}.multiSelect must be a boolean`);
}
return {
id,
questionId: id,
header,
question: questionText,
options: question.options.map((option, optionIndex) =>
@@ -474,7 +474,7 @@ export function cancelAskUserPromptDelivery(
function answeredResult(questions: readonly QuestionRequestQuestion[], answers: QuestionAnswers) {
const payload = { status: "answered" as const, answers };
const lines = questions.map((question) => {
const values = answers.answers[question.id]?.answers ?? [];
const values = answers.answers[question.questionId] ?? [];
return `${question.header}: ${values.length > 0 ? values.join(", ") : "(no answer)"}`;
});
return textResult(`${lines.join("\n")}\n\n${JSON.stringify(payload, null, 2)}`, payload);
@@ -584,13 +584,11 @@ export function createAskUserTool(params: {
gatewayCall,
waiters: new Set(),
} satisfies AskUserQuestionState);
state.sessionKey = sessionKey;
state.questions = normalized.questions;
Object.assign(state, { sessionKey, questions: normalized.questions });
state.expiresAtMs = Date.now() + timeoutMs;
state.gatewayCall = gatewayCall;
transitionAskUserQuestion(state, { kind: "registering" });
askUserQuestions.set(questionId, state);
let cancellation:
| Promise<Extract<QuestionWaitAnswerResult, { status: "answered" }> | undefined>
| undefined;
@@ -647,12 +645,14 @@ export function createAskUserTool(params: {
}
throw new Error("question.waitAnswer returned an invalid status");
};
try {
state.claim = registerPendingAgentQuestion({
questionId,
sessionKey,
questions: normalized.questions,
questions: normalized.questions.map(({ questionId: id, ...question }) => ({
...question,
id,
})),
gatewayCall,
onCancel: () => {
if (
+3
View File
@@ -1,7 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Value } from "typebox/value";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { SnapshotSchema } from "../../packages/gateway-protocol/src/schema/snapshot.js";
import { createPluginRecord } from "../plugins/status.test-fixtures.js";
const testConfig = { session: { store: "/tmp/x" } };
@@ -92,6 +94,7 @@ describe("getHealthSnapshot plugin state", () => {
const snap = await getHealthSnapshot({ timeoutMs: 10, probe: false });
expect(Value.Check(SnapshotSchema.properties.health, snap)).toBe(true);
expect(snap.plugins?.unavailable).toEqual([
{
id: "discord",
+2 -2
View File
@@ -863,12 +863,12 @@ describe("getHealthSnapshot", () => {
buildTelegramHealthSummaryForTest = (snapshot) => ({
accountId: snapshot.accountId,
configured: Boolean(snapshot.configured),
probe: { ok: true, token: "summary-secret" },
probe: { ok: true, token: "test-token" },
});
probeTelegramAccountForTestOverride = async () => ({
ok: true,
bot: { username: "runtime_bot" },
token: "probe-secret",
token: "test-token",
});
const snap = await getHealthSnapshot({
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { listCoreGatewayMethodMetadata } from "./core-descriptors.js";
const CURRENT_TRAIN_METHODS = [
"question.request",
"question.waitAnswer",
"question.resolve",
"question.get",
"question.list",
"terminal.open",
"terminal.input",
"terminal.resize",
"terminal.close",
"terminal.attach",
"terminal.list",
"terminal.text",
"terminal.upload",
"worktrees.list",
"worktrees.branches",
"worktrees.create",
"worktrees.remove",
"worktrees.restore",
"worktrees.gc",
"agents.workspace.list",
"agents.workspace.get",
"audit.list",
"audit.activity.list",
"tts.speak",
"environments.list",
"environments.status",
"environments.create",
"environments.destroy",
"sessions.dispatch",
"sessions.reclaim",
"sessions.catalog.list",
"sessions.catalog.read",
"sessions.catalog.continue",
"sessions.catalog.archive",
"approval.get",
"approval.resolve",
"approval.history",
"migrations.memory.plan",
"migrations.memory.apply",
"gateway.suspend.prepare",
"gateway.suspend.status",
"gateway.suspend.resume",
"ui.command",
"device.pair.rename",
] as const;
describe("core gateway method release trains", () => {
it("records a valid train for every method and dates the 2026.7 families", () => {
const methods = listCoreGatewayMethodMetadata();
for (const method of methods) {
expect(method.since, method.name).toMatch(/^(<=)?\d{4}\.\d{1,2}$/);
}
expect(
methods
.filter((method) => method.since === "2026.7")
.map((method) => method.name)
.toSorted(),
).toEqual(CURRENT_TRAIN_METHODS.toSorted());
});
});
+362 -318
View File
@@ -11,396 +11,434 @@ import {
type CoreGatewayMethodSpec = {
name: string;
scope: GatewayMethodScope;
since?: string;
advertise?: false;
startup?: true;
controlPlaneWrite?: true;
};
type CoreGatewayMethodMetadata = Pick<CoreGatewayMethodSpec, "name" | "scope" | "since">;
// This is the canonical core method policy table: every core handler must appear here so
// listing, authorization, startup availability, and write throttling stay in sync.
const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "health", scope: "operator.read" },
{ name: "diagnostics.stability", scope: "operator.read" },
{ name: "doctor.memory.status", scope: "operator.read" },
{ name: "doctor.memory.dreamDiary", scope: "operator.read" },
{ name: "doctor.memory.backfillDreamDiary", scope: "operator.write" },
{ name: "doctor.memory.resetDreamDiary", scope: "operator.write" },
{ name: "doctor.memory.resetGroundedShortTerm", scope: "operator.write" },
{ name: "doctor.memory.repairDreamingArtifacts", scope: "operator.write" },
{ name: "doctor.memory.dedupeDreamDiary", scope: "operator.write" },
{ name: "doctor.memory.remHarness", scope: "operator.read" },
{ name: "logs.tail", scope: "operator.read" },
{ name: "channels.status", scope: "operator.read" },
{ name: "channels.start", scope: "operator.admin" },
{ name: "channels.stop", scope: "operator.admin" },
{ name: "channels.logout", scope: "operator.admin" },
{ name: "status", scope: "operator.read" },
{ name: "usage.status", scope: "operator.read" },
{ name: "usage.cost", scope: "operator.read" },
{ name: "tts.status", scope: "operator.read" },
{ name: "tts.providers", scope: "operator.read" },
{ name: "tts.personas", scope: "operator.read" },
{ name: "tts.enable", scope: "operator.write" },
{ name: "tts.disable", scope: "operator.write" },
{ name: "tts.convert", scope: "operator.write" },
{ name: "tts.setProvider", scope: "operator.write" },
{ name: "tts.setPersona", scope: "operator.write" },
{ name: "config.get", scope: "operator.read" },
{ name: "config.set", scope: "operator.admin" },
{ name: "config.apply", scope: "operator.admin", controlPlaneWrite: true },
{ name: "config.patch", scope: "operator.admin", controlPlaneWrite: true },
{ name: "config.schema", scope: "operator.admin" },
{ name: "config.schema.lookup", scope: "operator.read" },
{ name: "exec.approvals.get", scope: "operator.admin" },
{ name: "exec.approvals.set", scope: "operator.admin" },
{ name: "exec.approvals.node.get", scope: "operator.admin" },
{ name: "exec.approvals.node.set", scope: "operator.admin" },
{ name: "exec.approval.get", scope: "operator.approvals" },
{ name: "exec.approval.list", scope: "operator.approvals" },
{ name: "exec.approval.request", scope: "operator.approvals" },
{ name: "exec.approval.waitDecision", scope: "operator.approvals" },
{ name: "exec.approval.resolve", scope: "operator.approvals" },
{ name: "question.request", scope: "operator.questions" },
{ name: "question.waitAnswer", scope: "operator.questions" },
{ name: "question.resolve", scope: "operator.questions" },
{ name: "question.get", scope: "operator.questions" },
{ name: "question.list", scope: "operator.questions" },
{ name: "plugin.approval.list", scope: "operator.approvals" },
{ name: "plugin.approval.request", scope: "operator.approvals" },
{ name: "plugin.approval.waitDecision", scope: "operator.approvals" },
{ name: "plugin.approval.resolve", scope: "operator.approvals" },
{ name: "plugins.uiDescriptors", scope: "operator.read" },
{ name: "plugins.sessionAction", scope: "dynamic" },
{ name: "openclaw.chat", scope: "operator.admin" },
{ name: "openclaw.changes.list", scope: "operator.admin" },
{ name: "openclaw.approval.list", scope: "operator.approvals" },
{ name: "openclaw.setup.detect", scope: "operator.admin" },
{ name: "health", scope: "operator.read", since: "<=2026.7" },
{ name: "diagnostics.stability", scope: "operator.read", since: "<=2026.7" },
{ name: "doctor.memory.status", scope: "operator.read", since: "<=2026.7" },
{ name: "doctor.memory.dreamDiary", scope: "operator.read", since: "<=2026.7" },
{ name: "doctor.memory.backfillDreamDiary", scope: "operator.write", since: "<=2026.7" },
{ name: "doctor.memory.resetDreamDiary", scope: "operator.write", since: "<=2026.7" },
{ name: "doctor.memory.resetGroundedShortTerm", scope: "operator.write", since: "<=2026.7" },
{ name: "doctor.memory.repairDreamingArtifacts", scope: "operator.write", since: "<=2026.7" },
{ name: "doctor.memory.dedupeDreamDiary", scope: "operator.write", since: "<=2026.7" },
{ name: "doctor.memory.remHarness", scope: "operator.read", since: "<=2026.7" },
{ name: "logs.tail", scope: "operator.read", since: "<=2026.7" },
{ name: "channels.status", scope: "operator.read", since: "<=2026.7" },
{ name: "channels.start", scope: "operator.admin", since: "<=2026.7" },
{ name: "channels.stop", scope: "operator.admin", since: "<=2026.7" },
{ name: "channels.logout", scope: "operator.admin", since: "<=2026.7" },
{ name: "status", scope: "operator.read", since: "<=2026.7" },
{ name: "usage.status", scope: "operator.read", since: "<=2026.7" },
{ name: "usage.cost", scope: "operator.read", since: "<=2026.7" },
{ name: "tts.status", scope: "operator.read", since: "<=2026.7" },
{ name: "tts.providers", scope: "operator.read", since: "<=2026.7" },
{ name: "tts.personas", scope: "operator.read", since: "<=2026.7" },
{ name: "tts.enable", scope: "operator.write", since: "<=2026.7" },
{ name: "tts.disable", scope: "operator.write", since: "<=2026.7" },
{ name: "tts.convert", scope: "operator.write", since: "<=2026.7" },
{ name: "tts.setProvider", scope: "operator.write", since: "<=2026.7" },
{ name: "tts.setPersona", scope: "operator.write", since: "<=2026.7" },
{ name: "config.get", scope: "operator.read", since: "<=2026.7" },
{ name: "config.set", scope: "operator.admin", since: "<=2026.7" },
{ name: "config.apply", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true },
{ name: "config.patch", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true },
{ name: "config.schema", scope: "operator.admin", since: "<=2026.7" },
{ name: "config.schema.lookup", scope: "operator.read", since: "<=2026.7" },
{ name: "exec.approvals.get", scope: "operator.admin", since: "<=2026.7" },
{ name: "exec.approvals.set", scope: "operator.admin", since: "<=2026.7" },
{ name: "exec.approvals.node.get", scope: "operator.admin", since: "<=2026.7" },
{ name: "exec.approvals.node.set", scope: "operator.admin", since: "<=2026.7" },
{ name: "exec.approval.get", scope: "operator.approvals", since: "<=2026.7" },
{ name: "exec.approval.list", scope: "operator.approvals", since: "<=2026.7" },
{ name: "exec.approval.request", scope: "operator.approvals", since: "<=2026.7" },
{ name: "exec.approval.waitDecision", scope: "operator.approvals", since: "<=2026.7" },
{ name: "exec.approval.resolve", scope: "operator.approvals", since: "<=2026.7" },
{ name: "question.request", scope: "operator.questions", since: "2026.7" },
{ name: "question.waitAnswer", scope: "operator.questions", since: "2026.7" },
{ name: "question.resolve", scope: "operator.questions", since: "2026.7" },
{ name: "question.get", scope: "operator.questions", since: "2026.7" },
{ name: "question.list", scope: "operator.questions", since: "2026.7" },
{ name: "plugin.approval.list", scope: "operator.approvals", since: "<=2026.7" },
{ name: "plugin.approval.request", scope: "operator.approvals", since: "<=2026.7" },
{ name: "plugin.approval.waitDecision", scope: "operator.approvals", since: "<=2026.7" },
{ name: "plugin.approval.resolve", scope: "operator.approvals", since: "<=2026.7" },
{ name: "plugins.uiDescriptors", scope: "operator.read", since: "<=2026.7" },
{ name: "plugins.sessionAction", scope: "dynamic", since: "<=2026.7" },
{ name: "openclaw.chat", scope: "operator.admin", since: "<=2026.7" },
{ name: "openclaw.changes.list", scope: "operator.admin", since: "<=2026.7" },
{ name: "openclaw.approval.list", scope: "operator.approvals", since: "<=2026.7" },
{ name: "openclaw.setup.detect", scope: "operator.admin", since: "<=2026.7" },
// Failed activation candidates are non-mutating probes. Keep this admin-only
// without the shared three-write budget so the automatic ladder can finish.
{ name: "openclaw.setup.activate", scope: "operator.admin" },
{ name: "openclaw.setup.auth.start", scope: "operator.admin" },
{ name: "openclaw.setup.prepare.start", scope: "operator.admin" },
{ name: "wizard.start", scope: "operator.admin" },
{ name: "wizard.next", scope: "operator.admin" },
{ name: "wizard.cancel", scope: "operator.admin" },
{ name: "wizard.status", scope: "operator.admin" },
{ name: "talk.catalog", scope: "operator.read" },
{ name: "openclaw.setup.activate", scope: "operator.admin", since: "<=2026.7" },
{ name: "openclaw.setup.auth.start", scope: "operator.admin", since: "<=2026.7" },
{ name: "openclaw.setup.prepare.start", scope: "operator.admin", since: "<=2026.7" },
{ name: "wizard.start", scope: "operator.admin", since: "<=2026.7" },
{ name: "wizard.next", scope: "operator.admin", since: "<=2026.7" },
{ name: "wizard.cancel", scope: "operator.admin", since: "<=2026.7" },
{ name: "wizard.status", scope: "operator.admin", since: "<=2026.7" },
{ name: "talk.catalog", scope: "operator.read", since: "<=2026.7" },
// 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.transcript", scope: "operator.write" },
{ name: "talk.client.close", scope: "operator.write" },
{ name: "talk.client.toolCall", scope: "operator.write" },
{ name: "talk.client.steer", scope: "operator.write" },
{ name: "talk.session.create", scope: "operator.write" },
{ name: "talk.session.join", scope: "operator.write" },
{ name: "talk.session.appendAudio", scope: "operator.write" },
{ name: "talk.session.startTurn", scope: "operator.write" },
{ name: "talk.session.endTurn", scope: "operator.write" },
{ name: "talk.session.cancelTurn", scope: "operator.write" },
{ name: "talk.session.cancelOutput", scope: "operator.write" },
{ name: "talk.session.acknowledgeMark", scope: "operator.write" },
{ name: "talk.session.submitToolResult", scope: "operator.write" },
{ name: "talk.session.steer", scope: "operator.write" },
{ name: "talk.session.close", scope: "operator.write" },
{ name: "talk.speak", scope: "operator.write" },
{ name: "talk.mode", scope: "operator.write" },
{ name: "commands.list", scope: "operator.read" },
{ name: "models.list", scope: "operator.read", startup: true },
{ name: "models.authStatus", scope: "operator.read" },
{ name: "models.authLogout", scope: "operator.admin", controlPlaneWrite: true },
{ name: "tools.catalog", scope: "operator.read" },
{ name: "tools.effective", scope: "operator.read", startup: true },
{ name: "tools.invoke", scope: "operator.write" },
{ name: "mcp.app.view", scope: "operator.read" },
{ name: "mcp.app.listTools", scope: "operator.read" },
{ name: "mcp.app.listResources", scope: "operator.read" },
{ name: "mcp.app.listResourceTemplates", scope: "operator.read" },
{ name: "mcp.app.readResource", scope: "operator.read" },
{ name: "mcp.app.callTool", scope: "operator.write" },
{ name: "mcp.app.updateModelContext", scope: "operator.write" },
{ name: "board.get", scope: "operator.read" },
{ name: "board.update", scope: "operator.write" },
{ name: "board.widget.put", scope: "operator.write" },
{ name: "board.widget.grant", scope: "operator.approvals" },
{ name: "board.event", scope: "operator.write" },
{ name: "audit.list", scope: "operator.read" },
{ name: "audit.activity.list", scope: "operator.read" },
{ name: "users.list", scope: "operator.read" },
{ name: "users.self", scope: "operator.write" },
{ name: "users.linkEmail", scope: "operator.admin" },
{ name: "users.setDisplayName", scope: "operator.write" },
{ name: "users.setAvatar", scope: "operator.write" },
{ name: "tasks.list", scope: "operator.read" },
{ name: "tasks.get", scope: "operator.read" },
{ name: "tasks.cancel", scope: "operator.write" },
{ name: "taskSuggestions.list", scope: "operator.read" },
{ name: "taskSuggestions.create", scope: "operator.write" },
{ name: "taskSuggestions.accept", scope: "operator.admin" },
{ name: "taskSuggestions.dismiss", scope: "operator.write" },
{ name: "environments.list", scope: "operator.read" },
{ name: "environments.status", scope: "operator.read" },
{ name: "worktrees.list", scope: "operator.read" },
{ name: "talk.config", scope: "dynamic", since: "<=2026.7" },
{ name: "talk.client.create", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.client.transcript", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.client.close", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.client.toolCall", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.client.steer", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.create", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.join", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.appendAudio", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.startTurn", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.endTurn", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.cancelTurn", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.cancelOutput", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.acknowledgeMark", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.submitToolResult", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.steer", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.session.close", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.speak", scope: "operator.write", since: "<=2026.7" },
{ name: "talk.mode", scope: "operator.write", since: "<=2026.7" },
{ name: "commands.list", scope: "operator.read", since: "<=2026.7" },
{ name: "models.list", scope: "operator.read", since: "<=2026.7", startup: true },
{ name: "models.authStatus", scope: "operator.read", since: "<=2026.7" },
{
name: "models.authLogout",
scope: "operator.admin",
since: "<=2026.7",
controlPlaneWrite: true,
},
{ name: "tools.catalog", scope: "operator.read", since: "<=2026.7" },
{ name: "tools.effective", scope: "operator.read", since: "<=2026.7", startup: true },
{ name: "tools.invoke", scope: "operator.write", since: "<=2026.7" },
{ name: "mcp.app.view", scope: "operator.read", since: "<=2026.7" },
{ name: "mcp.app.listTools", scope: "operator.read", since: "<=2026.7" },
{ name: "mcp.app.listResources", scope: "operator.read", since: "<=2026.7" },
{ name: "mcp.app.listResourceTemplates", scope: "operator.read", since: "<=2026.7" },
{ name: "mcp.app.readResource", scope: "operator.read", since: "<=2026.7" },
{ name: "mcp.app.callTool", scope: "operator.write", since: "<=2026.7" },
{ name: "mcp.app.updateModelContext", scope: "operator.write", since: "<=2026.7" },
{ name: "board.get", scope: "operator.read", since: "<=2026.7" },
{ name: "board.update", scope: "operator.write", since: "<=2026.7" },
{ name: "board.widget.put", scope: "operator.write", since: "<=2026.7" },
{ name: "board.widget.grant", scope: "operator.approvals", since: "<=2026.7" },
{ name: "board.event", scope: "operator.write", since: "<=2026.7" },
{ name: "audit.list", scope: "operator.read", since: "2026.7" },
{ name: "audit.activity.list", scope: "operator.read", since: "2026.7" },
{ name: "users.list", scope: "operator.read", since: "<=2026.7" },
{ name: "users.self", scope: "operator.write", since: "<=2026.7" },
{ name: "users.linkEmail", scope: "operator.admin", since: "<=2026.7" },
{ name: "users.setDisplayName", scope: "operator.write", since: "<=2026.7" },
{ name: "users.setAvatar", scope: "operator.write", since: "<=2026.7" },
{ name: "tasks.list", scope: "operator.read", since: "<=2026.7" },
{ name: "tasks.get", scope: "operator.read", since: "<=2026.7" },
{ name: "tasks.cancel", scope: "operator.write", since: "<=2026.7" },
{ name: "taskSuggestions.list", scope: "operator.read", since: "<=2026.7" },
{ name: "taskSuggestions.create", scope: "operator.write", since: "<=2026.7" },
{ name: "taskSuggestions.accept", scope: "operator.admin", since: "<=2026.7" },
{ name: "taskSuggestions.dismiss", scope: "operator.write", since: "<=2026.7" },
{ name: "environments.list", scope: "operator.read", since: "2026.7" },
{ name: "environments.status", scope: "operator.read", since: "2026.7" },
{ name: "worktrees.list", scope: "operator.read", since: "2026.7" },
// Read-only git probe, but it accepts arbitrary host paths; keep it at the
// same bar as starting worktree sessions instead of plain read scope.
{ name: "worktrees.branches", scope: "operator.write" },
{ name: "worktrees.branches", scope: "operator.write", since: "2026.7" },
// Arbitrary host-path directory listing backs the new-session folder picker;
// same trust bar as sessions.create with an explicit cwd.
{ name: "fs.listDir", scope: "operator.admin" },
{ name: "worktrees.create", scope: "operator.admin", controlPlaneWrite: true },
{ name: "worktrees.remove", scope: "operator.admin", controlPlaneWrite: true },
{ name: "worktrees.restore", scope: "operator.admin", controlPlaneWrite: true },
{ name: "worktrees.gc", scope: "operator.admin", controlPlaneWrite: true },
{ name: "agents.list", scope: "operator.read" },
{ name: "agents.create", scope: "operator.admin" },
{ name: "agents.update", scope: "operator.admin" },
{ name: "agents.delete", scope: "operator.admin" },
{ name: "agents.files.list", scope: "operator.read" },
{ name: "agents.files.get", scope: "operator.read" },
{ name: "agents.files.set", scope: "operator.admin" },
{ name: "sessions.files.list", scope: "operator.read" },
{ name: "sessions.files.get", scope: "operator.read" },
{ name: "fs.listDir", scope: "operator.admin", since: "<=2026.7" },
{ name: "worktrees.create", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true },
{ name: "worktrees.remove", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true },
{ name: "worktrees.restore", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true },
{ name: "worktrees.gc", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true },
{ name: "agents.list", scope: "operator.read", since: "<=2026.7" },
{ name: "agents.create", scope: "operator.admin", since: "<=2026.7" },
{ name: "agents.update", scope: "operator.admin", since: "<=2026.7" },
{ name: "agents.delete", scope: "operator.admin", since: "<=2026.7" },
{ name: "agents.files.list", scope: "operator.read", since: "<=2026.7" },
{ name: "agents.files.get", scope: "operator.read", since: "<=2026.7" },
{ name: "agents.files.set", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.files.list", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.files.get", scope: "operator.read", since: "<=2026.7" },
// Workspace file writes require the same admin scope as agents.files.set.
{ name: "sessions.files.set", scope: "operator.admin" },
{ name: "sessions.files.reveal", scope: "operator.admin" },
{ name: "artifacts.list", scope: "operator.read" },
{ name: "artifacts.get", scope: "operator.read" },
{ name: "artifacts.download", scope: "operator.read" },
{ name: "skills.status", scope: "operator.read" },
{ name: "skills.search", scope: "operator.read" },
{ name: "skills.detail", scope: "operator.read" },
{ name: "skills.securityVerdicts", scope: "operator.read" },
{ name: "skills.skillCard", scope: "operator.read" },
{ name: "skills.bins", scope: "node" },
{ name: "skills.upload.begin", scope: "operator.admin" },
{ name: "skills.upload.chunk", scope: "operator.admin" },
{ name: "skills.upload.commit", scope: "operator.admin" },
{ name: "skills.install", scope: "operator.admin" },
{ name: "skills.update", scope: "operator.admin" },
{ name: "skills.curator.status", scope: "operator.read" },
{ name: "skills.curator.pin", scope: "operator.admin" },
{ name: "skills.curator.unpin", scope: "operator.admin" },
{ name: "skills.curator.restore", scope: "operator.admin" },
{ name: "skills.proposals.list", scope: "operator.read" },
{ name: "skills.proposals.inspect", scope: "operator.read" },
{ name: "skills.proposals.historyStatus", scope: "operator.read" },
{ name: "skills.proposals.historyScan", scope: "operator.admin" },
{ name: "skills.proposals.create", scope: "operator.admin" },
{ name: "skills.proposals.update", scope: "operator.admin" },
{ name: "skills.proposals.revise", scope: "operator.admin" },
{ name: "skills.proposals.requestRevision", scope: "operator.admin" },
{ name: "skills.proposals.apply", scope: "operator.admin" },
{ name: "skills.proposals.reject", scope: "operator.admin" },
{ name: "skills.proposals.quarantine", scope: "operator.admin" },
{ name: "update.status", scope: "operator.admin" },
{ name: "update.run", scope: "operator.admin", controlPlaneWrite: true },
{ name: "voicewake.get", scope: "operator.read" },
{ name: "voicewake.set", scope: "operator.write" },
{ name: "secrets.reload", scope: "operator.admin" },
{ name: "secrets.resolve", scope: "operator.admin" },
{ name: "voicewake.routing.get", scope: "operator.read" },
{ name: "voicewake.routing.set", scope: "operator.write" },
{ name: "sessions.list", scope: "operator.read", startup: true },
{ name: "sessions.subscribe", scope: "operator.read" },
{ name: "sessions.unsubscribe", scope: "operator.read" },
{ name: "sessions.messages.subscribe", scope: "operator.read" },
{ name: "sessions.messages.unsubscribe", scope: "operator.read" },
{ name: "sessions.preview", scope: "operator.read" },
{ name: "sessions.describe", scope: "operator.read" },
{ name: "sessions.compaction.list", scope: "operator.read" },
{ name: "sessions.compaction.get", scope: "operator.read" },
{ name: "sessions.compaction.branch", scope: "operator.write" },
{ name: "sessions.compaction.restore", scope: "operator.admin" },
{ name: "sessions.branches.list", scope: "operator.read" },
{ name: "sessions.branches.switch", scope: "operator.admin" },
{ name: "sessions.rewind", scope: "operator.admin" },
{ name: "sessions.fork", scope: "operator.write" },
{ name: "sessions.files.set", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.files.reveal", scope: "operator.admin", since: "<=2026.7" },
{ name: "artifacts.list", scope: "operator.read", since: "<=2026.7" },
{ name: "artifacts.get", scope: "operator.read", since: "<=2026.7" },
{ name: "artifacts.download", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.status", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.search", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.detail", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.securityVerdicts", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.skillCard", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.bins", scope: "node", since: "<=2026.7" },
{ name: "skills.upload.begin", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.upload.chunk", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.upload.commit", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.install", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.update", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.curator.status", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.curator.pin", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.curator.unpin", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.curator.restore", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.list", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.proposals.inspect", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.proposals.historyStatus", scope: "operator.read", since: "<=2026.7" },
{ name: "skills.proposals.historyScan", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.create", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.update", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.revise", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.requestRevision", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.apply", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.reject", scope: "operator.admin", since: "<=2026.7" },
{ name: "skills.proposals.quarantine", scope: "operator.admin", since: "<=2026.7" },
{ name: "update.status", scope: "operator.admin", since: "<=2026.7" },
{ name: "update.run", scope: "operator.admin", controlPlaneWrite: true, since: "<=2026.7" },
{ name: "voicewake.get", scope: "operator.read", since: "<=2026.7" },
{ name: "voicewake.set", scope: "operator.write", since: "<=2026.7" },
{ name: "secrets.reload", scope: "operator.admin", since: "<=2026.7" },
{ name: "secrets.resolve", scope: "operator.admin", since: "<=2026.7" },
{ name: "voicewake.routing.get", scope: "operator.read", since: "<=2026.7" },
{ name: "voicewake.routing.set", scope: "operator.write", since: "<=2026.7" },
{ name: "sessions.list", scope: "operator.read", startup: true, since: "<=2026.7" },
{ name: "sessions.subscribe", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.unsubscribe", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.messages.subscribe", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.messages.unsubscribe", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.preview", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.describe", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.compaction.list", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.compaction.get", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.compaction.branch", scope: "operator.write", since: "<=2026.7" },
{ name: "sessions.compaction.restore", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.branches.list", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.branches.switch", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.rewind", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.fork", scope: "operator.write", since: "<=2026.7" },
// Params-aware: explicit cwd can point at any host checkout and requires admin.
{ name: "sessions.create", scope: "dynamic", startup: true },
{ name: "sessions.send", scope: "operator.write", startup: true },
{ name: "sessions.abort", scope: "operator.write", startup: true },
{ name: "sessions.create", scope: "dynamic", since: "<=2026.7", startup: true },
{ name: "sessions.send", scope: "operator.write", since: "<=2026.7", startup: true },
{ name: "sessions.abort", scope: "operator.write", since: "<=2026.7", startup: true },
// Params-aware: write scope may mutate chat-organization fields
// (label/category/icon/pinned/archived/unread); every other patch field stays
// admin-only. Policy lives in method-scopes.ts.
{ name: "sessions.patch", scope: "dynamic" },
{ name: "sessions.pluginPatch", scope: "operator.admin" },
{ name: "sessions.cleanup", scope: "operator.admin" },
{ name: "sessions.reset", scope: "operator.admin" },
{ name: "sessions.patch", scope: "dynamic", since: "<=2026.7" },
{ name: "sessions.pluginPatch", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.cleanup", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.reset", scope: "operator.admin", since: "<=2026.7" },
// State-aware: write scope may delete already-archived sessions
// (archive-then-delete); the handler enforces the archived requirement and
// admin keeps unrestricted delete. Policy in method-scopes.ts + handler.
{ name: "sessions.delete", scope: "dynamic" },
{ name: "sessions.compact", scope: "operator.admin" },
{ name: "sessions.groups.list", scope: "operator.read" },
{ name: "sessions.groups.put", scope: "operator.write" },
{ name: "sessions.groups.rename", scope: "operator.write" },
{ name: "sessions.groups.delete", scope: "operator.write" },
{ name: "last-heartbeat", scope: "operator.read" },
{ name: "set-heartbeats", scope: "operator.admin" },
{ name: "wake", scope: "operator.write" },
{ name: "node.pair.list", scope: "operator.pairing" },
{ name: "node.pair.approve", scope: "operator.pairing" },
{ name: "node.pair.reject", scope: "operator.pairing" },
{ name: "node.pair.remove", scope: "operator.pairing" },
{ name: "device.pair.list", scope: "operator.pairing" },
{ name: "device.pair.approve", scope: "operator.pairing" },
{ name: "device.pair.reject", scope: "operator.pairing" },
{ name: "device.pair.remove", scope: "operator.pairing" },
{ name: "device.pair.rename", scope: "operator.pairing" },
{ name: "device.token.rotate", scope: "operator.pairing" },
{ name: "device.token.revoke", scope: "operator.pairing" },
{ name: "device.pair.setupCode", scope: "operator.admin", advertise: false },
{ name: "node.rename", scope: "operator.pairing" },
{ name: "node.list", scope: "operator.read" },
{ name: "node.describe", scope: "operator.read" },
{ name: "node.pluginSurface.refresh", scope: "node" },
{ name: "node.pluginTools.update", scope: "node" },
{ name: "node.skills.update", scope: "node" },
{ name: "node.pending.drain", scope: "node" },
{ name: "node.pending.enqueue", scope: "operator.write" },
{ name: "sessions.delete", scope: "dynamic", since: "<=2026.7" },
{ name: "sessions.compact", scope: "operator.admin", since: "<=2026.7" },
{ name: "sessions.groups.list", scope: "operator.read", since: "<=2026.7" },
{ name: "sessions.groups.put", scope: "operator.write", since: "<=2026.7" },
{ name: "sessions.groups.rename", scope: "operator.write", since: "<=2026.7" },
{ name: "sessions.groups.delete", scope: "operator.write", since: "<=2026.7" },
{ name: "last-heartbeat", scope: "operator.read", since: "<=2026.7" },
{ name: "set-heartbeats", scope: "operator.admin", since: "<=2026.7" },
{ name: "wake", scope: "operator.write", since: "<=2026.7" },
{ name: "node.pair.list", scope: "operator.pairing", since: "<=2026.7" },
{ name: "node.pair.approve", scope: "operator.pairing", since: "<=2026.7" },
{ name: "node.pair.reject", scope: "operator.pairing", since: "<=2026.7" },
{ name: "node.pair.remove", scope: "operator.pairing", since: "<=2026.7" },
{ name: "device.pair.list", scope: "operator.pairing", since: "<=2026.7" },
{ name: "device.pair.approve", scope: "operator.pairing", since: "<=2026.7" },
{ name: "device.pair.reject", scope: "operator.pairing", since: "<=2026.7" },
{ name: "device.pair.remove", scope: "operator.pairing", since: "<=2026.7" },
{ name: "device.pair.rename", scope: "operator.pairing", since: "2026.7" },
{ name: "device.token.rotate", scope: "operator.pairing", since: "<=2026.7" },
{ name: "device.token.revoke", scope: "operator.pairing", since: "<=2026.7" },
{ name: "device.pair.setupCode", scope: "operator.admin", since: "<=2026.7", advertise: false },
{ name: "node.rename", scope: "operator.pairing", since: "<=2026.7" },
{ name: "node.list", scope: "operator.read", since: "<=2026.7" },
{ name: "node.describe", scope: "operator.read", since: "<=2026.7" },
{ name: "node.pluginSurface.refresh", scope: "node", since: "<=2026.7" },
{ name: "node.pluginTools.update", scope: "node", since: "<=2026.7" },
{ name: "node.skills.update", scope: "node", since: "<=2026.7" },
{ name: "node.pending.drain", scope: "node", since: "<=2026.7" },
{ name: "node.pending.enqueue", scope: "operator.write", since: "<=2026.7" },
// 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" },
{ name: "node.invoke.result", scope: "node" },
{ name: "node.event", scope: "node" },
{ name: "cron.get", scope: "operator.read" },
{ name: "cron.list", scope: "operator.read" },
{ name: "cron.status", scope: "operator.read" },
{ name: "cron.add", scope: "operator.admin" },
{ name: "cron.update", scope: "operator.admin" },
{ name: "cron.remove", scope: "operator.admin" },
{ name: "cron.run", scope: "operator.admin" },
{ name: "cron.runs", scope: "operator.read" },
{ name: "gateway.identity.get", scope: "operator.read" },
{ name: "gateway.restart.preflight", scope: "operator.read" },
{ name: "gateway.restart.request", scope: "operator.admin", controlPlaneWrite: true },
{ name: "system-presence", scope: "operator.read" },
{ name: "system-event", scope: "operator.admin" },
{ name: "message.action", scope: "operator.write" },
{ name: "conversations.send", scope: "operator.admin" },
{ name: "conversations.turn", scope: "operator.admin" },
{ name: "conversations.turn.cancel", scope: "operator.admin" },
{ name: "send", scope: "operator.write" },
{ name: "node.invoke", scope: "dynamic", since: "<=2026.7" },
{ name: "node.pending.pull", scope: "node", since: "<=2026.7" },
{ name: "node.pending.ack", scope: "node", since: "<=2026.7" },
{ name: "node.invoke.progress", scope: "node", since: "<=2026.7" },
{ name: "node.invoke.result", scope: "node", since: "<=2026.7" },
{ name: "node.event", scope: "node", since: "<=2026.7" },
{ name: "cron.get", scope: "operator.read", since: "<=2026.7" },
{ name: "cron.list", scope: "operator.read", since: "<=2026.7" },
{ name: "cron.status", scope: "operator.read", since: "<=2026.7" },
{ name: "cron.add", scope: "operator.admin", since: "<=2026.7" },
{ name: "cron.update", scope: "operator.admin", since: "<=2026.7" },
{ name: "cron.remove", scope: "operator.admin", since: "<=2026.7" },
{ name: "cron.run", scope: "operator.admin", since: "<=2026.7" },
{ name: "cron.runs", scope: "operator.read", since: "<=2026.7" },
{ name: "gateway.identity.get", scope: "operator.read", since: "<=2026.7" },
{ name: "gateway.restart.preflight", scope: "operator.read", since: "<=2026.7" },
{
name: "gateway.restart.request",
scope: "operator.admin",
since: "<=2026.7",
controlPlaneWrite: true,
},
{ name: "system-presence", scope: "operator.read", since: "<=2026.7" },
{ name: "system-event", scope: "operator.admin", since: "<=2026.7" },
{ name: "message.action", scope: "operator.write", since: "<=2026.7" },
{ name: "conversations.send", scope: "operator.admin", since: "<=2026.7" },
{ name: "conversations.turn", scope: "operator.admin", since: "<=2026.7" },
{ name: "conversations.turn.cancel", scope: "operator.admin", since: "<=2026.7" },
{ name: "send", scope: "operator.write", since: "<=2026.7" },
// 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 },
{ name: "chat.startup", scope: "operator.read", startup: true },
{ name: "chat.metadata", scope: "operator.read", startup: true },
{ name: "chat.message.get", scope: "operator.read", startup: true },
{ name: "chat.abort", scope: "operator.write" },
{ name: "chat.send", scope: "operator.write" },
{ name: "agent", scope: "dynamic", since: "<=2026.7" },
{ name: "agent.identity.get", scope: "operator.read", since: "<=2026.7" },
{ name: "agent.wait", scope: "operator.write", since: "<=2026.7", startup: true },
{ name: "chat.history", scope: "operator.read", since: "<=2026.7", startup: true },
{ name: "chat.startup", scope: "operator.read", since: "<=2026.7", startup: true },
{ name: "chat.metadata", scope: "operator.read", since: "<=2026.7", startup: true },
{ name: "chat.message.get", scope: "operator.read", since: "<=2026.7", startup: true },
{ name: "chat.abort", scope: "operator.write", since: "<=2026.7" },
{ name: "chat.send", scope: "operator.write", since: "<=2026.7" },
// Operator terminal: admin-only PTY surface. Appended to the advertised block
// so existing advertised method indices stay stable for older clients.
{ name: "terminal.open", scope: "operator.admin" },
{ name: "terminal.input", scope: "operator.admin" },
{ name: "terminal.resize", scope: "operator.admin" },
{ name: "terminal.close", scope: "operator.admin" },
{ name: "assistant.media.get", scope: "operator.read", advertise: false },
{ name: "sessions.get", scope: "operator.read", advertise: false },
{ name: "sessions.resolve", scope: "operator.read", advertise: false },
{ name: "sessions.usage", scope: "operator.read", advertise: false },
{ name: "sessions.usage.timeseries", scope: "operator.read", advertise: false },
{ name: "sessions.usage.logs", scope: "operator.read", advertise: false },
{ name: "poll", scope: "operator.write", advertise: false },
{ name: "sessions.steer", scope: "operator.write", advertise: false },
{ name: "push.test", scope: "operator.write", advertise: false },
{ name: "attach.grant", scope: "operator.admin", controlPlaneWrite: true },
{ name: "attach.revoke", scope: "operator.admin" },
{ name: "push.web.vapidPublicKey", scope: "operator.write", advertise: false },
{ name: "push.web.subscribe", scope: "operator.write", advertise: false },
{ name: "push.web.unsubscribe", scope: "operator.write", advertise: false },
{ name: "push.web.test", scope: "operator.write", advertise: false },
{ name: "config.openFile", scope: "operator.admin", advertise: false },
{ name: "connect", scope: "operator.admin", advertise: false },
{ name: "chat.inject", scope: "operator.admin", advertise: false },
{ name: "nativeHook.invoke", scope: "operator.admin", advertise: false },
{ name: "web.login.start", scope: "operator.admin", advertise: false },
{ name: "web.login.wait", scope: "operator.admin", advertise: false },
{ name: "terminal.open", scope: "operator.admin", since: "2026.7" },
{ name: "terminal.input", scope: "operator.admin", since: "2026.7" },
{ name: "terminal.resize", scope: "operator.admin", since: "2026.7" },
{ name: "terminal.close", scope: "operator.admin", since: "2026.7" },
{ name: "assistant.media.get", scope: "operator.read", since: "<=2026.7", advertise: false },
{ name: "sessions.get", scope: "operator.read", since: "<=2026.7", advertise: false },
{ name: "sessions.resolve", scope: "operator.read", since: "<=2026.7", advertise: false },
{ name: "sessions.usage", scope: "operator.read", since: "<=2026.7", advertise: false },
{
name: "sessions.usage.timeseries",
scope: "operator.read",
since: "<=2026.7",
advertise: false,
},
{ name: "sessions.usage.logs", scope: "operator.read", since: "<=2026.7", advertise: false },
{ name: "poll", scope: "operator.write", since: "<=2026.7", advertise: false },
{ name: "sessions.steer", scope: "operator.write", since: "<=2026.7", advertise: false },
{ name: "push.test", scope: "operator.write", since: "<=2026.7", advertise: false },
{ name: "attach.grant", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true },
{ name: "attach.revoke", scope: "operator.admin", since: "<=2026.7" },
{ name: "push.web.vapidPublicKey", scope: "operator.write", since: "<=2026.7", advertise: false },
{ name: "push.web.subscribe", scope: "operator.write", since: "<=2026.7", advertise: false },
{ name: "push.web.unsubscribe", scope: "operator.write", since: "<=2026.7", advertise: false },
{ name: "push.web.test", scope: "operator.write", since: "<=2026.7", advertise: false },
{ name: "config.openFile", scope: "operator.admin", since: "<=2026.7", advertise: false },
{ name: "connect", scope: "operator.admin", since: "<=2026.7", advertise: false },
{ name: "chat.inject", scope: "operator.admin", since: "<=2026.7", advertise: false },
{ name: "nativeHook.invoke", scope: "operator.admin", since: "<=2026.7", advertise: false },
{ name: "web.login.start", scope: "operator.admin", since: "<=2026.7", advertise: false },
{ name: "web.login.wait", scope: "operator.admin", since: "<=2026.7", advertise: false },
// Terminal detach/reattach surface. Kept together near the end so previously
// advertised method indices stay stable for older clients; new methods append.
{ name: "terminal.attach", scope: "operator.admin" },
{ name: "terminal.list", scope: "operator.admin" },
{ name: "terminal.text", scope: "operator.admin" },
{ name: "controlUi.githubPreview", scope: "operator.read" },
{ name: "terminal.attach", scope: "operator.admin", since: "2026.7" },
{ name: "terminal.list", scope: "operator.admin", since: "2026.7" },
{ name: "terminal.text", scope: "operator.admin", since: "2026.7" },
{ name: "controlUi.githubPreview", scope: "operator.read", since: "<=2026.7" },
// Additive discovery methods append here so older clients keep stable indices.
{ name: "system.info", scope: "operator.read" },
{ name: "system.info", scope: "operator.read", since: "<=2026.7" },
// Workspace contents stay in the documented trusted operator domain, like session and log
// reads. Strong user/tenant isolation requires separate Gateways; see operator-scopes.md.
{ name: "agents.workspace.list", scope: "operator.read" },
{ name: "agents.workspace.get", scope: "operator.read" },
{ name: "tts.speak", scope: "operator.write" },
{ name: "plugins.list", scope: "operator.read" },
{ name: "plugins.search", scope: "operator.read" },
{ name: "plugins.install", scope: "operator.admin", controlPlaneWrite: true },
{ name: "plugins.setEnabled", scope: "operator.admin", controlPlaneWrite: true },
{ name: "plugins.uninstall", scope: "operator.admin", controlPlaneWrite: true },
{ name: "plugins.refresh", scope: "operator.admin", controlPlaneWrite: true },
{ name: "agents.workspace.list", scope: "operator.read", since: "2026.7" },
{ name: "agents.workspace.get", scope: "operator.read", since: "2026.7" },
{ name: "tts.speak", scope: "operator.write", since: "2026.7" },
{ name: "plugins.list", scope: "operator.read", since: "<=2026.7" },
{ name: "plugins.search", scope: "operator.read", since: "<=2026.7" },
{ name: "plugins.install", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true },
{
name: "plugins.setEnabled",
scope: "operator.admin",
since: "<=2026.7",
controlPlaneWrite: true,
},
{
name: "plugins.uninstall",
scope: "operator.admin",
since: "<=2026.7",
controlPlaneWrite: true,
},
{ name: "plugins.refresh", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true },
// Session PR chips read the session's own checkout metadata, matching the
// sessions.files.* trusted-operator read domain.
{ name: "controlUi.sessionPullRequests", scope: "operator.read" },
{ name: "controlUi.sessionPullRequests", scope: "operator.read", since: "<=2026.7" },
{
name: "gateway.suspend.prepare",
scope: "operator.admin",
since: "2026.7",
startup: true,
controlPlaneWrite: true,
},
{ name: "gateway.suspend.status", scope: "operator.read" },
{ name: "gateway.suspend.status", scope: "operator.read", since: "2026.7" },
// Resume is the safety escape hatch and must not sit behind write-rate limiting.
{ name: "gateway.suspend.resume", scope: "operator.admin" },
{ name: "gateway.suspend.resume", scope: "operator.admin", since: "2026.7" },
// Spends utility-model tokens on cache misses when the opt-in is enabled, so
// it needs write scope despite being a read-shaped lookup.
{ name: "chat.toolTitles", scope: "operator.write" },
{ name: "chat.toolTitles", scope: "operator.write", since: "<=2026.7" },
// Session checkout diff reads the session's own git worktree, matching the
// sessions.files.* trusted-operator read domain.
{ name: "sessions.diff", scope: "operator.read" },
{ name: "sessions.diff", scope: "operator.read", since: "<=2026.7" },
// Additive protocol methods append here to preserve existing advertised indices.
{ name: "openclaw.setup.verify", scope: "operator.admin" },
{ name: "openclaw.setup.verify", scope: "operator.admin", since: "<=2026.7" },
// Cloud-worker mutations depend on the loaded provider registry and owned
// reconciler, so advertise them early but gate dispatch until sidecars are ready.
{
name: "environments.create",
scope: "operator.admin",
since: "2026.7",
startup: true,
controlPlaneWrite: true,
},
{
name: "environments.destroy",
scope: "operator.admin",
since: "2026.7",
startup: true,
controlPlaneWrite: true,
},
{ name: "sessions.catalog.list", scope: "operator.read" },
{ name: "sessions.catalog.read", scope: "operator.read" },
{ name: "terminal.upload", scope: "operator.admin" },
{ name: "sessions.catalog.continue", scope: "operator.write" },
{ name: "sessions.catalog.archive", scope: "operator.write" },
{ name: "approval.get", scope: "operator.approvals" },
{ name: "approval.resolve", scope: "operator.approvals" },
{ name: "sessions.search", scope: "operator.read" },
{ name: "sessions.catalog.list", scope: "operator.read", since: "2026.7" },
{ name: "sessions.catalog.read", scope: "operator.read", since: "2026.7" },
{ name: "terminal.upload", scope: "operator.admin", since: "2026.7" },
{ name: "sessions.catalog.continue", scope: "operator.write", since: "2026.7" },
{ name: "sessions.catalog.archive", scope: "operator.write", since: "2026.7" },
{ name: "approval.get", scope: "operator.approvals", since: "2026.7" },
{ name: "approval.resolve", scope: "operator.approvals", since: "2026.7" },
{ name: "sessions.search", scope: "operator.read", since: "<=2026.7" },
{
name: "sessions.dispatch",
scope: "operator.admin",
since: "2026.7",
startup: true,
controlPlaneWrite: true,
},
{
name: "sessions.reclaim",
scope: "operator.admin",
since: "2026.7",
startup: true,
controlPlaneWrite: true,
},
{ name: "models.probe", scope: "operator.admin" },
{ name: "models.probe", scope: "operator.admin", since: "<=2026.7" },
// Memory migration reads host assistant state and writes agent workspaces.
{ name: "migrations.memory.plan", scope: "operator.admin" },
{ name: "migrations.memory.apply", scope: "operator.admin", controlPlaneWrite: true },
{ name: "ui.command", scope: "operator.write" },
{ name: "approval.history", scope: "operator.approvals" },
{ name: "plugin.surface.refresh", scope: "operator.read" },
{ name: "conversations.list", scope: "operator.admin" },
{ name: "migrations.memory.plan", scope: "operator.admin", since: "2026.7" },
{
name: "migrations.memory.apply",
scope: "operator.admin",
since: "2026.7",
controlPlaneWrite: true,
},
{ name: "ui.command", scope: "operator.write", since: "2026.7" },
{ name: "approval.history", scope: "operator.approvals", since: "2026.7" },
{ name: "plugin.surface.refresh", scope: "operator.read", since: "<=2026.7" },
{ name: "conversations.list", scope: "operator.admin", since: "<=2026.7" },
] as const;
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
@@ -421,7 +459,12 @@ export function listCoreAdvertisedGatewayMethodNames(): string[] {
/** Returns all registered core method names, including hidden/internal compatibility methods. */
export function listCoreGatewayMethodNames(): string[] {
return CORE_GATEWAY_METHOD_SPECS.map((spec) => spec.name);
return listCoreGatewayMethodMetadata().map((spec) => spec.name);
}
/** Returns the public metadata emitted for every core gateway method. */
export function listCoreGatewayMethodMetadata(): readonly CoreGatewayMethodMetadata[] {
return CORE_GATEWAY_METHOD_SPECS.map(({ name, scope, since }) => ({ name, scope, since }));
}
/** Looks up the raw core method scope, including node and dynamic sentinel scopes. */
@@ -469,6 +512,7 @@ export function createCoreGatewayMethodDescriptors(
handler,
owner: { kind: "core", area: "gateway" },
scope: spec.scope,
...(spec.since ? { since: spec.since } : {}),
...(spec.advertise === false ? { advertise: false } : {}),
...(spec.startup === true ? { startup: "unavailable-until-sidecars" } : {}),
...(spec.controlPlaneWrite === true ? { controlPlaneWrite: true } : {}),
+1
View File
@@ -30,6 +30,7 @@ export type GatewayMethodDescriptor = {
handler: GatewayMethodHandler;
scope: GatewayMethodScope;
owner: GatewayMethodOwner;
since?: string;
startup?: GatewayMethodStartupAvailability;
controlPlaneWrite?: boolean;
advertise?: boolean;
+26 -20
View File
@@ -10,7 +10,7 @@ const QUESTION_RESOLVED_ENTRY_GRACE_MS = 15_000;
const questions: Question[] = [
{
id: "choice",
questionId: "choice",
header: "Choice",
question: "Which option?",
options: [
@@ -20,33 +20,39 @@ const questions: Question[] = [
isOther: true,
},
];
const answers = { answers: { choice: { answers: ["Two"] } } };
const answers = { answers: { choice: ["Two"] } };
const invalidAnswerCases: Array<[string, Question[], QuestionAnswers, string]> = [
["an empty answer map", questions, { answers: {} }, "choice"],
[
"a prototype-key question id with no submitted answer",
[{ ...questions[0]!, questionId: "constructor" }],
{ answers: {} },
"constructor",
],
[
"an unknown question id",
questions,
{ answers: { choice: { answers: ["Two"] }, unknown: { answers: ["value"] } } },
{ answers: { choice: ["Two"], unknown: ["value"] } },
"unknown",
],
[
"a missing question answer",
[...questions, { ...questions[0]!, id: "second" }],
[...questions, { ...questions[0]!, questionId: "second" }],
answers,
"second",
],
["an empty string", questions, { answers: { choice: { answers: [" "] } } }, "choice"],
["an empty string", questions, { answers: { choice: [" "] } }, "choice"],
[
"multiple values for a single-select question",
questions,
{ answers: { choice: { answers: ["One", "Two"] } } },
{ answers: { choice: ["One", "Two"] } },
"choice",
],
[
"a value outside the declared options",
[{ ...questions[0]!, isOther: false }],
{ answers: { choice: { answers: ["Three"] } } },
{ answers: { choice: ["Three"] } },
"choice",
],
];
@@ -69,7 +75,7 @@ describe("QuestionManager", () => {
const first = manager.request({ questions, timeoutMs: 10_000, agentId: "main" });
vi.setSystemTime(1_001);
const second = manager.request({
questions: [{ ...questions[0]!, id: "other" }],
questions: [{ ...questions[0]!, questionId: "other" }],
timeoutMs: 10_000,
sessionKey: "agent:main:main",
});
@@ -119,22 +125,22 @@ describe("QuestionManager", () => {
questions: [{ ...questions[0]!, isOther: false }],
timeoutMs: 10_000,
});
expect(
manager.resolve(strict.id, { answers: { choice: { answers: [" Two "] } } }),
).toMatchObject({ status: "answered" });
expect(manager.resolve(strict.id, { answers: { choice: [" Two "] } })).toMatchObject({
status: "answered",
});
const open = manager.request({ questions, timeoutMs: 10_000 });
expect(
manager.resolve(open.id, { answers: { choice: { answers: ["custom"] } } }),
).toMatchObject({ status: "answered" });
expect(manager.resolve(open.id, { answers: { choice: ["custom"] } })).toMatchObject({
status: "answered",
});
const freeText = manager.request({
questions: [{ ...questions[0]!, options: [], isOther: false }],
timeoutMs: 10_000,
});
expect(
manager.resolve(freeText.id, { answers: { choice: { answers: ["custom"] } } }),
).toMatchObject({ status: "answered" });
expect(manager.resolve(freeText.id, { answers: { choice: ["custom"] } })).toMatchObject({
status: "answered",
});
});
it("times out one waiter without resolving the question", async () => {
@@ -207,7 +213,7 @@ describe("answer canonicalization", () => {
const record = localManager.request({
questions: [
{
id: "pick",
questionId: "pick",
header: "Pick",
question: "Pick one",
options: [{ label: "Two" }, { label: "Three" }],
@@ -217,11 +223,11 @@ describe("answer canonicalization", () => {
timeoutMs: 60_000,
});
const result = localManager.resolve(record.id, {
answers: { pick: { answers: [" Two "] } },
answers: { pick: [" Two "] },
});
expect(result).toEqual({
status: "answered",
answers: { answers: { pick: { answers: ["Two"] } } },
answers: { answers: { pick: ["Two"] } },
});
localManager.reset();
});
+13 -7
View File
@@ -242,22 +242,28 @@ export class QuestionManager {
/** Validates answers against stored questions and returns them in canonical form. */
private validateAnswers(questions: Question[], answers: QuestionAnswers): QuestionAnswers {
const submittedIds = Object.keys(answers.answers);
const questionsById = new Map(questions.map((question) => [question.id, question]));
const questionsById = new Map(questions.map((question) => [question.questionId, question]));
const unknownId = submittedIds.find((id) => !questionsById.has(id));
if (unknownId) {
throw this.invalidAnswer(unknownId, "is not part of this request");
}
// Canonical rebuilds every key as an own property, so downstream readers of
// resolved answers can index the record directly without prototype checks.
const canonical: QuestionAnswers = { answers: {} };
for (const question of questions) {
const values = answers.answers[question.id]?.answers;
// Object.hasOwn: the id grammar admits "constructor"; a plain index read
// would return the inherited prototype member instead of undefined.
const values = Object.hasOwn(answers.answers, question.questionId)
? answers.answers[question.questionId]
: undefined;
if (!values || values.length === 0) {
throw this.invalidAnswer(question.id, "requires an answer");
throw this.invalidAnswer(question.questionId, "requires an answer");
}
if (values.some((value) => !value.trim())) {
throw this.invalidAnswer(question.id, "contains an empty answer");
throw this.invalidAnswer(question.questionId, "contains an empty answer");
}
if (!question.multiSelect && values.length > 1) {
throw this.invalidAnswer(question.id, "does not allow multiple answers");
throw this.invalidAnswer(question.questionId, "does not allow multiple answers");
}
// Store the declared option label when a value matches trim-insensitively;
// downstream renderers compare answers to option labels exactly.
@@ -270,9 +276,9 @@ export class QuestionManager {
!question.isOther &&
canonicalValues.some((value) => !question.options.some((option) => option.label === value))
) {
throw this.invalidAnswer(question.id, "contains an unknown option");
throw this.invalidAnswer(question.questionId, "contains an unknown option");
}
canonical.answers[question.id] = { answers: canonicalValues };
canonical.answers[question.questionId] = canonicalValues;
}
return canonical;
}
+6 -6
View File
@@ -41,8 +41,8 @@ async function call(method: string, params: Record<string, unknown>) {
const requestParams = {
questions: [
{
id: "destination",
header: "Longer than twelve",
questionId: "destination",
header: "Destination",
question: "Where next?",
options: [],
multiSelect: false,
@@ -56,7 +56,7 @@ const requestParams = {
};
describe("question gateway methods", () => {
it("requests normalized questions, then gets and lists them", async () => {
it("requests questions, then gets and lists them", async () => {
const requested = await call("question.request", {
...requestParams,
id: "client-question-id",
@@ -68,7 +68,7 @@ describe("question gateway methods", () => {
"question.requested",
expect.objectContaining({
id,
questions: [expect.objectContaining({ header: "Longer than " })],
questions: [expect.objectContaining({ header: "Destination" })],
status: "pending",
}),
);
@@ -88,7 +88,7 @@ describe("question gateway methods", () => {
it("broadcasts answered and expired terminal states", async () => {
const requested = await call("question.request", requestParams);
const id = (requested[1] as { id: string }).id;
const answers = { answers: { destination: { answers: ["Home"] } } };
const answers = { answers: { destination: ["Home"] } };
expect(await call("question.resolve", { id, answers, resolvedBy: "control-ui" })).toEqual([
true,
@@ -173,7 +173,7 @@ describe("question gateway methods", () => {
const resolved = await call("question.resolve", {
id,
answers: { answers: { destination: { answers: ["Somewhere else"] } } },
answers: { answers: { destination: ["Somewhere else"] } },
});
expect(resolved[0]).toBe(false);
+7 -11
View File
@@ -1,5 +1,4 @@
// Question gateway methods create, inspect, wait for, and resolve transient prompts.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import {
ErrorCodes,
errorShape,
@@ -58,18 +57,18 @@ function managerError(error: unknown, respond: RespondFn): boolean {
function normalizeQuestions(params: QuestionRequestParams): Question[] {
const ids = new Set<string>();
return params.questions.map((question) => {
if (ids.has(question.id)) {
throw new QuestionRequestValidationError(`duplicate question id '${question.id}'`);
if (ids.has(question.questionId)) {
throw new QuestionRequestValidationError(`duplicate question id '${question.questionId}'`);
}
ids.add(question.id);
ids.add(question.questionId);
if (question.options.length === 1) {
throw new QuestionRequestValidationError(
`question '${question.id}' must have either no options or 2 to 4 options`,
`question '${question.questionId}' must have either no options or 2 to 4 options`,
);
}
if (question.isSecret) {
throw new QuestionRequestValidationError(
`question '${question.id}': secret questions are not supported yet`,
`question '${question.questionId}': secret questions are not supported yet`,
);
}
const optionLabels = new Set<string>();
@@ -77,15 +76,12 @@ function normalizeQuestions(params: QuestionRequestParams): Question[] {
const normalizedLabel = option.label.trim().toLowerCase();
if (optionLabels.has(normalizedLabel)) {
throw new QuestionRequestValidationError(
`question '${question.id}' has duplicate option label '${option.label}'`,
`question '${question.questionId}' has duplicate option label '${option.label}'`,
);
}
optionLabels.add(normalizedLabel);
}
return {
...question,
header: truncateUtf16Safe(question.header, 12),
};
return question;
});
}
@@ -177,6 +177,21 @@ describe("session catalog Gateway methods", () => {
}
});
it("rejects host cursors without a catalog selector", async () => {
const respond = await call("sessions.catalog.list", {
cursors: { "gateway:local": "next" },
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
message: "catalogId is required when cursors are provided",
}),
);
});
it("normalizes search once before dispatching every provider", async () => {
const alphaList = vi.fn(async () => []);
const zetaList = vi.fn(async () => []);

Some files were not shown because too many files have changed in this diff Show More