diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 17797f789404..a35de07f1403 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -231,6 +231,11 @@ enum class GatewayMethod( McpAppListResourceTemplates("mcp.app.listResourceTemplates"), McpAppReadResource("mcp.app.readResource"), McpAppCallTool("mcp.app.callTool"), + BoardGet("board.get"), + BoardUpdate("board.update"), + BoardWidgetPut("board.widget.put"), + BoardWidgetGrant("board.widget.grant"), + BoardEvent("board.event"), AuditList("audit.list"), AuditActivityList("audit.activity.list"), TasksList("tasks.list"), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt index 3a25165d3b65..5b9e93570587 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt @@ -417,6 +417,7 @@ internal val nativeStringResourceIds: Map = "Current session" to R.string.native_a32789d2b53dc704, "D" to R.string.native_3f39d5c348e5b79d, "DIARY" to R.string.native_6cb1e6a563098eba, + "Dashboard" to R.string.native_67b696468610b879, "Default" to R.string.native_21b111cbfe6e8fca, "Default Agent" to R.string.native_0349070711a2323d, "Default agent" to R.string.native_94da52ecd6c5c3b7, diff --git a/apps/android/app/src/main/res/values-ar/strings.xml b/apps/android/app/src/main/res/values-ar/strings.xml index 8b752f8aa342..353864f5fd60 100644 --- a/apps/android/app/src/main/res/values-ar/strings.xml +++ b/apps/android/app/src/main/res/values-ar/strings.xml @@ -613,6 +613,7 @@ "تفاصيل الجدول" "إمكانات الهاتف" "غير متاح" + "Dashboard" "لصق الرمز المميز" "لا يوجد موفرون" "ميكروفون Bluetooth" diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 3da92011b913..5452ae59bc4a 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -613,6 +613,7 @@ "Zeitplandetails" "Telefonfunktionen" "Nicht verfügbar" + "Dashboard" "Token einfügen" "Keine Anbieter" "Bluetooth-Mikrofon" diff --git a/apps/android/app/src/main/res/values-es/strings.xml b/apps/android/app/src/main/res/values-es/strings.xml index 2f0183fd1291..d61ca45d238e 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -613,6 +613,7 @@ "Detalles de la programación" "Capacidades del teléfono" "No disponible" + "Dashboard" "Pegar token" "No hay proveedores" "Micrófono Bluetooth" diff --git a/apps/android/app/src/main/res/values-fa/strings.xml b/apps/android/app/src/main/res/values-fa/strings.xml index 8c4f4a73bb33..777106e2ae9a 100644 --- a/apps/android/app/src/main/res/values-fa/strings.xml +++ b/apps/android/app/src/main/res/values-fa/strings.xml @@ -613,6 +613,7 @@ "جزئیات زمان‌بندی" "قابلیت‌های تلفن" "در دسترس نیست" + "Dashboard" "چسباندن توکن" "ارائه‌دهنده‌ای وجود ندارد" "میکروفون Bluetooth" diff --git a/apps/android/app/src/main/res/values-fr/strings.xml b/apps/android/app/src/main/res/values-fr/strings.xml index dc3b7ae05516..65035e3e37db 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -613,6 +613,7 @@ "Détails de la planification" "Fonctionnalités du téléphone" "Non disponible" + "Dashboard" "Coller le jeton" "Aucun fournisseur" "Microphone Bluetooth" diff --git a/apps/android/app/src/main/res/values-hi/strings.xml b/apps/android/app/src/main/res/values-hi/strings.xml index 143d9b6daabf..47ad310bb46f 100644 --- a/apps/android/app/src/main/res/values-hi/strings.xml +++ b/apps/android/app/src/main/res/values-hi/strings.xml @@ -613,6 +613,7 @@ "शेड्यूल का विवरण" "फ़ोन की क्षमताएँ" "उपलब्ध नहीं" + "Dashboard" "टोकन पेस्ट करें" "कोई प्रदाता नहीं" "Bluetooth माइक्रोफ़ोन" diff --git a/apps/android/app/src/main/res/values-in/strings.xml b/apps/android/app/src/main/res/values-in/strings.xml index ea6f87f1cffd..73851aa080fd 100644 --- a/apps/android/app/src/main/res/values-in/strings.xml +++ b/apps/android/app/src/main/res/values-in/strings.xml @@ -613,6 +613,7 @@ "Detail Jadwal" "Kemampuan Ponsel" "Tidak tersedia" + "Dashboard" "Tempel token" "Tidak ada penyedia" "Mikrofon Bluetooth" diff --git a/apps/android/app/src/main/res/values-it/strings.xml b/apps/android/app/src/main/res/values-it/strings.xml index a854ebf46456..a5dea0df39f4 100644 --- a/apps/android/app/src/main/res/values-it/strings.xml +++ b/apps/android/app/src/main/res/values-it/strings.xml @@ -613,6 +613,7 @@ "Dettagli pianificazione" "Funzionalità del telefono" "Non disponibile" + "Dashboard" "Incolla token" "Nessun provider" "Microfono Bluetooth" diff --git a/apps/android/app/src/main/res/values-ja/strings.xml b/apps/android/app/src/main/res/values-ja/strings.xml index 7e2b866ea267..0ac6247a836c 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -613,6 +613,7 @@ "スケジュールの詳細" "電話の機能" "利用できません" + "Dashboard" "トークンを貼り付け" "プロバイダーがありません" "Bluetoothマイク" diff --git a/apps/android/app/src/main/res/values-ko/strings.xml b/apps/android/app/src/main/res/values-ko/strings.xml index 1223da5b6f83..46925e615a57 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -613,6 +613,7 @@ "일정 상세 정보" "휴대전화 기능" "사용할 수 없음" + "Dashboard" "토큰 붙여넣기" "제공자 없음" "Bluetooth 마이크" diff --git a/apps/android/app/src/main/res/values-nl/strings.xml b/apps/android/app/src/main/res/values-nl/strings.xml index 3dd37fb59f5a..d46c9b89454f 100644 --- a/apps/android/app/src/main/res/values-nl/strings.xml +++ b/apps/android/app/src/main/res/values-nl/strings.xml @@ -613,6 +613,7 @@ "Planningsdetails" "Telefoonmogelijkheden" "Niet beschikbaar" + "Dashboard" "Token plakken" "Geen providers" "Bluetooth-microfoon" diff --git a/apps/android/app/src/main/res/values-pl/strings.xml b/apps/android/app/src/main/res/values-pl/strings.xml index 8db1d89b7a89..19ff07cef90b 100644 --- a/apps/android/app/src/main/res/values-pl/strings.xml +++ b/apps/android/app/src/main/res/values-pl/strings.xml @@ -613,6 +613,7 @@ "Szczegóły harmonogramu" "Funkcje telefonu" "Niedostępne" + "Dashboard" "Wklej token" "Brak dostawców" "Mikrofon Bluetooth" diff --git a/apps/android/app/src/main/res/values-pt-rBR/strings.xml b/apps/android/app/src/main/res/values-pt-rBR/strings.xml index 1912b95df076..30c2631ce8cf 100644 --- a/apps/android/app/src/main/res/values-pt-rBR/strings.xml +++ b/apps/android/app/src/main/res/values-pt-rBR/strings.xml @@ -613,6 +613,7 @@ "Detalhes do agendamento" "Recursos do telefone" "Não disponível" + "Dashboard" "Colar token" "Nenhum provedor" "Microfone Bluetooth" diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 1d9d0893c301..aff4cba1eb99 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -613,6 +613,7 @@ "Сведения о расписании" "Возможности телефона" "Недоступно" + "Dashboard" "Вставить токен" "Нет провайдеров" "Bluetooth-микрофон" diff --git a/apps/android/app/src/main/res/values-sv/strings.xml b/apps/android/app/src/main/res/values-sv/strings.xml index aab49a300267..806ccfdc141c 100644 --- a/apps/android/app/src/main/res/values-sv/strings.xml +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -613,6 +613,7 @@ "Schemadetaljer" "Telefonfunktioner" "Inte tillgänglig" + "Dashboard" "Klistra in token" "Inga leverantörer" "Bluetooth-mikrofon" diff --git a/apps/android/app/src/main/res/values-th/strings.xml b/apps/android/app/src/main/res/values-th/strings.xml index 147cdbf079a6..6431754eb9bd 100644 --- a/apps/android/app/src/main/res/values-th/strings.xml +++ b/apps/android/app/src/main/res/values-th/strings.xml @@ -613,6 +613,7 @@ "รายละเอียดกำหนดการ" "ความสามารถของโทรศัพท์" "ไม่พร้อมใช้งาน" + "Dashboard" "วางโทเค็น" "ไม่มีผู้ให้บริการ" "ไมโครโฟน Bluetooth" diff --git a/apps/android/app/src/main/res/values-tr/strings.xml b/apps/android/app/src/main/res/values-tr/strings.xml index 553268ae0d31..f93e3dc3484c 100644 --- a/apps/android/app/src/main/res/values-tr/strings.xml +++ b/apps/android/app/src/main/res/values-tr/strings.xml @@ -613,6 +613,7 @@ "Zamanlama Ayrıntısı" "Telefon Özellikleri" "Kullanılamıyor" + "Dashboard" "Token\'ı yapıştır" "Sağlayıcı yok" "Bluetooth mikrofonu" diff --git a/apps/android/app/src/main/res/values-uk/strings.xml b/apps/android/app/src/main/res/values-uk/strings.xml index 508a6c8f4a78..49c8c8cb6d1c 100644 --- a/apps/android/app/src/main/res/values-uk/strings.xml +++ b/apps/android/app/src/main/res/values-uk/strings.xml @@ -613,6 +613,7 @@ "Відомості про розклад" "Можливості телефона" "Недоступно" + "Dashboard" "Вставити токен" "Немає постачальників" "Bluetooth-мікрофон" diff --git a/apps/android/app/src/main/res/values-vi/strings.xml b/apps/android/app/src/main/res/values-vi/strings.xml index 18059c2a8be3..b826d6d7b681 100644 --- a/apps/android/app/src/main/res/values-vi/strings.xml +++ b/apps/android/app/src/main/res/values-vi/strings.xml @@ -613,6 +613,7 @@ "Chi tiết lịch trình" "Khả năng của điện thoại" "Không khả dụng" + "Dashboard" "Dán token" "Không có nhà cung cấp" "Micrô Bluetooth" diff --git a/apps/android/app/src/main/res/values-zh-rCN/strings.xml b/apps/android/app/src/main/res/values-zh-rCN/strings.xml index e2b465af6464..15d3211c2931 100644 --- a/apps/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rCN/strings.xml @@ -613,6 +613,7 @@ "计划详情" "手机功能" "不可用" + "Dashboard" "粘贴令牌" "无提供商" "蓝牙麦克风" diff --git a/apps/android/app/src/main/res/values-zh-rTW/strings.xml b/apps/android/app/src/main/res/values-zh-rTW/strings.xml index 9f35fc0ecf49..1d26b4f6f03e 100644 --- a/apps/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rTW/strings.xml @@ -613,6 +613,7 @@ "排程詳細資料" "手機功能" "無法使用" + "Dashboard" "貼上 token" "沒有提供者" "Bluetooth 麥克風" diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index cddfc2388975..3975fd16b1e9 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -613,6 +613,7 @@ "Schedule Detail" "Phone Capabilities" "Not available" + "Dashboard" "Paste token" "No providers" "Bluetooth microphone" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 36fc1402492e..3da7628c8195 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -274,6 +274,16 @@ } } }, + "dashboard": { + "emoji": "📋", + "title": "Dashboard", + "detailKeys": [ + "action", + "tabId", + "name", + "title" + ] + }, "nodes": { "emoji": "📱", "title": "Nodes", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 1c3289ace1c0..37a29eb28c01 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -186,6 +186,534 @@ public enum QuestionStatus: String, Codable, Sendable { case expired = "expired" } +public struct BoardTab: Codable, Sendable { + public let tabid: String + public let title: String + public let position: Int + public let chatdock: AnyCodable + + public init( + tabid: String, + title: String, + position: Int, + chatdock: AnyCodable) + { + self.tabid = tabid + self.title = title + self.position = position + self.chatdock = chatdock + } + + private enum CodingKeys: String, CodingKey { + case tabid = "tabId" + case title + case position + case chatdock = "chatDock" + } +} + +public struct BoardWidget: Codable, Sendable { + public let name: String + public let tabid: String + public let title: String? + public let contentkind: AnyCodable + public let sizew: Int + public let sizeh: Int + public let position: Int + public let grantstate: AnyCodable + public let revision: Int + public let declaredsummary: [String]? + public let frameurl: String? + + public init( + name: String, + tabid: String, + title: String? = nil, + contentkind: AnyCodable, + sizew: Int, + sizeh: Int, + position: Int, + grantstate: AnyCodable, + revision: Int, + declaredsummary: [String]? = nil, + frameurl: String? = nil) + { + self.name = name + self.tabid = tabid + self.title = title + self.contentkind = contentkind + self.sizew = sizew + self.sizeh = sizeh + self.position = position + self.grantstate = grantstate + self.revision = revision + self.declaredsummary = declaredsummary + self.frameurl = frameurl + } + + private enum CodingKeys: String, CodingKey { + case name + case tabid = "tabId" + case title + case contentkind = "contentKind" + case sizew = "sizeW" + case sizeh = "sizeH" + case position + case grantstate = "grantState" + case revision + case declaredsummary = "declaredSummary" + case frameurl = "frameUrl" + } +} + +public struct BoardSnapshot: Codable, Sendable { + public let sessionkey: String + public let revision: Int + public let tabs: [BoardTab] + public let widgets: [BoardWidget] + + public init( + sessionkey: String, + revision: Int, + tabs: [BoardTab], + widgets: [BoardWidget]) + { + self.sessionkey = sessionkey + self.revision = revision + self.tabs = tabs + self.widgets = widgets + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case revision + case tabs + case widgets + } +} + +public struct BoardTabCreateOp: Codable, Sendable { + public let kind: String + public let tabid: String + public let title: String + public let chatdock: AnyCodable? + + public init( + kind: String, + tabid: String, + title: String, + chatdock: AnyCodable? = nil) + { + self.kind = kind + self.tabid = tabid + self.title = title + self.chatdock = chatdock + } + + private enum CodingKeys: String, CodingKey { + case kind + case tabid = "tabId" + case title + case chatdock = "chatDock" + } +} + +public struct BoardTabUpdateOp: Codable, Sendable { + public let kind: String + public let tabid: String + public let title: String? + public let chatdock: AnyCodable? + public let position: Int? + + public init( + kind: String, + tabid: String, + title: String? = nil, + chatdock: AnyCodable? = nil, + position: Int? = nil) + { + self.kind = kind + self.tabid = tabid + self.title = title + self.chatdock = chatdock + self.position = position + } + + private enum CodingKeys: String, CodingKey { + case kind + case tabid = "tabId" + case title + case chatdock = "chatDock" + case position + } +} + +public struct BoardTabDeleteOp: Codable, Sendable { + public let kind: String + public let tabid: String + + public init( + kind: String, + tabid: String) + { + self.kind = kind + self.tabid = tabid + } + + private enum CodingKeys: String, CodingKey { + case kind + case tabid = "tabId" + } +} + +public struct BoardTabsReorderOp: Codable, Sendable { + public let kind: String + public let tabids: [String] + + public init( + kind: String, + tabids: [String]) + { + self.kind = kind + self.tabids = tabids + } + + private enum CodingKeys: String, CodingKey { + case kind + case tabids = "tabIds" + } +} + +public struct BoardWidgetMoveOp: Codable, Sendable { + public let kind: String + public let name: String + public let tabid: String? + public let position: Int? + public let after: String? + + public init( + kind: String, + name: String, + tabid: String? = nil, + position: Int? = nil, + after: String? = nil) + { + self.kind = kind + self.name = name + self.tabid = tabid + self.position = position + self.after = after + } + + private enum CodingKeys: String, CodingKey { + case kind + case name + case tabid = "tabId" + case position + case after + } +} + +public struct BoardWidgetResizeOp: Codable, Sendable { + public let kind: String + public let name: String + public let sizew: Int + public let sizeh: Int + + public init( + kind: String, + name: String, + sizew: Int, + sizeh: Int) + { + self.kind = kind + self.name = name + self.sizew = sizew + self.sizeh = sizeh + } + + private enum CodingKeys: String, CodingKey { + case kind + case name + case sizew = "sizeW" + case sizeh = "sizeH" + } +} + +public struct BoardWidgetRemoveOp: Codable, Sendable { + public let kind: String + public let name: String + + public init( + kind: String, + name: String) + { + self.kind = kind + self.name = name + } + + private enum CodingKeys: String, CodingKey { + case kind + case name + } +} + +public struct BoardMcpAppDescriptor: Codable, Sendable { + public let servername: String + public let toolname: String + public let uiresourceuri: String + public let originsessionkey: String + public let toolcallid: String + + public init( + servername: String, + toolname: String, + uiresourceuri: String, + originsessionkey: String, + toolcallid: String) + { + self.servername = servername + self.toolname = toolname + self.uiresourceuri = uiresourceuri + self.originsessionkey = originsessionkey + self.toolcallid = toolcallid + } + + private enum CodingKeys: String, CodingKey { + case servername = "serverName" + case toolname = "toolName" + case uiresourceuri = "uiResourceUri" + case originsessionkey = "originSessionKey" + case toolcallid = "toolCallId" + } +} + +public struct BoardWidgetHtmlContent: Codable, Sendable { + public let kind: String + public let html: String + + public init( + kind: String, + html: String) + { + self.kind = kind + self.html = html + } + + private enum CodingKeys: String, CodingKey { + case kind + case html + } +} + +public struct BoardWidgetMcpAppContent: Codable, Sendable { + public let kind: String + public let descriptor: BoardMcpAppDescriptor + + public init( + kind: String, + descriptor: BoardMcpAppDescriptor) + { + self.kind = kind + self.descriptor = descriptor + } + + private enum CodingKeys: String, CodingKey { + case kind + case descriptor + } +} + +public struct BoardGetParams: Codable, Sendable { + public let sessionkey: String + + public init( + sessionkey: String) + { + self.sessionkey = sessionkey + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + } +} + +public struct BoardUpdateParams: Codable, Sendable { + public let sessionkey: String + public let ops: [BoardOp] + + public init( + sessionkey: String, + ops: [BoardOp]) + { + self.sessionkey = sessionkey + self.ops = ops + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case ops + } +} + +public struct BoardWidgetPutParams: Codable, Sendable { + public let sessionkey: String + public let name: String + public let title: String? + public let content: BoardWidgetContent + public let placement: [String: AnyCodable]? + public let declared: [String: AnyCodable]? + + public init( + sessionkey: String, + name: String, + title: String? = nil, + content: BoardWidgetContent, + placement: [String: AnyCodable]? = nil, + declared: [String: AnyCodable]? = nil) + { + self.sessionkey = sessionkey + self.name = name + self.title = title + self.content = content + self.placement = placement + self.declared = declared + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case name + case title + case content + case placement + case declared + } +} + +public struct BoardWidgetGrantParams: Codable, Sendable { + public let sessionkey: String + public let name: String + public let decision: AnyCodable + public let revision: Int + + public init( + sessionkey: String, + name: String, + decision: AnyCodable, + revision: Int) + { + self.sessionkey = sessionkey + self.name = name + self.decision = decision + self.revision = revision + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case name + case decision + case revision + } +} + +public struct BoardEventParams: Codable, Sendable { + public let sessionkey: String + public let widget: String + public let payload: AnyCodable + + public init( + sessionkey: String, + widget: String, + payload: AnyCodable) + { + self.sessionkey = sessionkey + self.widget = widget + self.payload = payload + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case widget + case payload + } +} + +public struct BoardChangedEvent: Codable, Sendable { + public let sessionkey: String + public let revision: Int + public let widget: String? + + public init( + sessionkey: String, + revision: Int, + widget: String? = nil) + { + self.sessionkey = sessionkey + self.revision = revision + self.widget = widget + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case revision + case widget + } +} + +public struct BoardFocusTabCommand: Codable, Sendable { + public let kind: String + public let tabid: String + + public init( + kind: String, + tabid: String) + { + self.kind = kind + self.tabid = tabid + } + + private enum CodingKeys: String, CodingKey { + case kind + case tabid = "tabId" + } +} + +public struct BoardSetChatDockCommand: Codable, Sendable { + public let kind: String + public let dock: AnyCodable + + public init( + kind: String, + dock: AnyCodable) + { + self.kind = kind + self.dock = dock + } + + private enum CodingKeys: String, CodingKey { + case kind + case dock + } +} + +public struct BoardCommandEvent: Codable, Sendable { + public let sessionkey: String + public let command: BoardCommand + + public init( + sessionkey: String, + command: BoardCommand) + { + self.sessionkey = sessionkey + self.command = command + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case command + } +} + public struct ConnectParams: Codable, Sendable { public let minprotocol: Int public let maxprotocol: Int @@ -14591,6 +15119,114 @@ public struct ShutdownEvent: Codable, Sendable { } } +public enum BoardOp: Codable, Sendable { + case tabCreate(BoardTabCreateOp) + case tabUpdate(BoardTabUpdateOp) + case tabDelete(BoardTabDeleteOp) + case tabsReorder(BoardTabsReorderOp) + case widgetMove(BoardWidgetMoveOp) + case widgetResize(BoardWidgetResizeOp) + case widgetRemove(BoardWidgetRemoveOp) + + private enum CodingKeys: String, CodingKey { + case discriminator = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .discriminator) + switch discriminator { + case "tab_create": self = try .tabCreate(BoardTabCreateOp(from: decoder)) + case "tab_update": self = try .tabUpdate(BoardTabUpdateOp(from: decoder)) + case "tab_delete": self = try .tabDelete(BoardTabDeleteOp(from: decoder)) + case "tabs_reorder": self = try .tabsReorder(BoardTabsReorderOp(from: decoder)) + case "widget_move": self = try .widgetMove(BoardWidgetMoveOp(from: decoder)) + case "widget_resize": self = try .widgetResize(BoardWidgetResizeOp(from: decoder)) + case "widget_remove": self = try .widgetRemove(BoardWidgetRemoveOp(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .discriminator, + in: container, + debugDescription: "Unknown BoardOp discriminator value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .tabCreate(let value): try value.encode(to: encoder) + case .tabUpdate(let value): try value.encode(to: encoder) + case .tabDelete(let value): try value.encode(to: encoder) + case .tabsReorder(let value): try value.encode(to: encoder) + case .widgetMove(let value): try value.encode(to: encoder) + case .widgetResize(let value): try value.encode(to: encoder) + case .widgetRemove(let value): try value.encode(to: encoder) + } + } +} + +public enum BoardWidgetContent: Codable, Sendable { + case html(BoardWidgetHtmlContent) + case mcpApp(BoardWidgetMcpAppContent) + + private enum CodingKeys: String, CodingKey { + case discriminator = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .discriminator) + switch discriminator { + case "html": self = try .html(BoardWidgetHtmlContent(from: decoder)) + case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppContent(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .discriminator, + in: container, + debugDescription: "Unknown BoardWidgetContent discriminator value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .html(let value): try value.encode(to: encoder) + case .mcpApp(let value): try value.encode(to: encoder) + } + } +} + +public enum BoardCommand: Codable, Sendable { + case focusTab(BoardFocusTabCommand) + case setChatDock(BoardSetChatDockCommand) + + private enum CodingKeys: String, CodingKey { + case discriminator = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .discriminator) + switch discriminator { + case "focus_tab": self = try .focusTab(BoardFocusTabCommand(from: decoder)) + case "set_chat_dock": self = try .setChatDock(BoardSetChatDockCommand(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .discriminator, + in: container, + debugDescription: "Unknown BoardCommand discriminator value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .focusTab(let value): try value.encode(to: encoder) + case .setChatDock(let value): try value.encode(to: encoder) + } + } +} + public enum GatewaySuspendPrepareResult: Codable, Sendable { case busy(GatewaySuspendPrepareBusyResult) case ready(GatewaySuspendPrepareReadyResult) diff --git a/config/knip.config.ts b/config/knip.config.ts index 702fcc587be9..75a55c58c406 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -344,6 +344,12 @@ const config = { // reporting enabled. Suppress them only in this application-production scan. ignoreIssues: { "scripts/**": ["exports", "nsExports", "types", "nsTypes", "enumMembers", "namespaceMembers"], + // The full-tree companion config makes tests entrypoints; these contracts + // are intentionally test-only in the production graph. + "src/boards/board-layout.ts": ["types"], + "src/boards/board-notices.ts": ["exports"], + "src/boards/board-store.ts": ["exports"], + "src/gateway/board-view-ticket.ts": ["exports"], }, workspaces: { ".": { diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 941fdd7c9bec..8ca32c7074a8 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -15,9 +15,15 @@ export * from "./schema/worker-inference.js"; export * from "./schema/skill-history.js"; export * from "./schema/ui-command.js"; export type { GatewayErrorDetails, MissingScopeErrorDetails } from "./schema/error-codes.js"; +export * from "./schema/board.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; import { + BoardEventParamsSchema, + BoardGetParamsSchema, + BoardUpdateParamsSchema, + BoardWidgetGrantParamsSchema, + BoardWidgetPutParamsSchema, AgentEventSchema, AuditActivityAgentRunV1Schema, AuditActivityEventV1Schema, @@ -631,6 +637,11 @@ export const validateAgentWaitParams = lazyCompile(AgentWaitParamsSchema); export const validateWakeParams = lazyCompile(WakeParamsSchema); export const validateAgentsListParams = lazyCompile(AgentsListParamsSchema); export const validateWorktreesListParams = lazyCompile(WorktreesListParamsSchema); +export const validateBoardGetParams = lazyCompile(BoardGetParamsSchema); +export const validateBoardUpdateParams = lazyCompile(BoardUpdateParamsSchema); +export const validateBoardWidgetPutParams = lazyCompile(BoardWidgetPutParamsSchema); +export const validateBoardWidgetGrantParams = lazyCompile(BoardWidgetGrantParamsSchema); +export const validateBoardEventParams = lazyCompile(BoardEventParamsSchema); export const validateWorktreesCreateParams = lazyCompile(WorktreesCreateParamsSchema); export const validateWorktreesRemoveParams = lazyCompile(WorktreesRemoveParamsSchema); export const validateWorktreesRestoreParams = lazyCompile(WorktreesRestoreParamsSchema); diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index e80d82e0c40f..2572ca51410f 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -12,6 +12,7 @@ export * from "./schema/artifacts.js"; export * from "./schema/approvals.js"; export * from "./schema/audit-activity.js"; export * from "./schema/audit.js"; +export * from "./schema/board.js"; export * from "./schema/channels.js"; export * from "./schema/talk-marks.js"; export * from "./schema/commands.js"; diff --git a/packages/gateway-protocol/src/schema/board.test.ts b/packages/gateway-protocol/src/schema/board.test.ts new file mode 100644 index 000000000000..1cabae4f4771 --- /dev/null +++ b/packages/gateway-protocol/src/schema/board.test.ts @@ -0,0 +1,60 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { BoardSnapshotSchema, BoardWidgetGrantParamsSchema } from "./board.js"; + +describe("BoardSnapshotSchema", () => { + it("accepts optional HTML widget view metadata", () => { + const snapshot = { + sessionKey: "agent:main:main", + revision: 1, + tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }], + widgets: [ + { + name: "status", + tabId: "main", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 0, + grantState: "none", + revision: 1, + declaredSummary: ["Network access: https://example.com"], + frameUrl: "/__openclaw__/board/agent%3Amain%3Amain/status/index.html?bt=ticket", + }, + ], + }; + expect(Value.Check(BoardSnapshotSchema, snapshot)).toBe(true); + expect( + Value.Check(BoardSnapshotSchema, { + ...snapshot, + widgets: [{ ...snapshot.widgets[0], frameUrl: 42 }], + }), + ).toBe(false); + expect( + Value.Check(BoardSnapshotSchema, { + ...snapshot, + widgets: [{ ...snapshot.widgets[0], declaredSummary: [42] }], + }), + ).toBe(false); + }); +}); + +describe("BoardWidgetGrantParamsSchema", () => { + it("requires the widget revision being approved", () => { + expect( + Value.Check(BoardWidgetGrantParamsSchema, { + sessionKey: "agent:main:main", + name: "status", + decision: "granted", + revision: 1, + }), + ).toBe(true); + expect( + Value.Check(BoardWidgetGrantParamsSchema, { + sessionKey: "agent:main:main", + name: "status", + decision: "granted", + }), + ).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/board.ts b/packages/gateway-protocol/src/schema/board.ts new file mode 100644 index 000000000000..db49f154a5db --- /dev/null +++ b/packages/gateway-protocol/src/schema/board.ts @@ -0,0 +1,202 @@ +import type { Static } from "typebox"; +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; + +export const BoardTabIdSchema = Type.String({ pattern: "^[a-z0-9-]{1,40}$" }); +export const BoardWidgetNameSchema = Type.String({ + pattern: "^[a-z0-9][a-z0-9._-]{0,63}$", +}); +export const BoardChatDockSchema = Type.Union([ + Type.Literal("left"), + Type.Literal("right"), + Type.Literal("bottom"), + Type.Literal("hidden"), +]); +export const BoardSizeSchema = Type.Union([ + Type.Literal("sm"), + Type.Literal("md"), + Type.Literal("lg"), + Type.Literal("xl"), + Type.Literal("full"), +]); + +export const BoardTabSchema = closedObject({ + tabId: BoardTabIdSchema, + title: Type.String({ minLength: 1, maxLength: 80 }), + position: Type.Integer({ minimum: 0 }), + chatDock: BoardChatDockSchema, +}); +export type BoardTab = Static; + +export const BoardWidgetSchema = closedObject({ + name: BoardWidgetNameSchema, + tabId: BoardTabIdSchema, + title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), + contentKind: Type.Union([Type.Literal("html"), Type.Literal("mcp-app")]), + sizeW: Type.Integer({ minimum: 1, maximum: 12 }), + sizeH: Type.Integer({ minimum: 1, maximum: 20 }), + position: Type.Integer({ minimum: 0 }), + grantState: Type.Union([ + Type.Literal("none"), + Type.Literal("pending"), + Type.Literal("granted"), + Type.Literal("rejected"), + ]), + revision: Type.Integer({ minimum: 1 }), + declaredSummary: Type.Optional(Type.Array(Type.String())), + frameUrl: Type.Optional(Type.String()), +}); +export type BoardWidget = Static; + +export const BoardSnapshotSchema = closedObject({ + sessionKey: NonEmptyString, + revision: Type.Integer({ minimum: 0 }), + tabs: Type.Array(BoardTabSchema), + widgets: Type.Array(BoardWidgetSchema), +}); +export type BoardSnapshot = Static; + +export const BoardTabCreateOpSchema = closedObject({ + kind: Type.Literal("tab_create"), + tabId: BoardTabIdSchema, + title: Type.String({ minLength: 1, maxLength: 80 }), + chatDock: Type.Optional(BoardChatDockSchema), +}); +export const BoardTabUpdateOpSchema = closedObject({ + kind: Type.Literal("tab_update"), + tabId: BoardTabIdSchema, + title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), + chatDock: Type.Optional(BoardChatDockSchema), + position: Type.Optional(Type.Integer({ minimum: 0 })), +}); +export const BoardTabDeleteOpSchema = closedObject({ + kind: Type.Literal("tab_delete"), + tabId: BoardTabIdSchema, +}); +export const BoardTabsReorderOpSchema = closedObject({ + kind: Type.Literal("tabs_reorder"), + tabIds: Type.Array(BoardTabIdSchema), +}); +export const BoardWidgetMoveOpSchema = closedObject({ + kind: Type.Literal("widget_move"), + name: BoardWidgetNameSchema, + tabId: Type.Optional(BoardTabIdSchema), + position: Type.Optional(Type.Integer({ minimum: 0 })), + after: Type.Optional(BoardWidgetNameSchema), +}); +export const BoardWidgetResizeOpSchema = closedObject({ + kind: Type.Literal("widget_resize"), + name: BoardWidgetNameSchema, + sizeW: Type.Integer(), + sizeH: Type.Integer(), +}); +export const BoardWidgetRemoveOpSchema = closedObject({ + kind: Type.Literal("widget_remove"), + name: BoardWidgetNameSchema, +}); +export const BoardOpSchema = Type.Union([ + BoardTabCreateOpSchema, + BoardTabUpdateOpSchema, + BoardTabDeleteOpSchema, + BoardTabsReorderOpSchema, + BoardWidgetMoveOpSchema, + BoardWidgetResizeOpSchema, + BoardWidgetRemoveOpSchema, +]); +export type BoardOp = Static; + +export const BoardGetParamsSchema = closedObject({ sessionKey: NonEmptyString }); +export type BoardGetParams = Static; + +export const BoardUpdateParamsSchema = closedObject({ + sessionKey: NonEmptyString, + ops: Type.Array(BoardOpSchema), +}); +export type BoardUpdateParams = Static; + +export const BoardMcpAppDescriptorSchema = closedObject({ + serverName: NonEmptyString, + toolName: NonEmptyString, + uiResourceUri: NonEmptyString, + originSessionKey: NonEmptyString, + toolCallId: NonEmptyString, +}); +export type BoardMcpAppDescriptor = Static; + +export const BoardWidgetHtmlContentSchema = closedObject({ + kind: Type.Literal("html"), + html: Type.String({ maxLength: 262_144 }), +}); +export const BoardWidgetMcpAppContentSchema = closedObject({ + kind: Type.Literal("mcp-app"), + descriptor: BoardMcpAppDescriptorSchema, +}); +export const BoardWidgetContentSchema = Type.Union([ + BoardWidgetHtmlContentSchema, + BoardWidgetMcpAppContentSchema, +]); +export type BoardWidgetContent = Static; + +export const BoardWidgetPutParamsSchema = closedObject({ + sessionKey: NonEmptyString, + name: BoardWidgetNameSchema, + title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), + content: BoardWidgetContentSchema, + placement: Type.Optional( + closedObject({ + tabId: Type.Optional(BoardTabIdSchema), + size: Type.Optional(BoardSizeSchema), + after: Type.Optional(BoardWidgetNameSchema), + }), + ), + declared: Type.Optional( + closedObject({ + netOrigins: Type.Optional(Type.Array(Type.String())), + tools: Type.Optional(Type.Array(Type.String())), + }), + ), +}); +export type BoardWidgetPutParams = Static; + +export const BoardWidgetGrantParamsSchema = closedObject({ + sessionKey: NonEmptyString, + name: BoardWidgetNameSchema, + decision: Type.Union([Type.Literal("granted"), Type.Literal("rejected")]), + revision: Type.Integer({ minimum: 1 }), +}); +export type BoardWidgetGrantParams = Static; + +export const BoardEventParamsSchema = closedObject({ + sessionKey: NonEmptyString, + widget: BoardWidgetNameSchema, + payload: Type.Unknown(), +}); +export type BoardEventParams = Static; + +export const BoardChangedEventSchema = closedObject({ + sessionKey: NonEmptyString, + revision: Type.Integer({ minimum: 0 }), + widget: Type.Optional(BoardWidgetNameSchema), +}); +export type BoardChangedEvent = Static; + +export const BoardFocusTabCommandSchema = closedObject({ + kind: Type.Literal("focus_tab"), + tabId: BoardTabIdSchema, +}); +export const BoardSetChatDockCommandSchema = closedObject({ + kind: Type.Literal("set_chat_dock"), + dock: BoardChatDockSchema, +}); +export const BoardCommandSchema = Type.Union([ + BoardFocusTabCommandSchema, + BoardSetChatDockCommandSchema, +]); +export type BoardCommand = Static; + +export const BoardCommandEventSchema = closedObject({ + sessionKey: NonEmptyString, + command: BoardCommandSchema, +}); +export type BoardCommandEvent = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 29d9dccbc4ed..54d62d1cf889 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -151,6 +151,33 @@ import { AuditActivityToolActionV1Schema, } from "./audit-activity.js"; import { AuditEventSchema, AuditListParamsSchema, AuditListResultSchema } from "./audit.js"; +import { + BoardChangedEventSchema, + BoardCommandEventSchema, + BoardCommandSchema, + BoardEventParamsSchema, + BoardFocusTabCommandSchema, + BoardGetParamsSchema, + BoardMcpAppDescriptorSchema, + BoardOpSchema, + BoardSetChatDockCommandSchema, + BoardSnapshotSchema, + BoardTabCreateOpSchema, + BoardTabDeleteOpSchema, + BoardTabSchema, + BoardTabsReorderOpSchema, + BoardTabUpdateOpSchema, + BoardUpdateParamsSchema, + BoardWidgetContentSchema, + BoardWidgetGrantParamsSchema, + BoardWidgetHtmlContentSchema, + BoardWidgetMcpAppContentSchema, + BoardWidgetMoveOpSchema, + BoardWidgetPutParamsSchema, + BoardWidgetRemoveOpSchema, + BoardWidgetResizeOpSchema, + BoardWidgetSchema, +} from "./board.js"; import { ChannelsStartParamsSchema, ChannelsStopParamsSchema, @@ -540,6 +567,31 @@ import { /** Public schema registry keyed by stable protocol schema name. */ export const ProtocolSchemas = { + BoardTab: BoardTabSchema, + BoardWidget: BoardWidgetSchema, + BoardSnapshot: BoardSnapshotSchema, + BoardTabCreateOp: BoardTabCreateOpSchema, + BoardTabUpdateOp: BoardTabUpdateOpSchema, + BoardTabDeleteOp: BoardTabDeleteOpSchema, + BoardTabsReorderOp: BoardTabsReorderOpSchema, + BoardWidgetMoveOp: BoardWidgetMoveOpSchema, + BoardWidgetResizeOp: BoardWidgetResizeOpSchema, + BoardWidgetRemoveOp: BoardWidgetRemoveOpSchema, + BoardOp: BoardOpSchema, + BoardMcpAppDescriptor: BoardMcpAppDescriptorSchema, + BoardWidgetHtmlContent: BoardWidgetHtmlContentSchema, + BoardWidgetMcpAppContent: BoardWidgetMcpAppContentSchema, + BoardWidgetContent: BoardWidgetContentSchema, + BoardGetParams: BoardGetParamsSchema, + BoardUpdateParams: BoardUpdateParamsSchema, + BoardWidgetPutParams: BoardWidgetPutParamsSchema, + BoardWidgetGrantParams: BoardWidgetGrantParamsSchema, + BoardEventParams: BoardEventParamsSchema, + BoardChangedEvent: BoardChangedEventSchema, + BoardFocusTabCommand: BoardFocusTabCommandSchema, + BoardSetChatDockCommand: BoardSetChatDockCommandSchema, + BoardCommand: BoardCommandSchema, + BoardCommandEvent: BoardCommandEventSchema, AuthProbeStatus: AuthProbeStatusSchema, // Handshake, transport frames, state snapshots, and shared error envelopes. ConnectParams: ConnectParamsSchema, diff --git a/src/agents/core-tool-factory-descriptors.ts b/src/agents/core-tool-factory-descriptors.ts index 787c272ff875..9fbaf96a00fe 100644 --- a/src/agents/core-tool-factory-descriptors.ts +++ b/src/agents/core-tool-factory-descriptors.ts @@ -24,6 +24,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [ { name: "conversations_send", family: "openclaw" }, { name: "conversations_turn", family: "openclaw" }, { name: "cron", family: "openclaw" }, + { name: "dashboard", family: "openclaw" }, { name: "gateway", family: "openclaw" }, { name: "get_goal", family: "openclaw" }, { name: "heartbeat_respond", family: "openclaw" }, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 14a72c3c24f3..ed846e79bee9 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -55,6 +55,7 @@ import { createConversationsTurnTool, } from "./tools/conversation-tools.js"; import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js"; +import { createDashboardTool } from "./tools/dashboard-tool.js"; import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js"; import { createGatewayToolCallerWrapper } from "./tools/gateway-caller-context.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; @@ -472,6 +473,9 @@ export function createOpenClawTools( }); const includeTranscriptsTool = resolveTranscriptsConfig(resolvedConfig?.transcripts).enabled; const tools: AnyAgentTool[] = [ + createDashboardTool({ + agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, + }), ...(embedded ? [] : [ diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 59594ff15087..a2652c3389de 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -52,6 +52,7 @@ describe("tool-catalog", () => { "spawn_task", "dismiss_task", "screen", + "dashboard", "terminal", "cron", "get_goal", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 660430bc7c6a..b61ba842333f 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -280,6 +280,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: ["coding"], includeInOpenClawGroup: true, }, + { + id: "dashboard", + label: "dashboard", + description: "Read and arrange the session dashboard", + sectionId: "ui", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, { id: "terminal", label: "terminal", diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index a126009bec7e..75e47b6fa524 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -196,6 +196,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { }, }, }, + dashboard: { + emoji: "📋", + title: "Dashboard", + detailKeys: ["action", "tabId", "name", "title"], + }, nodes: { emoji: "📱", title: "Nodes", diff --git a/src/agents/tools/dashboard-tool.test.ts b/src/agents/tools/dashboard-tool.test.ts new file mode 100644 index 000000000000..46da83d5d541 --- /dev/null +++ b/src/agents/tools/dashboard-tool.test.ts @@ -0,0 +1,140 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import type { BoardCommand, BoardSnapshot } from "../../../packages/gateway-protocol/src/index.js"; +import { createDashboardTool } from "./dashboard-tool.js"; +import type { InProcessGatewayCaller } from "./in-process-gateway.js"; + +const snapshot: BoardSnapshot = { + sessionKey: "agent:main:main", + revision: 3, + tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }], + widgets: [], +}; + +function recorder() { + const calls: Array<[string, Record]> = []; + const commands: Array<{ sessionKey: string; command: BoardCommand }> = []; + const callGateway: InProcessGatewayCaller = async ( + method: string, + params: Record, + ): Promise => { + calls.push([method, params]); + return snapshot as T; + }; + return { + calls, + commands, + callGateway, + emitCommand: (command: { sessionKey: string; command: BoardCommand }) => { + commands.push(command); + return 2; + }, + }; +} + +describe("dashboard tool", () => { + it("declares every action, no client capability guard, and stable-name/size guidance", () => { + const tool = createDashboardTool(); + expect(tool.requiredClientCaps).toBeUndefined(); + expect(tool.description).toContain("stable names"); + expect(tool.description).toContain("sm=3x3"); + expect(tool.parameters).toMatchObject({ + additionalProperties: false, + properties: { + action: { + enum: [ + "read", + "tab_create", + "tab_update", + "tab_delete", + "tabs_reorder", + "widget_move", + "widget_resize", + "widget_remove", + "focus_tab", + "set_chat_dock", + ], + }, + }, + }); + expect(Value.Check(tool.parameters, { action: "widget_move", name: "status" })).toBe(true); + expect(Value.Check(tool.parameters, { action: "unknown" })).toBe(false); + }); + + it("reads a compact text plus JSON snapshot", async () => { + const harness = recorder(); + const tool = createDashboardTool({ + agentSessionKey: "agent:main:main", + callGateway: harness.callGateway, + }); + const result = await tool.execute("read", { action: "read" }); + expect(harness.calls).toEqual([["board.get", { sessionKey: "agent:main:main" }]]); + expect(result.details).toEqual(snapshot); + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining('"revision":3'), + }); + }); + + it("rejects protocol-invalid focus tab ids before broadcasting", async () => { + const harness = recorder(); + const tool = createDashboardTool({ + agentSessionKey: "agent:main:main", + emitCommand: harness.emitCommand, + }); + await expect( + tool.execute("focus", { action: "focus_tab", tabId: "Invalid Tab" }), + ).rejects.toThrow("lowercase slug"); + expect(harness.commands).toEqual([]); + }); + + it.each([ + [ + "tab_create", + { tabId: "notes", title: "Notes", chatDock: "bottom" }, + { kind: "tab_create", tabId: "notes", title: "Notes", chatDock: "bottom" }, + ], + [ + "tab_update", + { tabId: "notes", title: "New", position: 0 }, + { kind: "tab_update", tabId: "notes", title: "New", position: 0 }, + ], + ["tab_delete", { tabId: "notes" }, { kind: "tab_delete", tabId: "notes" }], + ["tabs_reorder", { tabIds: ["two", "one"] }, { kind: "tabs_reorder", tabIds: ["two", "one"] }], + [ + "widget_move", + { name: "status", tabId: "notes", after: "clock" }, + { kind: "widget_move", name: "status", tabId: "notes", after: "clock" }, + ], + [ + "widget_resize", + { name: "status", sizeW: 8, sizeH: 6 }, + { kind: "widget_resize", name: "status", sizeW: 8, sizeH: 6 }, + ], + ["widget_remove", { name: "status" }, { kind: "widget_remove", name: "status" }], + ])("maps %s to one board.update op", async (action, args, op) => { + const harness = recorder(); + const tool = createDashboardTool({ + agentSessionKey: "agent:main:main", + callGateway: harness.callGateway, + }); + await tool.execute("mutate", { action, ...args }); + expect(harness.calls).toEqual([["board.update", { sessionKey: "agent:main:main", ops: [op] }]]); + }); + + it.each([ + ["focus_tab", { tabId: "notes" }, { kind: "focus_tab", tabId: "notes" }], + ["set_chat_dock", { dock: "left" }, { kind: "set_chat_dock", dock: "left" }], + ])("emits board.command for %s", async (action, args, command) => { + const harness = recorder(); + const tool = createDashboardTool({ + agentSessionKey: "agent:main:main", + callGateway: harness.callGateway, + emitCommand: harness.emitCommand, + }); + const result = await tool.execute("command", { action, ...args }); + expect(harness.calls).toEqual([]); + expect(harness.commands).toEqual([{ sessionKey: "agent:main:main", command }]); + expect(result.details).toEqual({ ok: true, delivered: 2 }); + }); +}); diff --git a/src/agents/tools/dashboard-tool.ts b/src/agents/tools/dashboard-tool.ts new file mode 100644 index 000000000000..daf04e1d87a2 --- /dev/null +++ b/src/agents/tools/dashboard-tool.ts @@ -0,0 +1,257 @@ +import { Type } from "typebox"; +import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js"; +import type { + BoardCommand, + BoardOp, + BoardSnapshot, +} from "../../../packages/gateway-protocol/src/index.js"; +import type { AnyAgentTool } from "./common.js"; +import { + readNumberParam, + readStringArrayParam, + readStringParam, + textResult, + ToolInputError, +} from "./common.js"; +import { + callInProcessGatewayTool, + getInProcessGatewayToolContext, + type InProcessGatewayCaller, +} from "./in-process-gateway.js"; + +const DASHBOARD_ACTIONS = [ + "read", + "tab_create", + "tab_update", + "tab_delete", + "tabs_reorder", + "widget_move", + "widget_resize", + "widget_remove", + "focus_tab", + "set_chat_dock", +] as const; +const BOARD_TAB_ID_PATTERN = "^[a-z0-9-]{1,40}$"; +const BOARD_TAB_ID_REGEX = /^[a-z0-9-]{1,40}$/; +const BOARD_WIDGET_NAME_PATTERN = "^[a-z0-9][a-z0-9._-]{0,63}$"; + +const DashboardToolSchema = Type.Object( + { + action: Type.String({ enum: [...DASHBOARD_ACTIONS], description: "Dashboard action" }), + tabId: Type.Optional( + Type.String({ pattern: BOARD_TAB_ID_PATTERN, description: "Stable tab slug" }), + ), + title: Type.Optional(Type.String({ minLength: 1, maxLength: 80, description: "Tab title" })), + chatDock: Type.Optional( + Type.String({ enum: ["left", "right", "bottom", "hidden"], description: "Chat dock" }), + ), + dock: Type.Optional( + Type.String({ enum: ["left", "right", "bottom", "hidden"], description: "Chat dock" }), + ), + position: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based position" })), + tabIds: Type.Optional( + Type.Array(Type.String({ pattern: BOARD_TAB_ID_PATTERN }), { + description: "Complete tab order", + }), + ), + name: Type.Optional( + Type.String({ pattern: BOARD_WIDGET_NAME_PATTERN, description: "Stable widget name" }), + ), + after: Type.Optional( + Type.String({ + pattern: BOARD_WIDGET_NAME_PATTERN, + description: "Place after stable widget name", + }), + ), + sizeW: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })), + sizeH: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })), + }, + { additionalProperties: false }, +); + +type DashboardCommandEmitter = (params: { sessionKey: string; command: BoardCommand }) => number; + +type DashboardGatewayContext = { + getClientConnIds?: ( + predicate: (client: { connect: { client: { id: string } } }) => boolean, + ) => Set; + broadcastToConnIds: (event: "board.command", payload: unknown, connIds: Set) => void; +}; + +type DashboardToolOptions = { + agentSessionKey?: string; + callGateway?: InProcessGatewayCaller; + emitCommand?: DashboardCommandEmitter; +}; + +function requireSessionKey(value: string | undefined): string { + const sessionKey = value?.trim(); + if (!sessionKey) { + throw new ToolInputError("agent session required"); + } + return sessionKey; +} + +function readDock( + params: Record, + key: "chatDock" | "dock", +): "left" | "right" | "bottom" | "hidden" | undefined { + const value = readStringParam(params, key); + if ( + value === undefined || + value === "left" || + value === "right" || + value === "bottom" || + value === "hidden" + ) { + return value; + } + throw new ToolInputError(`${key} must be left, right, bottom, or hidden`); +} + +function requireInteger(params: Record, key: string): number { + const value = readNumberParam(params, key, { required: true, integer: true, strict: true }); + if (value === undefined) { + throw new ToolInputError(`${key} required`); + } + return value; +} + +function readTabId(params: Record): string { + const tabId = readStringParam(params, "tabId", { required: true }); + if (!BOARD_TAB_ID_REGEX.test(tabId)) { + throw new ToolInputError("tabId must be a lowercase slug up to 40 characters"); + } + return tabId; +} + +function opForAction(action: string, params: Record): BoardOp { + const name = () => readStringParam(params, "name", { required: true }); + switch (action) { + case "tab_create": + return { + kind: "tab_create", + tabId: readTabId(params), + title: readStringParam(params, "title", { required: true }), + ...(readDock(params, "chatDock") ? { chatDock: readDock(params, "chatDock") } : {}), + }; + case "tab_update": { + const title = readStringParam(params, "title"); + const chatDock = readDock(params, "chatDock"); + const position = readNumberParam(params, "position", { integer: true, strict: true }); + if (title === undefined && chatDock === undefined && position === undefined) { + throw new ToolInputError("tab_update requires title, chatDock, or position"); + } + return { + kind: "tab_update", + tabId: readTabId(params), + ...(title !== undefined ? { title } : {}), + ...(chatDock !== undefined ? { chatDock } : {}), + ...(position !== undefined ? { position } : {}), + }; + } + case "tab_delete": + return { kind: "tab_delete", tabId: readTabId(params) }; + case "tabs_reorder": + return { + kind: "tabs_reorder", + tabIds: readStringArrayParam(params, "tabIds", { required: true }), + }; + case "widget_move": { + const targetTabId = readStringParam(params, "tabId"); + const position = readNumberParam(params, "position", { integer: true, strict: true }); + const after = readStringParam(params, "after"); + if (position !== undefined && after !== undefined) { + throw new ToolInputError("widget_move accepts either position or after, not both"); + } + return { + kind: "widget_move", + name: name(), + ...(targetTabId !== undefined ? { tabId: targetTabId } : {}), + ...(position !== undefined ? { position } : {}), + ...(after !== undefined ? { after } : {}), + }; + } + case "widget_resize": + return { + kind: "widget_resize", + name: name(), + sizeW: requireInteger(params, "sizeW"), + sizeH: requireInteger(params, "sizeH"), + }; + case "widget_remove": + return { kind: "widget_remove", name: name() }; + default: + throw new ToolInputError(`Unknown dashboard action: ${action}`); + } +} + +function emitBoardCommand(params: { sessionKey: string; command: BoardCommand }): number { + const context = getInProcessGatewayToolContext() as DashboardGatewayContext | undefined; + if (!context) { + throw new ToolInputError("dashboard command unavailable outside gateway runtime"); + } + const connIds = + context.getClientConnIds?.( + (client) => client.connect.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI, + ) ?? new Set(); + context.broadcastToConnIds("board.command", params, connIds); + return connIds.size; +} + +function snapshotResult(snapshot: BoardSnapshot) { + return textResult( + `Dashboard revision ${snapshot.revision}: ${snapshot.tabs.length} tabs, ${snapshot.widgets.length} widgets\n${JSON.stringify(snapshot)}`, + snapshot, + ); +} + +export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTool { + const gatewayCall = opts.callGateway ?? callInProcessGatewayTool; + const emitCommand = opts.emitCommand ?? emitBoardCommand; + return { + label: "Dashboard", + name: "dashboard", + description: + "Read and arrange this session dashboard. Widgets use stable names. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.", + parameters: DashboardToolSchema, + execute: async (_toolCallId, rawArgs) => { + const params = rawArgs as Record; + const action = readStringParam(params, "action", { required: true }); + const sessionKey = requireSessionKey(opts.agentSessionKey); + if (action === "read") { + return snapshotResult(await gatewayCall("board.get", { sessionKey })); + } + if (action === "focus_tab") { + const delivered = emitCommand({ + sessionKey, + command: { + kind: "focus_tab", + tabId: readTabId(params), + }, + }); + return textResult(`Dashboard command sent to ${delivered} client(s)`, { + ok: true, + delivered, + }); + } + if (action === "set_chat_dock") { + const dock = readDock(params, "dock"); + if (!dock) { + throw new ToolInputError("dock required"); + } + const delivered = emitCommand({ sessionKey, command: { kind: "set_chat_dock", dock } }); + return textResult(`Dashboard command sent to ${delivered} client(s)`, { + ok: true, + delivered, + }); + } + return snapshotResult( + await gatewayCall("board.update", { + sessionKey, + ops: [opForAction(action, params)], + }), + ); + }, + }; +} diff --git a/src/boards/board-layout.test.ts b/src/boards/board-layout.test.ts new file mode 100644 index 000000000000..127976fa3fc1 --- /dev/null +++ b/src/boards/board-layout.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import type { BoardLayout } from "./board-layout.js"; +import { applyBoardOps, BoardValidationError, normalizeBoardLayout } from "./board-layout.js"; + +function layout(): BoardLayout { + return { + tabs: [ + { tabId: "one", title: "One", position: 4, chatDock: "right" }, + { tabId: "two", title: "Two", position: 8, chatDock: "bottom" }, + ], + widgets: [ + { + name: "alpha", + tabId: "one", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 3, + grantState: "none", + revision: 1, + }, + { + name: "beta", + tabId: "one", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 9, + grantState: "none", + revision: 1, + }, + { + name: "gamma", + tabId: "two", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 2, + grantState: "none", + revision: 1, + }, + ], + }; +} + +function widgetOrder(state: BoardLayout, tabId: string): string[] { + return state.widgets + .filter((widget) => widget.tabId === tabId) + .toSorted((a, b) => a.position - b.position) + .map((widget) => widget.name); +} + +describe("board layout", () => { + it("normalizes tab and per-tab widget positions without holes", () => { + const normalized = normalizeBoardLayout(layout()); + expect(normalized.tabs.map((tab) => tab.position)).toEqual([0, 1]); + expect( + normalized.widgets + .filter((widget) => widget.tabId === "one") + .map((widget) => widget.position), + ).toEqual([0, 1]); + expect(normalized.widgets.find((widget) => widget.name === "gamma")?.position).toBe(0); + }); + + it("reorders widgets by position and after anchors, including cross-tab moves", () => { + const reordered = applyBoardOps(layout(), [ + { kind: "widget_move", name: "beta", position: 0 }, + { kind: "widget_move", name: "gamma", tabId: "one", after: "beta" }, + ]); + expect(widgetOrder(reordered, "one")).toEqual(["beta", "gamma", "alpha"]); + expect(widgetOrder(reordered, "two")).toEqual([]); + }); + + it("rejects missing and self after anchors", () => { + expect(() => + applyBoardOps(layout(), [{ kind: "widget_move", name: "alpha", after: "missing" }]), + ).toThrow(BoardValidationError); + expect(() => + applyBoardOps(layout(), [{ kind: "widget_move", name: "alpha", after: "alpha" }]), + ).toThrow("after itself"); + }); + + it("clamps widget sizes to the grid bounds", () => { + const resized = applyBoardOps(layout(), [ + { kind: "widget_resize", name: "alpha", sizeW: 99, sizeH: -5 }, + ]); + const widget = resized.widgets.find((candidate) => candidate.name === "alpha"); + expect(widget).toMatchObject({ sizeW: 12, sizeH: 1 }); + }); + + it("moves deleted-tab widgets to the first remaining tab", () => { + const next = applyBoardOps(layout(), [{ kind: "tab_delete", tabId: "one" }]); + expect(next.tabs.map((tab) => tab.tabId)).toEqual(["two"]); + expect(widgetOrder(next, "two")).toEqual(["gamma", "alpha", "beta"]); + }); + + it("allows deleting the last empty tab but rejects deleting it with widgets", () => { + expect( + applyBoardOps( + { tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }], widgets: [] }, + [{ kind: "tab_delete", tabId: "main" }], + ).tabs, + ).toEqual([]); + expect(() => + applyBoardOps({ ...layout(), tabs: [layout().tabs[0]!], widgets: [layout().widgets[0]!] }, [ + { kind: "tab_delete", tabId: "one" }, + ]), + ).toThrow("last board tab"); + }); + + it("requires tabs_reorder to be an exact permutation", () => { + expect( + applyBoardOps(layout(), [{ kind: "tabs_reorder", tabIds: ["two", "one"] }]).tabs.map( + (tab) => tab.tabId, + ), + ).toEqual(["two", "one"]); + expect(() => + applyBoardOps(layout(), [{ kind: "tabs_reorder", tabIds: ["one", "one"] }]), + ).toThrow("every tab exactly once"); + }); +}); diff --git a/src/boards/board-layout.ts b/src/boards/board-layout.ts new file mode 100644 index 000000000000..22ab5fde89e9 --- /dev/null +++ b/src/boards/board-layout.ts @@ -0,0 +1,285 @@ +import type { + BoardOp, + BoardSnapshot, + BoardTab, + BoardWidget, +} from "../../packages/gateway-protocol/src/index.js"; + +export const BOARD_SIZE_PRESETS = { + sm: { sizeW: 3, sizeH: 3 }, + md: { sizeW: 6, sizeH: 4 }, + lg: { sizeW: 8, sizeH: 6 }, + xl: { sizeW: 12, sizeH: 8 }, + full: { sizeW: 12, sizeH: 8 }, +} as const; + +export type BoardSize = keyof typeof BOARD_SIZE_PRESETS; +export type BoardLayout = Pick; +type BoardValidationErrorCode = "conflict" | "invalid_operation" | "not_found"; + +export class BoardValidationError extends Error { + readonly code: BoardValidationErrorCode; + + constructor(code: BoardValidationErrorCode, message: string) { + super(message); + this.name = "BoardValidationError"; + this.code = code; + } +} + +function cloneTab(tab: BoardTab): BoardTab { + return { + tabId: tab.tabId, + title: tab.title, + position: tab.position, + chatDock: tab.chatDock, + }; +} + +function cloneWidget(widget: BoardWidget): BoardWidget { + return { + name: widget.name, + tabId: widget.tabId, + ...(widget.title !== undefined ? { title: widget.title } : {}), + contentKind: widget.contentKind, + sizeW: widget.sizeW, + sizeH: widget.sizeH, + position: widget.position, + grantState: widget.grantState, + revision: widget.revision, + ...(widget.declaredSummary !== undefined + ? { declaredSummary: [...widget.declaredSummary] } + : {}), + }; +} + +function cloneLayout(layout: BoardLayout): BoardLayout { + return { + tabs: layout.tabs.map(cloneTab), + widgets: layout.widgets.map(cloneWidget), + }; +} + +function clampInteger(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +function comparePosition(a: { position: number }, b: { position: number }): number { + return a.position - b.position; +} + +export function normalizeBoardLayout(layout: BoardLayout): BoardLayout { + const tabs = layout.tabs.toSorted(comparePosition).map((tab, position) => { + const next = cloneTab(tab); + next.position = position; + return next; + }); + const tabPosition = new Map(tabs.map((tab) => [tab.tabId, tab.position])); + const widgets = layout.widgets + .toSorted((a, b) => { + const tabDelta = + (tabPosition.get(a.tabId) ?? Number.MAX_SAFE_INTEGER) - + (tabPosition.get(b.tabId) ?? Number.MAX_SAFE_INTEGER); + return tabDelta || a.position - b.position; + }) + .map(cloneWidget); + const nextPosition = new Map(); + for (const widget of widgets) { + const position = nextPosition.get(widget.tabId) ?? 0; + widget.position = position; + nextPosition.set(widget.tabId, position + 1); + } + return { tabs, widgets }; +} + +function requireTab(layout: BoardLayout, tabId: string): BoardTab { + const tab = layout.tabs.find((candidate) => candidate.tabId === tabId); + if (!tab) { + throw new BoardValidationError("not_found", `board tab not found: ${tabId}`); + } + return tab; +} + +function requireWidget(layout: BoardLayout, name: string): BoardWidget { + const widget = layout.widgets.find((candidate) => candidate.name === name); + if (!widget) { + throw new BoardValidationError("not_found", `board widget not found: ${name}`); + } + return widget; +} + +function moveTab(layout: BoardLayout, tab: BoardTab, position: number): void { + const ordered = layout.tabs.toSorted(comparePosition).filter((candidate) => candidate !== tab); + ordered.splice(clampInteger(position, 0, ordered.length), 0, tab); + ordered.forEach((candidate, index) => { + candidate.position = index; + }); + layout.tabs = ordered; +} + +function moveWidget( + layout: BoardLayout, + widget: BoardWidget, + targetTabId: string, + position?: number, + after?: string, +): void { + requireTab(layout, targetTabId); + if (position !== undefined && after !== undefined) { + throw new BoardValidationError( + "invalid_operation", + "widget_move accepts either position or after, not both", + ); + } + const targetWidgets = layout.widgets + .filter((candidate) => candidate.tabId === targetTabId && candidate !== widget) + .toSorted(comparePosition); + let targetPosition = targetWidgets.length; + if (after !== undefined) { + if (after === widget.name) { + throw new BoardValidationError("invalid_operation", "widget cannot be placed after itself"); + } + const anchorIndex = targetWidgets.findIndex((candidate) => candidate.name === after); + if (anchorIndex < 0) { + throw new BoardValidationError( + "not_found", + `board widget anchor not found on tab ${targetTabId}: ${after}`, + ); + } + targetPosition = anchorIndex + 1; + } else if (position !== undefined) { + targetPosition = clampInteger(position, 0, targetWidgets.length); + } + widget.tabId = targetTabId; + targetWidgets.splice(targetPosition, 0, widget); + targetWidgets.forEach((candidate, index) => { + candidate.position = index; + }); + const otherWidgets = layout.widgets.filter( + (candidate) => candidate !== widget && candidate.tabId !== targetTabId, + ); + layout.widgets = [...otherWidgets, ...targetWidgets]; +} + +function applyBoardOp(layout: BoardLayout, op: BoardOp): void { + switch (op.kind) { + case "tab_create": { + if (layout.tabs.some((tab) => tab.tabId === op.tabId)) { + throw new BoardValidationError("conflict", `board tab already exists: ${op.tabId}`); + } + layout.tabs.push({ + tabId: op.tabId, + title: op.title, + position: layout.tabs.length, + chatDock: op.chatDock ?? "right", + }); + return; + } + case "tab_update": { + const tab = requireTab(layout, op.tabId); + if (op.title === undefined && op.chatDock === undefined && op.position === undefined) { + throw new BoardValidationError("invalid_operation", "tab_update has no changes"); + } + if (op.title !== undefined) { + tab.title = op.title; + } + if (op.chatDock !== undefined) { + tab.chatDock = op.chatDock; + } + if (op.position !== undefined) { + moveTab(layout, tab, op.position); + } + return; + } + case "tab_delete": { + const tab = requireTab(layout, op.tabId); + const remainingTabs = layout.tabs + .filter((candidate) => candidate !== tab) + .toSorted(comparePosition); + const tabWidgets = layout.widgets + .filter((widget) => widget.tabId === tab.tabId) + .toSorted(comparePosition); + if (remainingTabs.length === 0 && tabWidgets.length > 0) { + throw new BoardValidationError( + "invalid_operation", + "cannot delete the last board tab while it contains widgets", + ); + } + layout.tabs = remainingTabs; + if (tabWidgets.length > 0) { + const fallback = remainingTabs[0]!; + for (const widget of tabWidgets) { + widget.tabId = fallback.tabId; + widget.position = Number.MAX_SAFE_INTEGER; + } + } + return; + } + case "tabs_reorder": { + if ( + op.tabIds.length !== layout.tabs.length || + new Set(op.tabIds).size !== op.tabIds.length || + op.tabIds.some((tabId) => !layout.tabs.some((tab) => tab.tabId === tabId)) + ) { + throw new BoardValidationError( + "invalid_operation", + "tabs_reorder must contain every tab exactly once", + ); + } + const byId = new Map(layout.tabs.map((tab) => [tab.tabId, tab])); + layout.tabs = op.tabIds.map((tabId, position) => { + const tab = byId.get(tabId)!; + tab.position = position; + return tab; + }); + return; + } + case "widget_move": { + const widget = requireWidget(layout, op.name); + moveWidget(layout, widget, op.tabId ?? widget.tabId, op.position, op.after); + return; + } + case "widget_resize": { + const widget = requireWidget(layout, op.name); + widget.sizeW = clampInteger(op.sizeW, 1, 12); + widget.sizeH = clampInteger(op.sizeH, 1, 20); + return; + } + case "widget_remove": { + requireWidget(layout, op.name); + layout.widgets = layout.widgets.filter((widget) => widget.name !== op.name); + } + } +} + +export function applyBoardOps(layout: BoardLayout, ops: readonly BoardOp[]): BoardLayout { + const next = cloneLayout(layout); + for (const op of ops) { + applyBoardOp(next, op); + const normalized = normalizeBoardLayout(next); + next.tabs = normalized.tabs; + next.widgets = normalized.widgets; + } + return normalizeBoardLayout(next); +} + +export function insertBoardWidget( + layout: BoardLayout, + widget: BoardWidget, + placement: { tabId: string; after?: string; move?: boolean }, +): BoardLayout { + const next = cloneLayout(layout); + const existing = next.widgets.find((candidate) => candidate.name === widget.name); + if (existing) { + const position = existing.position; + Object.assign(existing, widget, { tabId: placement.tabId, position }); + if (placement.move) { + moveWidget(next, existing, placement.tabId, undefined, placement.after); + } + } else { + next.widgets.push({ ...widget, tabId: placement.tabId }); + const inserted = requireWidget(next, widget.name); + moveWidget(next, inserted, placement.tabId, undefined, placement.after); + } + return normalizeBoardLayout(next); +} diff --git a/src/boards/board-notices.ts b/src/boards/board-notices.ts new file mode 100644 index 000000000000..ab21bae1fb7a --- /dev/null +++ b/src/boards/board-notices.ts @@ -0,0 +1,64 @@ +import { enqueueSystemEvent } from "../infra/system-events.js"; + +const BOARD_EVENT_MAX_BYTES = 8 * 1024; +const BOARD_NOTICE_MAX_CHARS = 500; +const BOARD_EVENT_DEDUPE_MS = 5_000; + +const recentNotices = new Map(); + +export class BoardEventPayloadError extends Error { + constructor(message: string) { + super(message); + this.name = "BoardEventPayloadError"; + } +} + +function serializePayload(payload: unknown): string { + try { + const serialized = JSON.stringify(payload); + return serialized === undefined ? String(payload) : serialized; + } catch { + throw new BoardEventPayloadError("board event payload must be JSON serializable"); + } +} + +function formatNotice(widget: string, summary: string): string { + const prefix = "[dashboard] "; + const suffix = ` on widget ${widget}`; + const available = BOARD_NOTICE_MAX_CHARS - prefix.length - suffix.length; + const clipped = + summary.length <= available ? summary : `${summary.slice(0, Math.max(0, available - 1))}…`; + return `${prefix}${clipped}${suffix}`; +} + +export function appendBoardEventNotice(params: { + sessionKey: string; + widget: string; + payload: unknown; + now?: number; +}): boolean { + const summary = serializePayload(params.payload); + if (Buffer.byteLength(summary, "utf8") > BOARD_EVENT_MAX_BYTES) { + throw new BoardEventPayloadError(`board event payload exceeds ${BOARD_EVENT_MAX_BYTES} bytes`); + } + const now = params.now ?? Date.now(); + const key = `${params.sessionKey}\0${params.widget}`; + const recent = recentNotices.get(key); + if (recent?.summary === summary && now - recent.at < BOARD_EVENT_DEDUPE_MS) { + return false; + } + recentNotices.set(key, { summary, at: now }); + for (const [candidate, notice] of recentNotices) { + if (now - notice.at >= BOARD_EVENT_DEDUPE_MS) { + recentNotices.delete(candidate); + } + } + return enqueueSystemEvent(formatNotice(params.widget, summary), { + sessionKey: params.sessionKey, + contextKey: `dashboard:${params.widget}:${now}`, + }); +} + +export function resetBoardEventNoticeStateForTest(): void { + recentNotices.clear(); +} diff --git a/src/boards/board-store.parity.test.ts b/src/boards/board-store.parity.test.ts new file mode 100644 index 000000000000..c8ae5b544b9b --- /dev/null +++ b/src/boards/board-store.parity.test.ts @@ -0,0 +1,508 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { replaceSessionEntrySync } from "../config/sessions/session-accessor.entry.js"; +import { deleteSessionEntryLifecycle } from "../config/sessions/session-accessor.js"; +import { requireNodeSqlite } from "../infra/node-sqlite.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { InMemoryBoardStore, type BoardStore } from "./board-store.js"; +import { SqliteBoardStore } from "./sqlite-board-store.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function seedSession(env: NodeJS.ProcessEnv, agentId: string, sessionKey: string): string { + const database = openOpenClawAgentDatabase({ agentId, env }); + const sessionId = `session-${agentId}-${sessionKey.replaceAll(":", "-")}`; + replaceSessionEntrySync( + { agentId, sessionKey, storePath: database.path }, + { sessionId, updatedAt: Date.now() }, + ); + return database.path; +} + +function createSqliteStore(): BoardStore { + const stateDir = tempDirs.make("openclaw-board-parity-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + seedSession(env, "main", "agent:main:board"); + return new SqliteBoardStore({ + resolveSession: (sessionKey) => ({ + agentId: sessionKey.split(":")[1] ?? "main", + sessionKey, + }), + env, + }); +} + +afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); +}); + +describe.each([ + ["memory", () => new InMemoryBoardStore()], + ["sqlite", createSqliteStore], +] as const)("BoardStore parity: %s", (_kind, createStore) => { + it("persists revisions, layout, bytes, and declared summaries", () => { + const store = createStore(); + const first = store.putWidget({ + sessionKey: "agent:main:board", + name: "weather", + content: { kind: "html", html: "

one

" }, + declared: { + netOrigins: ["https://weather.example"], + tools: ["weather.refresh"], + }, + }); + expect(first).toMatchObject({ + revision: 1, + tabs: [{ tabId: "main", position: 0 }], + widgets: [ + { + name: "weather", + revision: 1, + grantState: "pending", + declaredSummary: [ + "Network access: https://weather.example", + "Tool access: weather.refresh", + ], + }, + ], + }); + expect(store.readWidgetHtml("agent:main:board", "weather")).toMatchObject({ + html: "

one

", + revision: 1, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + + const resized = store.applyOps("agent:main:board", [ + { kind: "widget_resize", name: "weather", sizeW: 8, sizeH: 6 }, + ]); + expect(resized).toMatchObject({ + revision: 2, + widgets: [{ sizeW: 8, sizeH: 6, revision: 1 }], + }); + expect(store.grant("agent:main:board", "weather", "granted", 1)).toMatchObject({ + revision: 3, + widgets: [{ grantState: "granted" }], + }); + + const updated = store.putWidget({ + sessionKey: "agent:main:board", + name: "weather", + content: { kind: "html", html: "

two

" }, + }); + expect(updated).toMatchObject({ + revision: 4, + widgets: [{ revision: 2, grantState: "granted", sizeW: 8, sizeH: 6 }], + }); + expect(updated.widgets[0]).not.toHaveProperty("declaredSummary"); + }); + + it("keeps content-kind semantics and normalized ordering", () => { + const store = createStore(); + store.applyOps("agent:main:board", [ + { kind: "tab_create", tabId: "main", title: "Main" }, + { kind: "tab_create", tabId: "notes", title: "Notes" }, + ]); + store.putWidget({ + sessionKey: "agent:main:board", + name: "first", + content: { kind: "html", html: "first" }, + }); + store.putWidget({ + sessionKey: "agent:main:board", + name: "app", + content: { + kind: "mcp-app", + descriptor: { + serverName: "server", + toolName: "tool", + uiResourceUri: "ui://resource", + originSessionKey: "agent:main:origin", + toolCallId: "call", + }, + }, + placement: { tabId: "notes" }, + }); + expect(store.getSnapshot("agent:main:board").widgets).toEqual([ + expect.objectContaining({ name: "first", tabId: "main", position: 0 }), + expect.objectContaining({ name: "app", tabId: "notes", position: 0 }), + ]); + expect(store.readWidgetHtml("agent:main:board", "app")).toEqual({ + descriptor: { + serverName: "server", + toolName: "tool", + uiResourceUri: "ui://resource", + originSessionKey: "agent:main:origin", + toolCallId: "call", + }, + revision: 1, + }); + }); + + it("preserves grants only for equal or narrower declarations", () => { + const store = createStore(); + store.putWidget({ + sessionKey: "agent:main:board", + name: "scoped", + content: { kind: "html", html: "one" }, + declared: { + netOrigins: ["https://one.example", "https://two.example"], + tools: ["weather.read", "weather.refresh"], + }, + }); + store.grant("agent:main:board", "scoped", "granted", 1); + + const equal = store.putWidget({ + sessionKey: "agent:main:board", + name: "scoped", + content: { kind: "html", html: "two" }, + declared: { + netOrigins: ["https://one.example", "https://two.example"], + tools: ["weather.read", "weather.refresh"], + }, + }); + expect(equal.widgets[0]).toMatchObject({ revision: 2, grantState: "granted" }); + expect(store.readWidgetHtml("agent:main:board", "scoped")).toMatchObject({ + html: "two", + grantState: "granted", + }); + + const narrower = store.putWidget({ + sessionKey: "agent:main:board", + name: "scoped", + content: { kind: "html", html: "three" }, + declared: { + netOrigins: ["https://one.example"], + tools: ["weather.read"], + }, + }); + expect(narrower.widgets[0]).toMatchObject({ revision: 3, grantState: "granted" }); + + const wider = store.putWidget({ + sessionKey: "agent:main:board", + name: "scoped", + content: { kind: "html", html: "four" }, + declared: { + netOrigins: ["https://one.example", "https://three.example"], + tools: ["weather.read"], + }, + }); + expect(wider.widgets[0]).toMatchObject({ revision: 4, grantState: "pending" }); + }); + + it("rejects stale grant revisions before accepting the current one", () => { + const store = createStore(); + store.putWidget({ + sessionKey: "agent:main:board", + name: "scoped", + content: { kind: "html", html: "one" }, + declared: { tools: ["weather.read"] }, + }); + expect(() => store.grant("agent:main:board", "scoped", "granted", 2)).toThrow( + "revision changed", + ); + expect(store.grant("agent:main:board", "scoped", "granted", 1).widgets[0]).toMatchObject({ + revision: 1, + grantState: "granted", + }); + }); + + it("drops an empty board after its last tab is deleted", () => { + const store = createStore(); + store.applyOps("agent:main:board", [{ kind: "tab_create", tabId: "main", title: "Main" }]); + expect( + store.applyOps("agent:main:board", [{ kind: "tab_delete", tabId: "main" }]), + ).toMatchObject({ revision: 2, tabs: [], widgets: [] }); + expect(store.getSnapshot("agent:main:board").revision).toBe(0); + expect(store.listSessionsWithBoards()).toEqual([]); + }); +}); + +describe("SqliteBoardStore persistence", () => { + it("lazily creates board tables for an existing v13 database", () => { + const stateDir = tempDirs.make("openclaw-board-lazy-schema-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const sessionKey = "agent:main:board"; + seedSession(env, "main", sessionKey); + const opened = openOpenClawAgentDatabase({ agentId: "main", env }); + const databasePath = opened.path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const existingV13 = new DatabaseSync(databasePath); + existingV13.exec(` + DROP TABLE board_widgets; + DROP TABLE board_tabs; + PRAGMA user_version = 13; + UPDATE schema_meta SET schema_version = 13 WHERE meta_key = 'primary'; + `); + existingV13.close(); + + const reopened = openOpenClawAgentDatabase({ agentId: "main", env }); + expect( + reopened.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'board_tabs'") + .get(), + ).toBeUndefined(); + + const store = new SqliteBoardStore({ + resolveSession: () => ({ agentId: "main", sessionKey }), + env, + }); + expect(() => + store.putWidget({ + sessionKey, + name: "broken", + content: { kind: "html", html: "broken" }, + placement: { tabId: "missing" }, + }), + ).toThrow("board tab not found"); + store.putWidget({ + sessionKey, + name: "status", + content: { kind: "html", html: "ok" }, + }); + expect( + reopened.db + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name IN ('board_tabs', 'board_widgets') ORDER BY name", + ) + .all(), + ).toEqual([{ name: "board_tabs" }, { name: "board_widgets" }]); + expect( + reopened.db + .prepare("SELECT strict FROM pragma_table_list WHERE name = 'board_widgets'") + .get(), + ).toEqual({ strict: 1 }); + expect( + reopened.db + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'index' AND name = 'idx_agent_board_widgets_tab_position'", + ) + .get(), + ).toEqual({ name: "idx_agent_board_widgets_tab_position" }); + }); + + it("does not create an unregistered agent database during widget byte lookup", () => { + const stateDir = tempDirs.make("openclaw-board-no-create-"); + const store = new SqliteBoardStore({ + resolveSession: () => ({ + agentId: "attacker-selected", + sessionKey: "agent:attacker-selected:main", + }), + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + + expect(store.getSnapshot("agent:attacker-selected:main")).toEqual({ + sessionKey: "agent:attacker-selected:main", + revision: 0, + tabs: [], + widgets: [], + }); + expect(store.readWidgetHtml("agent:attacker-selected:main", "missing")).toBeUndefined(); + expect(() => + store.putWidget({ + sessionKey: "agent:attacker-selected:main", + name: "missing", + content: { kind: "html", html: "no" }, + }), + ).toThrow("board session not found"); + expect( + existsSync( + path.join(stateDir, "agents", "attacker-selected", "agent", "openclaw-agent.sqlite"), + ), + ).toBe(false); + expect(existsSync(path.join(stateDir, "agents", "attacker-selected"))).toBe(false); + }); + + it("canonicalizes aliases before reading and writing board rows", () => { + const stateDir = tempDirs.make("openclaw-board-alias-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const canonicalSessionKey = "agent:main:main"; + seedSession(env, "main", canonicalSessionKey); + const store = new SqliteBoardStore({ + resolveSession: (sessionKey) => ({ + agentId: "main", + sessionKey: sessionKey === "main" ? canonicalSessionKey : sessionKey, + }), + env, + }); + + store.putWidget({ + sessionKey: "main", + name: "status", + content: { kind: "html", html: "one" }, + }); + expect(store.getSnapshot(canonicalSessionKey)).toMatchObject({ + sessionKey: canonicalSessionKey, + widgets: [{ name: "status", revision: 1 }], + }); + store.putWidget({ + sessionKey: canonicalSessionKey, + name: "status", + content: { kind: "html", html: "two" }, + }); + expect(store.getSnapshot("main")).toMatchObject({ + sessionKey: canonicalSessionKey, + widgets: [{ name: "status", revision: 2 }], + }); + expect(store.listSessionsWithBoards()).toEqual([canonicalSessionKey]); + }); + + it("reads widget bytes only from the canonical per-agent database", () => { + const stateDir = tempDirs.make("openclaw-board-canonical-bytes-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const agentId = "worker-1"; + const sessionKey = "agent:worker-1:board"; + seedSession(env, agentId, sessionKey); + const store = new SqliteBoardStore({ + resolveSession: () => ({ agentId, sessionKey }), + env, + }); + store.putWidget({ + sessionKey, + name: "status", + content: { kind: "html", html: "canonical" }, + }); + + const relocated = openOpenClawAgentDatabase({ + agentId, + env, + path: path.join(stateDir, "000-relocated.sqlite"), + }); + replaceSessionEntrySync( + { agentId, sessionKey, storePath: relocated.path }, + { sessionId: "relocated-session", updatedAt: Date.now() }, + ); + relocated.db + .prepare( + "INSERT INTO board_tabs (session_key, tab_id, title, position, chat_dock, created_by, revision) VALUES (?, 'main', 'Main', 0, 'right', 'agent', 1)", + ) + .run(sessionKey); + relocated.db + .prepare( + "INSERT INTO board_widgets (session_key, name, tab_id, content_kind, html, sha256, view_generation, revision, size_w, size_h, position, manifest, grant_state, created_by, created_at, updated_at) VALUES (?, 'status', 'main', 'html', ?, ?, ?, 1, 6, 4, 0, '{}', 'none', 'agent', 1, 1)", + ) + .run(sessionKey, Buffer.from("relocated"), "a".repeat(64), "b".repeat(32)); + + expect(store.readWidgetHtml(sessionKey, "status")).toMatchObject({ html: "canonical" }); + }); + + it("purges board rows through the shared session deletion lifecycle", async () => { + const stateDir = tempDirs.make("openclaw-board-shared-delete-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const sessionKey = "agent:main:cleanup"; + const databasePath = seedSession(env, "main", sessionKey); + const store = new SqliteBoardStore({ + resolveSession: () => ({ agentId: "main", sessionKey }), + env, + }); + store.putWidget({ + sessionKey, + name: "status", + content: { kind: "html", html: "ok" }, + }); + + const result = await deleteSessionEntryLifecycle({ + agentId: "main", + archiveTranscript: false, + storePath: databasePath, + target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, + }); + + expect(result.deleted).toBe(true); + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + expect(database.db.prepare("SELECT count(*) AS count FROM board_widgets").get()).toEqual({ + count: 0, + }); + expect(database.db.prepare("SELECT count(*) AS count FROM board_tabs").get()).toEqual({ + count: 0, + }); + }); + + it("rebinds a preserved grant to the updated widget digest", () => { + const stateDir = tempDirs.make("openclaw-board-granted-digest-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const sessionKey = "agent:main:grant-digest"; + seedSession(env, "main", sessionKey); + const store = new SqliteBoardStore({ + resolveSession: () => ({ agentId: "main", sessionKey }), + env, + }); + store.putWidget({ + sessionKey, + name: "status", + content: { kind: "html", html: "one" }, + declared: { tools: ["status.read", "status.refresh"] }, + }); + store.grant(sessionKey, "status", "granted", 1); + store.putWidget({ + sessionKey, + name: "status", + content: { kind: "html", html: "two" }, + declared: { tools: ["status.read"] }, + }); + + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + expect( + database.db + .prepare( + "SELECT grant_state AS grantState, granted_sha AS grantedSha, sha256 FROM board_widgets WHERE session_key = ? AND name = 'status'", + ) + .get(sessionKey), + ).toEqual({ + grantState: "granted", + grantedSha: expect.stringMatching(/^[a-f0-9]{64}$/u), + sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + const row = database.db + .prepare( + "SELECT granted_sha AS grantedSha, sha256 FROM board_widgets WHERE session_key = ? AND name = 'status'", + ) + .get(sessionKey) as { grantedSha: string; sha256: string }; + expect(row.grantedSha).toBe(row.sha256); + }); + + it("reopens durable boards and isolates owning agent databases", () => { + const stateDir = tempDirs.make("openclaw-board-durable-"); + const options = { + resolveSession: (sessionKey: string) => ({ + agentId: sessionKey.split(":")[1] ?? "main", + sessionKey, + }), + env: { OPENCLAW_STATE_DIR: stateDir }, + }; + seedSession(options.env, "alpha", "agent:alpha:board"); + seedSession(options.env, "beta", "agent:beta:board"); + const store = new SqliteBoardStore(options); + store.putWidget({ + sessionKey: "agent:alpha:board", + name: "alpha", + content: { kind: "html", html: "alpha" }, + }); + store.putWidget({ + sessionKey: "agent:beta:board", + name: "beta", + content: { kind: "html", html: "beta" }, + }); + + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const reopened = new SqliteBoardStore(options); + expect(reopened.getSnapshot("agent:alpha:board").widgets).toEqual([ + expect.objectContaining({ name: "alpha", revision: 1 }), + ]); + expect(reopened.getSnapshot("agent:beta:board").widgets).toEqual([ + expect.objectContaining({ name: "beta", revision: 1 }), + ]); + expect(reopened.listSessionsWithBoards()).toEqual(["agent:alpha:board", "agent:beta:board"]); + }); +}); diff --git a/src/boards/board-store.test.ts b/src/boards/board-store.test.ts new file mode 100644 index 000000000000..f0b9135d5eb2 --- /dev/null +++ b/src/boards/board-store.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { BoardValidationError } from "./board-layout.js"; +import { InMemoryBoardStore } from "./board-store.js"; + +function putHtml(store: InMemoryBoardStore, sessionKey: string, name: string, html = "

one

") { + return store.putWidget({ sessionKey, name, content: { kind: "html", html } }); +} + +describe("in-memory board store", () => { + it("creates the implicit main tab and bumps board and widget revisions", () => { + const store = new InMemoryBoardStore(); + const first = putHtml(store, "agent:main:main", "status"); + const second = putHtml(store, "agent:main:main", "status", "

two

"); + expect(first).toMatchObject({ + revision: 1, + tabs: [{ tabId: "main", title: "Main", position: 0 }], + widgets: [{ name: "status", revision: 1 }], + }); + expect(second.revision).toBe(2); + expect(second.widgets[0]!.revision).toBe(2); + }); + + it("returns immutable snapshots and lists only existing boards", () => { + const store = new InMemoryBoardStore(); + putHtml(store, "session-b", "b"); + putHtml(store, "session-a", "a"); + const snapshot = store.getSnapshot("session-a"); + snapshot.tabs[0]!.title = "Changed"; + expect(store.getSnapshot("session-a").tabs[0]!.title).toBe("Main"); + expect(store.listSessionsWithBoards()).toEqual(["session-a", "session-b"]); + expect(store.getSnapshot("missing")).toEqual({ + sessionKey: "missing", + revision: 0, + tabs: [], + widgets: [], + }); + }); + + it("stores HTML bytes with digest and keeps MCP descriptors non-HTML", () => { + const store = new InMemoryBoardStore(); + putHtml(store, "session", "html", "
ok
"); + store.putWidget({ + sessionKey: "session", + name: "app", + content: { + kind: "mcp-app", + descriptor: { + serverName: "server", + toolName: "tool", + uiResourceUri: "ui://resource", + originSessionKey: "origin", + toolCallId: "call", + }, + }, + }); + expect(store.readWidgetHtml("session", "html")).toMatchObject({ + html: "
ok
", + revision: 1, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(store.readWidgetHtml("session", "app")).toEqual({ + descriptor: { + serverName: "server", + toolName: "tool", + uiResourceUri: "ui://resource", + originSessionKey: "origin", + toolCallId: "call", + }, + revision: 1, + }); + expect(store.readWidgetHtml("session", "unknown")).toBeUndefined(); + }); + + it("transitions declared widgets through pending grants", () => { + const store = new InMemoryBoardStore(); + const pending = store.putWidget({ + sessionKey: "session", + name: "networked", + content: { kind: "html", html: "

ok

" }, + declared: { netOrigins: ["https://example.com"] }, + }); + expect(pending.widgets[0]!.grantState).toBe("pending"); + expect(store.grant("session", "networked", "granted", 1).widgets[0]!.grantState).toBe( + "granted", + ); + expect(() => store.grant("session", "networked", "rejected", 1)).toThrow("not pending"); + }); + + it("survives reset/new boundaries", () => { + const store = new InMemoryBoardStore(); + putHtml(store, "session", "status"); + // Session reset has no BoardStore call; the stable session key remains authoritative. + expect(store.getSnapshot("session").widgets).toHaveLength(1); + }); + + it("rejects stale grant revisions and accepts the current revision", () => { + const store = new InMemoryBoardStore(); + store.putWidget({ + sessionKey: "session", + name: "networked", + content: { kind: "html", html: "ok" }, + declared: { tools: ["weather.refresh"] }, + }); + try { + store.grant("session", "networked", "granted", 2); + throw new Error("expected stale grant to fail"); + } catch (error) { + expect(error).toBeInstanceOf(BoardValidationError); + expect(error).toMatchObject({ code: "conflict" }); + expect((error as Error).message).toContain("revision changed"); + } + expect(store.grant("session", "networked", "granted", 1).widgets[0]).toMatchObject({ + grantState: "granted", + revision: 1, + }); + }); + + it("enforces the board widget count and UTF-8 HTML byte limits", () => { + const store = new InMemoryBoardStore(); + for (let index = 0; index < 48; index += 1) { + putHtml(store, "session", `widget-${index}`, "ok"); + } + try { + putHtml(store, "session", "widget-48", "ok"); + throw new Error("expected widget cap to fail"); + } catch (error) { + expect(error).toBeInstanceOf(BoardValidationError); + expect(error).toMatchObject({ code: "invalid_operation" }); + expect((error as Error).message).toContain("more than 48 widgets"); + } + expect(() => + putHtml(new InMemoryBoardStore(), "session", "large", "é".repeat(131_073)), + ).toThrow("262144 UTF-8 bytes"); + }); + + it("bumps once per applyOps transaction and removes widget bytes", () => { + const store = new InMemoryBoardStore(); + putHtml(store, "session", "status"); + const snapshot = store.applyOps("session", [ + { kind: "widget_resize", name: "status", sizeW: 3, sizeH: 3 }, + { kind: "widget_remove", name: "status" }, + ]); + expect(snapshot.revision).toBe(2); + expect(snapshot.widgets).toEqual([]); + expect(store.readWidgetHtml("session", "status")).toBeUndefined(); + }); + + it("preserves position on content updates and honors explicit after placement", () => { + const store = new InMemoryBoardStore(); + putHtml(store, "session", "first"); + putHtml(store, "session", "second"); + putHtml(store, "session", "third"); + + expect( + putHtml(store, "session", "first", "

updated

").widgets.map((widget) => widget.name), + ).toEqual(["first", "second", "third"]); + expect( + store + .putWidget({ + sessionKey: "session", + name: "first", + content: { kind: "html", html: "

moved

" }, + placement: { after: "third" }, + }) + .widgets.map((widget) => widget.name), + ).toEqual(["second", "third", "first"]); + }); +}); diff --git a/src/boards/board-store.ts b/src/boards/board-store.ts new file mode 100644 index 000000000000..1c2b30a1d45c --- /dev/null +++ b/src/boards/board-store.ts @@ -0,0 +1,279 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { + BoardMcpAppDescriptor, + BoardOp, + BoardSnapshot, + BoardWidgetContent, + BoardWidgetPutParams, +} from "../../packages/gateway-protocol/src/index.js"; +import { + applyBoardOps, + BOARD_SIZE_PRESETS, + BoardValidationError, + insertBoardWidget, + normalizeBoardLayout, + type BoardSize, +} from "./board-layout.js"; + +type BoardWidgetHtmlDocument = { + html: string; + revision: number; + sha256: string; + viewGeneration: string; + grantState: "none" | "pending" | "granted" | "rejected"; +}; +type BoardWidgetMcpAppDocument = { + descriptor: BoardMcpAppDescriptor; + revision: number; +}; +export type BoardWidgetDocument = BoardWidgetHtmlDocument | BoardWidgetMcpAppDocument; + +export interface BoardStore { + getSnapshot(sessionKey: string): BoardSnapshot; + applyOps(sessionKey: string, ops: readonly BoardOp[]): BoardSnapshot; + putWidget(params: BoardWidgetPutParams): BoardSnapshot; + grant( + sessionKey: string, + name: string, + decision: "granted" | "rejected", + revision: number, + ): BoardSnapshot; + readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined; + listSessionsWithBoards(): string[]; +} + +type StoredBoard = { + snapshot: BoardSnapshot; + documents: Map; +}; + +const BOARD_MAX_WIDGETS = 48; +const BOARD_MAX_WIDGET_HTML_BYTES = 256 * 1024; + +function emptyBoardSnapshot(sessionKey: string): BoardSnapshot { + return { sessionKey, revision: 0, tabs: [], widgets: [] }; +} + +export function cloneBoardSnapshot(snapshot: BoardSnapshot): BoardSnapshot { + return { + sessionKey: snapshot.sessionKey, + revision: snapshot.revision, + tabs: snapshot.tabs.map((tab) => ({ ...tab })), + widgets: snapshot.widgets.map((widget) => ({ + ...widget, + ...(widget.declaredSummary !== undefined + ? { declaredSummary: [...widget.declaredSummary] } + : {}), + })), + }; +} + +function createBoardWidgetDocument( + content: BoardWidgetContent, + revision: number, + grantState: BoardWidgetHtmlDocument["grantState"], +): BoardWidgetDocument { + if (content.kind === "html") { + return { + html: content.html, + revision, + sha256: createHash("sha256").update(content.html).digest("hex"), + viewGeneration: randomBytes(16).toString("hex"), + grantState, + }; + } + return { descriptor: { ...content.descriptor }, revision }; +} + +export function createBoardDeclaredSummary( + declared: BoardWidgetPutParams["declared"], +): string[] | undefined { + const lines = [ + ...(declared?.netOrigins ?? []).map((origin) => `Network access: ${origin}`), + ...(declared?.tools ?? []).map((tool) => `Tool access: ${tool}`), + ]; + return lines.length > 0 ? lines : undefined; +} + +export function createBoardWidgetPutSnapshot( + prior: BoardSnapshot, + params: BoardWidgetPutParams, +): BoardSnapshot { + if ( + params.content.kind === "html" && + Buffer.byteLength(params.content.html, "utf8") > BOARD_MAX_WIDGET_HTML_BYTES + ) { + throw new BoardValidationError( + "invalid_operation", + `board widget HTML exceeds ${BOARD_MAX_WIDGET_HTML_BYTES} UTF-8 bytes`, + ); + } + let layout = normalizeBoardLayout(prior); + if (layout.tabs.length === 0) { + layout.tabs.push({ tabId: "main", title: "Main", position: 0, chatDock: "right" }); + } + const existing = layout.widgets.find((widget) => widget.name === params.name); + if (!existing && layout.widgets.length >= BOARD_MAX_WIDGETS) { + throw new BoardValidationError( + "invalid_operation", + `board cannot contain more than ${BOARD_MAX_WIDGETS} widgets`, + ); + } + const tabId = params.placement?.tabId ?? existing?.tabId ?? layout.tabs[0]!.tabId; + if (!layout.tabs.some((tab) => tab.tabId === tabId)) { + throw new BoardValidationError("not_found", `board tab not found: ${tabId}`); + } + const size = BOARD_SIZE_PRESETS[(params.placement?.size ?? "md") as BoardSize]; + const widgetRevision = (existing?.revision ?? 0) + 1; + const declaredSummary = createBoardDeclaredSummary(params.declared); + // A grant follows new bytes only when every declared capability was already approved. + // Any widening must return to pending before the widget can be served. + const preservesGrant = + existing?.grantState === "granted" && + (declaredSummary ?? []).every((entry) => existing.declaredSummary?.includes(entry)); + layout = insertBoardWidget( + layout, + { + name: params.name, + tabId, + ...(params.title !== undefined + ? { title: params.title } + : existing?.title !== undefined + ? { title: existing.title } + : {}), + contentKind: params.content.kind, + sizeW: params.placement?.size ? size.sizeW : (existing?.sizeW ?? size.sizeW), + sizeH: params.placement?.size ? size.sizeH : (existing?.sizeH ?? size.sizeH), + position: existing?.position ?? layout.widgets.length, + grantState: preservesGrant ? "granted" : declaredSummary ? "pending" : "none", + revision: widgetRevision, + ...(declaredSummary ? { declaredSummary } : {}), + }, + { + tabId, + ...(params.placement?.after ? { after: params.placement.after } : {}), + move: params.placement?.tabId !== undefined || params.placement?.after !== undefined, + }, + ); + if (!declaredSummary) { + delete layout.widgets.find((widget) => widget.name === params.name)!.declaredSummary; + } + return { + sessionKey: params.sessionKey, + revision: prior.revision + 1, + ...layout, + }; +} + +export function createBoardGrantSnapshot( + current: BoardSnapshot, + name: string, + decision: "granted" | "rejected", + revision: number, +): BoardSnapshot { + const widget = current.widgets.find((candidate) => candidate.name === name); + if (!widget) { + throw new BoardValidationError("not_found", `board widget not found: ${name}`); + } + if (widget.revision !== revision) { + throw new BoardValidationError( + "conflict", + `board widget revision changed: ${name} is revision ${widget.revision}, not ${revision}`, + ); + } + if (widget.grantState !== "pending") { + throw new BoardValidationError( + "invalid_operation", + `board widget grant is not pending: ${name}`, + ); + } + const snapshot = cloneBoardSnapshot(current); + snapshot.widgets.find((candidate) => candidate.name === name)!.grantState = decision; + snapshot.revision += 1; + return snapshot; +} + +export class InMemoryBoardStore implements BoardStore { + private readonly boards = new Map(); + + getSnapshot(sessionKey: string): BoardSnapshot { + return cloneBoardSnapshot( + this.boards.get(sessionKey)?.snapshot ?? emptyBoardSnapshot(sessionKey), + ); + } + + applyOps(sessionKey: string, ops: readonly BoardOp[]): BoardSnapshot { + const current = this.boards.get(sessionKey); + const snapshot = current?.snapshot ?? emptyBoardSnapshot(sessionKey); + if (ops.length === 0) { + return cloneBoardSnapshot(snapshot); + } + const layout = applyBoardOps(snapshot, ops); + const next: BoardSnapshot = { + sessionKey, + revision: snapshot.revision + 1, + ...layout, + }; + const removedNames = new Set(next.widgets.map((widget) => widget.name)); + const documents = new Map( + [...(current?.documents ?? [])].filter(([name]) => removedNames.has(name)), + ); + if (next.tabs.length === 0 && next.widgets.length === 0) { + this.boards.delete(sessionKey); + } else { + this.boards.set(sessionKey, { snapshot: next, documents }); + } + return cloneBoardSnapshot(next); + } + + putWidget(params: BoardWidgetPutParams): BoardSnapshot { + const current = this.boards.get(params.sessionKey); + const prior = current?.snapshot ?? emptyBoardSnapshot(params.sessionKey); + const snapshot = createBoardWidgetPutSnapshot(prior, params); + const documents = new Map(current?.documents ?? []); + const widgetRevision = snapshot.widgets.find((widget) => widget.name === params.name)!.revision; + const widget = snapshot.widgets.find((candidate) => candidate.name === params.name)!; + documents.set( + params.name, + createBoardWidgetDocument(params.content, widgetRevision, widget.grantState), + ); + this.boards.set(params.sessionKey, { snapshot, documents }); + return cloneBoardSnapshot(snapshot); + } + + grant( + sessionKey: string, + name: string, + decision: "granted" | "rejected", + revision: number, + ): BoardSnapshot { + const current = this.boards.get(sessionKey); + if (!current) { + throw new BoardValidationError("not_found", `board widget not found: ${name}`); + } + const snapshot = createBoardGrantSnapshot(current.snapshot, name, decision, revision); + const document = current.documents.get(name); + if (document && "html" in document) { + document.grantState = decision; + } + this.boards.set(sessionKey, { snapshot, documents: current.documents }); + return cloneBoardSnapshot(snapshot); + } + + readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined { + const document = this.boards.get(sessionKey)?.documents.get(name); + if (!document) { + return undefined; + } + return "html" in document + ? { ...document } + : { descriptor: { ...document.descriptor }, revision: document.revision }; + } + + listSessionsWithBoards(): string[] { + return [...this.boards] + .filter(([, board]) => board.snapshot.tabs.length > 0 || board.snapshot.widgets.length > 0) + .map(([sessionKey]) => sessionKey) + .toSorted(); + } +} diff --git a/src/boards/sqlite-board-store.ts b/src/boards/sqlite-board-store.ts new file mode 100644 index 000000000000..39a8fb684d77 --- /dev/null +++ b/src/boards/sqlite-board-store.ts @@ -0,0 +1,602 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { Selectable } from "kysely"; +import type { + BoardMcpAppDescriptor, + BoardOp, + BoardSnapshot, + BoardTab, + BoardWidget, + BoardWidgetPutParams, +} from "../../packages/gateway-protocol/src/index.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import { + runSqliteDeferredTransactionSync, + runSqliteImmediateTransactionSync, +} from "../infra/sqlite-transaction.js"; +import { OPENCLAW_AGENT_BOARD_SCHEMA_SQL } from "../state/openclaw-agent-board-schema.js"; +import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js"; +import type { + BoardTabs as BoardTabRow, + BoardWidgets as BoardWidgetRow, + DB as OpenClawAgentKyselyDatabase, +} from "../state/openclaw-agent-db.generated.js"; +import { + listOpenClawRegisteredAgentDatabases, + openOpenClawAgentDatabase, + resolveOpenClawAgentSqlitePath, + runOpenClawAgentWriteTransaction, + type OpenClawAgentDatabase, +} from "../state/openclaw-agent-db.js"; +import { applyBoardOps, BoardValidationError, normalizeBoardLayout } from "./board-layout.js"; +import { + cloneBoardSnapshot, + createBoardDeclaredSummary, + createBoardGrantSnapshot, + createBoardWidgetPutSnapshot, + type BoardStore, + type BoardWidgetDocument, +} from "./board-store.js"; + +type BoardDatabase = Pick< + OpenClawAgentKyselyDatabase, + "board_tabs" | "board_widgets" | "session_entries" +>; +type BoardDatabaseHandle = Pick; +type SelectedBoardTabRow = Selectable; +type SelectedBoardWidgetRow = Selectable; + +type StoredBoard = { + snapshot: BoardSnapshot; + tabRows: SelectedBoardTabRow[]; + widgetRows: SelectedBoardWidgetRow[]; +}; + +const ensuredBoardDatabases = new WeakSet(); + +function ensureBoardSchema(database: OpenClawAgentDatabase): void { + if (ensuredBoardDatabases.has(database.db)) { + return; + } + if (database.db.isTransaction) { + throw new Error("board schema must be ensured before the write transaction starts"); + } + runSqliteImmediateTransactionSync( + database.db, + () => database.db.exec(OPENCLAW_AGENT_BOARD_SCHEMA_SQL), // sqlite-allow-raw: one-time DDL bootstrap before Kysely access. + { + databaseLabel: database.path, + operationLabel: "board.ensure-schema", + }, + ); + // Additive-surface rule: fold this into the next natural schema bump, then delete this lazy ensure. + ensuredBoardDatabases.add(database.db); +} + +type SqliteBoardStoreOptions = { + resolveSession: (sessionKey: string) => { + agentId: string; + path?: string; + sessionKey: string; + }; + env?: NodeJS.ProcessEnv; +}; + +function parseManifest(value: string): BoardWidgetPutParams["declared"] { + const parsed = JSON.parse(value) as { netOrigins?: unknown; tools?: unknown }; + const netOrigins = Array.isArray(parsed.netOrigins) + ? parsed.netOrigins.filter((entry): entry is string => typeof entry === "string") + : undefined; + const tools = Array.isArray(parsed.tools) + ? parsed.tools.filter((entry): entry is string => typeof entry === "string") + : undefined; + return { + ...(netOrigins?.length ? { netOrigins } : {}), + ...(tools?.length ? { tools } : {}), + }; +} + +function parseDescriptor(value: string): BoardMcpAppDescriptor { + return JSON.parse(value) as BoardMcpAppDescriptor; +} + +function rowToTab(row: SelectedBoardTabRow): BoardTab { + return { + tabId: row.tab_id, + title: row.title, + position: row.position, + chatDock: row.chat_dock as BoardTab["chatDock"], + }; +} + +function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget { + const declaredSummary = createBoardDeclaredSummary(parseManifest(row.manifest)); + return { + name: row.name, + tabId: row.tab_id, + ...(row.title !== null ? { title: row.title } : {}), + contentKind: row.content_kind as BoardWidget["contentKind"], + sizeW: row.size_w, + sizeH: row.size_h, + position: row.position, + grantState: row.grant_state as BoardWidget["grantState"], + revision: row.revision, + ...(declaredSummary ? { declaredSummary } : {}), + }; +} + +function readStoredBoard(database: BoardDatabaseHandle, sessionKey: string): StoredBoard { + // Write callers already hold an IMMEDIATE transaction; the shared helper nests + // this consistent read as a savepoint instead of issuing a second BEGIN. + return runSqliteDeferredTransactionSync( + database.db, + () => { + const db = getNodeSqliteKysely(database.db); + const tabRows = executeSqliteQuerySync( + database.db, + db + .selectFrom("board_tabs") + .selectAll() + .where("session_key", "=", sessionKey) + .orderBy("position", "asc") + .orderBy("tab_id", "asc"), + ).rows as SelectedBoardTabRow[]; + const widgetRows = executeSqliteQuerySync( + database.db, + db + .selectFrom("board_widgets") + .selectAll() + .where("session_key", "=", sessionKey) + .orderBy("tab_id", "asc") + .orderBy("position", "asc") + .orderBy("name", "asc"), + ).rows as SelectedBoardWidgetRow[]; + const layout = normalizeBoardLayout({ + tabs: tabRows.map(rowToTab), + widgets: widgetRows.map(rowToWidget), + }); + return { + snapshot: { + sessionKey, + // Board existence is row-defined; deleting the last empty tab removes + // the board, so a later read starts again at the empty revision. + revision: tabRows.reduce((revision, row) => Math.max(revision, row.revision), 0), + ...layout, + }, + tabRows, + widgetRows, + }; + }, + { databaseLabel: database.path, operationLabel: "board.read" }, + ); +} + +function upsertTabs( + database: BoardDatabaseHandle, + previous: StoredBoard, + next: BoardSnapshot, +): void { + const db = getNodeSqliteKysely(database.db); + const createdBy = new Map(previous.tabRows.map((row) => [row.tab_id, row.created_by])); + for (const tab of next.tabs) { + executeSqliteQuerySync( + database.db, + db + .insertInto("board_tabs") + .values({ + session_key: next.sessionKey, + tab_id: tab.tabId, + title: tab.title, + position: tab.position, + chat_dock: tab.chatDock, + created_by: createdBy.get(tab.tabId) ?? "agent", + revision: next.revision, + }) + .onConflict((conflict) => + conflict.columns(["session_key", "tab_id"]).doUpdateSet({ + title: tab.title, + position: tab.position, + chat_dock: tab.chatDock, + revision: next.revision, + }), + ), + ); + } +} + +function updateWidgetLayouts( + database: BoardDatabaseHandle, + snapshot: BoardSnapshot, + updatedAt: number, +): void { + const db = getNodeSqliteKysely(database.db); + for (const widget of snapshot.widgets) { + executeSqliteQuerySync( + database.db, + db + .updateTable("board_widgets") + .set({ + tab_id: widget.tabId, + title: widget.title ?? null, + size_w: widget.sizeW, + size_h: widget.sizeH, + position: widget.position, + updated_at: updatedAt, + }) + .where("session_key", "=", snapshot.sessionKey) + .where("name", "=", widget.name), + ); + } +} + +function deleteRemovedWidgets( + database: BoardDatabaseHandle, + previous: StoredBoard, + next: BoardSnapshot, +): void { + const db = getNodeSqliteKysely(database.db); + const widgetNames = new Set(next.widgets.map((widget) => widget.name)); + for (const row of previous.widgetRows) { + if (!widgetNames.has(row.name)) { + executeSqliteQuerySync( + database.db, + db + .deleteFrom("board_widgets") + .where("session_key", "=", next.sessionKey) + .where("name", "=", row.name), + ); + } + } +} + +function deleteRemovedTabs( + database: BoardDatabaseHandle, + previous: StoredBoard, + next: BoardSnapshot, +): void { + const db = getNodeSqliteKysely(database.db); + const tabIds = new Set(next.tabs.map((tab) => tab.tabId)); + for (const row of previous.tabRows) { + if (!tabIds.has(row.tab_id)) { + executeSqliteQuerySync( + database.db, + db + .deleteFrom("board_tabs") + .where("session_key", "=", next.sessionKey) + .where("tab_id", "=", row.tab_id), + ); + } + } +} + +function contentFields( + params: BoardWidgetPutParams, + revision: number, + grantState: BoardWidget["grantState"], + viewGeneration: string, + now: number, +) { + const manifest = JSON.stringify(params.declared ?? {}); + if (params.content.kind === "html") { + const sha256 = createHash("sha256").update(params.content.html).digest("hex"); + return { + content_kind: "html", + html: Buffer.from(params.content.html, "utf8"), + descriptor_json: null, + sha256, + view_generation: viewGeneration, + revision, + manifest, + grant_state: grantState, + granted_sha: grantState === "granted" ? sha256 : null, + updated_at: now, + }; + } + const descriptorJson = JSON.stringify(params.content.descriptor); + const sha256 = createHash("sha256").update(descriptorJson).digest("hex"); + return { + content_kind: "mcp-app", + html: null, + descriptor_json: descriptorJson, + sha256, + view_generation: null, + revision, + manifest, + grant_state: grantState, + granted_sha: grantState === "granted" ? sha256 : null, + updated_at: now, + }; +} + +function hasSession(database: BoardDatabaseHandle, sessionKey: string): boolean { + const db = getNodeSqliteKysely(database.db); + return Boolean( + executeSqliteQuerySync( + database.db, + db + .selectFrom("session_entries") + .select("session_key") + .where("session_key", "=", sessionKey) + .limit(1), + ).rows[0], + ); +} + +function emptyBoardSnapshot(sessionKey: string): BoardSnapshot { + return { sessionKey, revision: 0, tabs: [], widgets: [] }; +} + +export class SqliteBoardStore implements BoardStore { + constructor(private readonly options: SqliteBoardStoreOptions) {} + + private resolve(sessionKey: string): { agentId: string; path?: string; sessionKey: string } { + return this.options.resolveSession(sessionKey); + } + + private requireExistingSession(resolved: { + agentId: string; + path?: string; + sessionKey: string; + }): void { + const result = withOpenClawAgentDatabaseReadOnly( + (database) => hasSession(database, resolved.sessionKey), + { + agentId: resolved.agentId, + ...(resolved.path ? { path: resolved.path } : {}), + env: this.options.env, + }, + ); + if (!result.found || !result.value) { + throw new BoardValidationError( + "not_found", + `board session not found: ${resolved.sessionKey}`, + ); + } + } + + private prepareWrite(sessionKey: string): { + database: OpenClawAgentDatabase; + resolved: { agentId: string; path?: string; sessionKey: string }; + } { + const resolved = this.resolve(sessionKey); + this.requireExistingSession(resolved); + const database = openOpenClawAgentDatabase({ + agentId: resolved.agentId, + ...(resolved.path ? { path: resolved.path } : {}), + env: this.options.env, + }); + ensureBoardSchema(database); + return { database, resolved }; + } + + getSnapshot(sessionKey: string): BoardSnapshot { + const resolved = this.resolve(sessionKey); + const result = withOpenClawAgentDatabaseReadOnly( + (database) => + hasSession(database, resolved.sessionKey) + ? readStoredBoard(database, resolved.sessionKey).snapshot + : undefined, + { + agentId: resolved.agentId, + ...(resolved.path ? { path: resolved.path } : {}), + env: this.options.env, + }, + ); + return cloneBoardSnapshot( + result.found && result.value ? result.value : emptyBoardSnapshot(resolved.sessionKey), + ); + } + + applyOps(sessionKey: string, ops: readonly BoardOp[]): BoardSnapshot { + if (ops.length === 0) { + return this.getSnapshot(sessionKey); + } + const { database, resolved } = this.prepareWrite(sessionKey); + return runOpenClawAgentWriteTransaction( + (transactionDatabase) => { + if (!hasSession(transactionDatabase, resolved.sessionKey)) { + throw new BoardValidationError( + "not_found", + `board session not found: ${resolved.sessionKey}`, + ); + } + const previous = readStoredBoard(transactionDatabase, resolved.sessionKey); + const layout = applyBoardOps(previous.snapshot, ops); + const next: BoardSnapshot = { + sessionKey: resolved.sessionKey, + revision: previous.snapshot.revision + 1, + ...layout, + }; + const now = Date.now(); + upsertTabs(transactionDatabase, previous, next); + deleteRemovedWidgets(transactionDatabase, previous, next); + updateWidgetLayouts(transactionDatabase, next, now); + deleteRemovedTabs(transactionDatabase, previous, next); + return cloneBoardSnapshot(next); + }, + { agentId: resolved.agentId, path: database.path, env: this.options.env }, + { operationLabel: "board.apply-ops" }, + ); + } + + putWidget(params: BoardWidgetPutParams): BoardSnapshot { + const { database, resolved } = this.prepareWrite(params.sessionKey); + const canonicalParams = { ...params, sessionKey: resolved.sessionKey }; + const viewGeneration = randomBytes(16).toString("hex"); + return runOpenClawAgentWriteTransaction( + (transactionDatabase) => { + if (!hasSession(transactionDatabase, resolved.sessionKey)) { + throw new BoardValidationError( + "not_found", + `board session not found: ${resolved.sessionKey}`, + ); + } + const previous = readStoredBoard(transactionDatabase, resolved.sessionKey); + const next = createBoardWidgetPutSnapshot(previous.snapshot, canonicalParams); + const widget = next.widgets.find((candidate) => candidate.name === canonicalParams.name)!; + const existing = previous.widgetRows.find((row) => row.name === canonicalParams.name); + const now = Date.now(); + upsertTabs(transactionDatabase, previous, next); + const db = getNodeSqliteKysely(transactionDatabase.db); + const fields = contentFields( + canonicalParams, + widget.revision, + widget.grantState, + viewGeneration, + now, + ); + executeSqliteQuerySync( + transactionDatabase.db, + db + .insertInto("board_widgets") + .values({ + session_key: resolved.sessionKey, + name: canonicalParams.name, + tab_id: widget.tabId, + title: widget.title ?? null, + size_w: widget.sizeW, + size_h: widget.sizeH, + position: widget.position, + created_by: existing?.created_by ?? "agent", + created_at: existing?.created_at ?? now, + ...fields, + }) + .onConflict((conflict) => + conflict.columns(["session_key", "name"]).doUpdateSet({ + tab_id: widget.tabId, + title: widget.title ?? null, + size_w: widget.sizeW, + size_h: widget.sizeH, + position: widget.position, + ...fields, + }), + ), + ); + updateWidgetLayouts(transactionDatabase, next, now); + return cloneBoardSnapshot(next); + }, + { agentId: resolved.agentId, path: database.path, env: this.options.env }, + { operationLabel: "board.put-widget" }, + ); + } + + grant( + sessionKey: string, + name: string, + decision: "granted" | "rejected", + revision: number, + ): BoardSnapshot { + const { database, resolved } = this.prepareWrite(sessionKey); + return runOpenClawAgentWriteTransaction( + (transactionDatabase) => { + if (!hasSession(transactionDatabase, resolved.sessionKey)) { + throw new BoardValidationError( + "not_found", + `board session not found: ${resolved.sessionKey}`, + ); + } + const previous = readStoredBoard(transactionDatabase, resolved.sessionKey); + const next = createBoardGrantSnapshot(previous.snapshot, name, decision, revision); + upsertTabs(transactionDatabase, previous, next); + const row = previous.widgetRows.find((candidate) => candidate.name === name)!; + const db = getNodeSqliteKysely(transactionDatabase.db); + executeSqliteQuerySync( + transactionDatabase.db, + db + .updateTable("board_widgets") + .set({ + grant_state: decision, + granted_sha: decision === "granted" ? row.sha256 : null, + updated_at: Date.now(), + }) + .where("session_key", "=", resolved.sessionKey) + .where("name", "=", name), + ); + return cloneBoardSnapshot(next); + }, + { agentId: resolved.agentId, path: database.path, env: this.options.env }, + { operationLabel: "board.grant-widget" }, + ); + } + + readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined { + const resolved = this.resolve(sessionKey); + const result = withOpenClawAgentDatabaseReadOnly( + (database) => { + if (!hasSession(database, resolved.sessionKey)) { + return undefined; + } + const db = getNodeSqliteKysely(database.db); + const row = executeSqliteQuerySync( + database.db, + db + .selectFrom("board_widgets") + .select([ + "content_kind", + "html", + "descriptor_json", + "revision", + "sha256", + "view_generation", + "grant_state", + ]) + .where("session_key", "=", resolved.sessionKey) + .where("name", "=", name) + .limit(1), + ).rows[0]; + if (!row) { + return undefined; + } + if (row.content_kind === "html" && row.html !== null && row.view_generation !== null) { + return { + html: Buffer.from(row.html).toString("utf8"), + revision: row.revision, + sha256: row.sha256, + viewGeneration: row.view_generation, + grantState: row.grant_state as BoardWidget["grantState"], + }; + } + if (row.content_kind === "mcp-app" && row.descriptor_json !== null) { + return { descriptor: parseDescriptor(row.descriptor_json), revision: row.revision }; + } + return undefined; + }, + { + agentId: resolved.agentId, + ...(resolved.path ? { path: resolved.path } : {}), + env: this.options.env, + }, + ); + return result.found ? result.value : undefined; + } + + listSessionsWithBoards(): string[] { + const sessionKeys = new Set(); + const agentIds = new Set( + listOpenClawRegisteredAgentDatabases({ env: this.options.env }).map( + (registered) => registered.agentId, + ), + ); + for (const agentId of agentIds) { + const canonicalPath = + this.resolve(`agent:${agentId}:main`).path ?? + resolveOpenClawAgentSqlitePath({ agentId, env: this.options.env }); + const result = withOpenClawAgentDatabaseReadOnly( + (database) => { + const db = getNodeSqliteKysely(database.db); + return executeSqliteQuerySync( + database.db, + db.selectFrom("board_tabs").select("session_key").distinct(), + ).rows; + }, + { agentId, path: canonicalPath, env: this.options.env }, + ); + if (result.found) { + for (const row of result.value) { + sessionKeys.add(row.session_key); + } + } + } + return [...sessionKeys].toSorted(); + } +} diff --git a/src/config/sessions/session-accessor.sqlite-lifecycle.ts b/src/config/sessions/session-accessor.sqlite-lifecycle.ts index 3f107d35a51a..68b2d0903e5a 100644 --- a/src/config/sessions/session-accessor.sqlite-lifecycle.ts +++ b/src/config/sessions/session-accessor.sqlite-lifecycle.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; import { isAgentHarnessSessionKey, isValidAgentHarnessSessionStoreEntry, @@ -6,9 +7,11 @@ import { resolveAgentHarnessSessionStoreEntryError, } from "../../sessions/agent-harness-session-key.js"; import { emitSessionIdentityMutation } from "../../sessions/session-lifecycle-events.js"; +import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js"; import { openOpenClawAgentDatabase, runOpenClawAgentWriteTransaction, + type OpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; import { materializeSqliteSessionStateDeletePlans } from "./session-accessor.sqlite-archive.js"; import type { @@ -50,6 +53,44 @@ import type { SessionEntry } from "./types.js"; // Single-target lifecycle owner: cleanup, reset, guarded delete, and trusted rollback. +type SessionBoardCleanupDatabase = Pick< + OpenClawAgentKyselyDatabase, + "board_tabs" | "board_widgets" +> & { + sqlite_schema: { + name: string | null; + type: string; + }; +}; + +function deleteSessionBoardRows( + database: OpenClawAgentDatabase, + sessionKeys: readonly string[], +): void { + const keys = [...new Set(sessionKeys)]; + if (keys.length === 0) { + return; + } + const db = getNodeSqliteKysely(database.db); + const tableRows = executeSqliteQuerySync( + database.db, + db + .selectFrom("sqlite_schema") + .select("name") + .where("type", "=", "table") + .where("name", "in", ["board_tabs", "board_widgets"]), + ).rows; + const tables = new Set(tableRows.map((row) => row.name)); + if (!tables.has("board_tabs") || !tables.has("board_widgets")) { + return; + } + executeSqliteQuerySync( + database.db, + db.deleteFrom("board_widgets").where("session_key", "in", keys), + ); + executeSqliteQuerySync(database.db, db.deleteFrom("board_tabs").where("session_key", "in", keys)); +} + export async function cleanupSqliteSessionLifecycleArtifacts( params: SessionLifecycleArtifactCleanupParams, ): Promise { @@ -230,6 +271,11 @@ async function deleteSqliteSessionEntryLifecycleInternal( return; } deleteSqliteLifecycleTargetRows(transactionDb, params.target); + deleteSessionBoardRows(transactionDb, [ + params.target.canonicalKey, + ...params.target.storeKeys, + ...transactionSnapshot.rows.map((row) => row.sessionKey), + ]); const archivedTranscripts = deleteMaterializedSqliteSessionStatePlans( transactionDb, materializedPlans, diff --git a/src/gateway/board-http.test.ts b/src/gateway/board-http.test.ts new file mode 100644 index 000000000000..c5de4439a90a --- /dev/null +++ b/src/gateway/board-http.test.ts @@ -0,0 +1,259 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { InMemoryBoardStore } from "../boards/board-store.js"; +import { handleBoardHttpRequest } from "./board-http.js"; +import { + BOARD_VIEW_TICKET_TTL_MS, + createBoardViewTicket, + verifyBoardViewTicket, +} from "./board-view-ticket.js"; + +const store = new InMemoryBoardStore(); +const nowMs = 1_800_000_000_000; +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + store.putWidget({ + sessionKey: "agent:main:main", + name: "status", + content: { kind: "html", html: "

Status

" }, + }); + store.putWidget({ + sessionKey: "agent:main:main", + name: "pending", + content: { kind: "html", html: "pending" }, + declared: { tools: ["pending.read"] }, + }); + store.putWidget({ + sessionKey: "agent:main:main", + name: "rejected", + content: { kind: "html", html: "rejected" }, + declared: { tools: ["rejected.read"] }, + }); + store.grant("agent:main:main", "rejected", "rejected", 1); + store.putWidget({ + sessionKey: "agent:main:main", + name: "mcp", + content: { + kind: "mcp-app", + descriptor: { + serverName: "server", + toolName: "tool", + uiResourceUri: "ui://resource", + originSessionKey: "origin", + toolCallId: "call", + }, + }, + }); + store.putWidget({ + sessionKey: "agent:main:main", + name: "revisioned", + content: { kind: "html", html: "

one

" }, + }); + server = createServer((req, res) => { + const handled = handleBoardHttpRequest(req, res, { + store, + nowMs, + }); + if (!handled) { + res.statusCode = 404; + res.end("unhandled"); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +}); + +function ticketFor(name: string, revision = 1, issuedAtMs = nowMs): string { + const document = store.readWidgetHtml("agent:main:main", name); + if (!document || !("html" in document)) { + throw new Error(`missing HTML widget: ${name}`); + } + return createBoardViewTicket({ + sessionKey: "agent:main:main", + name, + revision, + viewGeneration: document.viewGeneration, + nowMs: issuedAtMs, + }).ticket; +} + +function request( + name: string, + init: { method?: string; headers?: Record; ticket?: string } = {}, +) { + const query = init.ticket ? `?bt=${encodeURIComponent(init.ticket)}` : ""; + return fetch(`${baseUrl}/__openclaw__/board/agent%3Amain%3Amain/${name}/index.html${query}`, { + method: init.method, + headers: init.headers, + }); +} + +describe("board widget HTTP", () => { + it("round-trips self-contained claims covered by a two-minute HMAC ticket", () => { + const document = store.readWidgetHtml("agent:main:main", "status"); + if (!document || !("html" in document)) { + throw new Error("missing status widget"); + } + const issued = createBoardViewTicket({ + sessionKey: "agent:main:main", + name: "status", + revision: 1, + viewGeneration: document.viewGeneration, + nowMs, + }); + expect(issued.ticket).toMatch(/^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u); + expect(issued.ticket).not.toContain("agent:main:main"); + expect(issued.expiresAtMs).toBe(nowMs + BOARD_VIEW_TICKET_TTL_MS); + expect(verifyBoardViewTicket(issued.ticket, { nowMs })).toEqual({ + sessionKey: "agent:main:main", + name: "status", + revision: 1, + viewGeneration: document.viewGeneration, + expiresAtMs: issued.expiresAtMs, + nonce: expect.stringMatching(/^[A-Za-z0-9_-]{32}$/u), + }); + }); + + it("serves HTML bytes with a valid ticket and no gateway auth", async () => { + const response = await request("status", { ticket: ticketFor("status") }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("content-security-policy")).toBe("sandbox allow-scripts"); + expect(response.headers.get("cache-control")).toBe("no-cache"); + await expect(response.text()).resolves.toBe("

Status

"); + }); + + it("does not require or inspect an operator token", async () => { + const response = await request("status", { + ticket: ticketFor("status"), + headers: { Authorization: "Bearer test-token" }, + }); + expect(response.status).toBe(200); + }); + + it("rejects garbage and expired tickets before reading the store", async () => { + const expired = ticketFor("status", 1, nowMs - BOARD_VIEW_TICKET_TTL_MS - 1); + const valid = ticketFor("status"); + const readSpy = vi.spyOn(store, "readWidgetHtml"); + expect((await request("status")).status).toBe(401); + expect((await request("status", { ticket: "garbage" })).status).toBe(401); + expect((await request("status", { ticket: `${valid.slice(0, -1)}x` })).status).toBe(401); + expect((await request("status", { ticket: expired })).status).toBe(401); + expect(readSpy).not.toHaveBeenCalled(); + readSpy.mockRestore(); + }); + + it("rejects a ticket after the widget revision changes", async () => { + const stale = ticketFor("revisioned"); + store.putWidget({ + sessionKey: "agent:main:main", + name: "revisioned", + content: { kind: "html", html: "

two

" }, + }); + expect((await request("revisioned", { ticket: stale })).status).toBe(401); + const current = await request("revisioned", { ticket: ticketFor("revisioned", 2) }); + expect(current.status).toBe(200); + await expect(current.text()).resolves.toBe("

two

"); + }); + + it("rejects a stale ticket when a widget name and revision are reused", async () => { + store.putWidget({ + sessionKey: "agent:main:main", + name: "recreated", + content: { kind: "html", html: "

old

" }, + }); + const stale = ticketFor("recreated"); + store.applyOps("agent:main:main", [{ kind: "widget_remove", name: "recreated" }]); + store.putWidget({ + sessionKey: "agent:main:main", + name: "recreated", + content: { kind: "html", html: "

old

" }, + }); + + expect((await request("recreated", { ticket: stale })).status).toBe(401); + expect((await request("recreated", { ticket: ticketFor("recreated") })).status).toBe(200); + }); + + it("rejects a ticket with a stale view generation", async () => { + const ticket = createBoardViewTicket({ + sessionKey: "agent:main:main", + name: "status", + revision: 1, + viewGeneration: "0".repeat(32), + nowMs, + }).ticket; + expect((await request("status", { ticket })).status).toBe(401); + }); + + it("refuses pending and rejected widgets even with valid tickets", async () => { + expect((await request("pending", { ticket: ticketFor("pending") })).status).toBe(401); + expect((await request("rejected", { ticket: ticketFor("rejected") })).status).toBe(401); + }); + + it("serves an encoded slash as part of an opaque session key", async () => { + store.putWidget({ + sessionKey: "session/with/slash", + name: "slash-key", + content: { kind: "html", html: "slash" }, + }); + const document = store.readWidgetHtml("session/with/slash", "slash-key"); + if (!document || !("html" in document)) { + throw new Error("missing slash-key widget"); + } + const ticket = createBoardViewTicket({ + sessionKey: "session/with/slash", + name: "slash-key", + revision: 1, + viewGeneration: document.viewGeneration, + nowMs, + }).ticket; + const response = await fetch( + `${baseUrl}/__openclaw__/board/session%2Fwith%2Fslash/slash-key/index.html?bt=${encodeURIComponent(ticket)}`, + ); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("slash"); + }); + + it("returns 401 when valid claims have no matching HTML document", async () => { + const ticket = createBoardViewTicket({ + sessionKey: "agent:main:main", + name: "missing", + revision: 1, + viewGeneration: "0".repeat(32), + nowMs, + }).ticket; + expect((await request("missing", { ticket })).status).toBe(401); + const mcpTicket = createBoardViewTicket({ + sessionKey: "agent:main:main", + name: "mcp", + revision: 1, + viewGeneration: "0".repeat(32), + nowMs, + }).ticket; + expect((await request("mcp", { ticket: mcpTicket })).status).toBe(401); + }); + + it("allows GET only", async () => { + const response = await request("status", { method: "POST", ticket: ticketFor("status") }); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET"); + }); +}); diff --git a/src/gateway/board-http.ts b/src/gateway/board-http.ts new file mode 100644 index 000000000000..35a09d8ef6ce --- /dev/null +++ b/src/gateway/board-http.ts @@ -0,0 +1,85 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { BoardStore } from "../boards/board-store.js"; +import { boardStore } from "./board-store.js"; +import { BOARD_HTTP_PATH_PREFIX, verifyBoardViewTicket } from "./board-view-ticket.js"; +import { sendMethodNotAllowed } from "./http-common.js"; + +const BOARD_WIDGET_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; + +type BoardHttpOptions = { + store?: BoardStore; + nowMs?: number; +}; + +function sendNotFound(res: ServerResponse): void { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); +} + +function sendUnauthorized(res: ServerResponse): void { + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); +} + +function parseBoardWidgetPath(pathname: string): { sessionKey: string; name: string } | undefined { + const match = /^\/__openclaw__\/board\/([^/]+)\/([^/]+)\/index\.html$/.exec(pathname); + if (!match) { + return undefined; + } + try { + const sessionKey = decodeURIComponent(match[1]!); + const name = decodeURIComponent(match[2]!); + if (!sessionKey || !BOARD_WIDGET_NAME_PATTERN.test(name)) { + return undefined; + } + return { sessionKey, name }; + } catch { + return undefined; + } +} + +export function handleBoardHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: BoardHttpOptions = {}, +): boolean { + const url = new URL(req.url ?? "/", "http://localhost"); + const pathname = url.pathname; + if (!pathname.startsWith(BOARD_HTTP_PATH_PREFIX)) { + return false; + } + if (req.method !== "GET") { + sendMethodNotAllowed(res, "GET"); + return true; + } + const path = parseBoardWidgetPath(pathname); + if (!path) { + sendNotFound(res); + return true; + } + const ticket = url.searchParams.get("bt"); + const claims = ticket ? verifyBoardViewTicket(ticket, { nowMs: opts.nowMs }) : undefined; + if (!claims || claims.sessionKey !== path.sessionKey || claims.name !== path.name) { + sendUnauthorized(res); + return true; + } + const document = (opts.store ?? boardStore).readWidgetHtml(path.sessionKey, path.name); + if ( + !document || + !("html" in document) || + (document.grantState !== "none" && document.grantState !== "granted") || + document.revision !== claims.revision || + document.viewGeneration !== claims.viewGeneration + ) { + sendUnauthorized(res); + return true; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Content-Security-Policy", "sandbox allow-scripts"); + res.setHeader("Cache-Control", "no-cache"); + res.end(document.html); + return true; +} diff --git a/src/gateway/board-store.ts b/src/gateway/board-store.ts new file mode 100644 index 000000000000..d1ee1c3aeff6 --- /dev/null +++ b/src/gateway/board-store.ts @@ -0,0 +1,20 @@ +import { SqliteBoardStore } from "../boards/sqlite-board-store.js"; +import { getRuntimeConfig } from "../config/io.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; +import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js"; + +export const boardStore = new SqliteBoardStore({ + resolveSession: (sessionKey) => { + const cfg = getRuntimeConfig(); + const canonicalSessionKey = resolveSessionStoreKey({ cfg, sessionKey }); + const agentId = resolveSessionStoreAgentId(cfg, canonicalSessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, { agentId }).path; + return { + agentId, + ...(databasePath ? { path: databasePath } : {}), + sessionKey: canonicalSessionKey, + }; + }, +}); diff --git a/src/gateway/board-view-ticket.ts b/src/gateway/board-view-ticket.ts new file mode 100644 index 000000000000..8211b734eb62 --- /dev/null +++ b/src/gateway/board-view-ticket.ts @@ -0,0 +1,118 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { safeEqualSecret } from "../security/secret-equal.js"; + +export const BOARD_HTTP_PATH_PREFIX = "/__openclaw__/board/"; +export const BOARD_VIEW_TICKET_TTL_MS = 2 * 60_000; + +const BOARD_VIEW_TICKET_SCOPE = "board-widget-view"; +const BOARD_VIEW_TICKET_MAX_LENGTH = 2_048; +const ticketSecret = randomBytes(32); + +type BoardViewTicket = { + ticket: string; + expiresAtMs: number; +}; + +type BoardViewTicketClaims = { + sessionKey: string; + name: string; + revision: number; + viewGeneration: string; + expiresAtMs: number; + nonce: string; +}; + +function signTicketPayload(payload: string, secret: Buffer): string { + return createHmac("sha256", secret) + .update(`${BOARD_VIEW_TICKET_SCOPE}\0${payload}`) + .digest("base64url"); +} + +function isValidClaims(value: unknown): value is BoardViewTicketClaims { + if (!value || typeof value !== "object") { + return false; + } + const claims = value as Partial; + return ( + typeof claims.sessionKey === "string" && + claims.sessionKey.length > 0 && + claims.sessionKey.length <= 512 && + typeof claims.name === "string" && + claims.name.length > 0 && + claims.name.length <= 64 && + Number.isSafeInteger(claims.revision) && + (claims.revision ?? 0) >= 1 && + typeof claims.viewGeneration === "string" && + /^[a-f0-9]{32}$/u.test(claims.viewGeneration) && + Number.isSafeInteger(claims.expiresAtMs) && + typeof claims.nonce === "string" && + /^[A-Za-z0-9_-]{32}$/u.test(claims.nonce) + ); +} + +export function createBoardViewTicket(params: { + sessionKey: string; + name: string; + revision: number; + viewGeneration: string; + nowMs?: number; +}): BoardViewTicket { + const nowMs = params.nowMs ?? Date.now(); + const claims: BoardViewTicketClaims = { + sessionKey: params.sessionKey, + name: params.name, + revision: params.revision, + viewGeneration: params.viewGeneration, + expiresAtMs: nowMs + BOARD_VIEW_TICKET_TTL_MS, + nonce: randomBytes(24).toString("base64url"), + }; + if (!Number.isSafeInteger(nowMs) || !isValidClaims(claims)) { + throw new Error("invalid board view ticket binding"); + } + const payload = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); + const signature = signTicketPayload(payload, ticketSecret); + return { + ticket: `v1.${payload}.${signature}`, + expiresAtMs: claims.expiresAtMs, + }; +} + +export function verifyBoardViewTicket( + value: string, + options: { nowMs?: number } = {}, +): BoardViewTicketClaims | undefined { + const nowMs = options.nowMs ?? Date.now(); + if (!Number.isSafeInteger(nowMs) || value.length > BOARD_VIEW_TICKET_MAX_LENGTH) { + return undefined; + } + const parts = value.split("."); + if (parts.length !== 3 || parts[0] !== "v1") { + return undefined; + } + const [, payload, signature] = parts; + if (!payload || !signature) { + return undefined; + } + const expectedSignature = signTicketPayload(payload, ticketSecret); + if (!safeEqualSecret(signature, expectedSignature)) { + return undefined; + } + let claims: unknown; + try { + claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return undefined; + } + if (!isValidClaims(claims) || claims.expiresAtMs <= nowMs) { + return undefined; + } + return claims; +} + +export function buildBoardWidgetFrameUrl(params: { + sessionKey: string; + name: string; + ticket: string; +}): string { + return `${BOARD_HTTP_PATH_PREFIX}${encodeURIComponent(params.sessionKey)}/${encodeURIComponent(params.name)}/index.html?bt=${encodeURIComponent(params.ticket)}`; +} diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 46e2e61d848d..c47d295a9925 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -115,6 +115,11 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "mcp.app.listResourceTemplates", scope: "operator.read" }, { name: "mcp.app.readResource", scope: "operator.read" }, { name: "mcp.app.callTool", 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: "tasks.list", scope: "operator.read" }, diff --git a/src/gateway/server-broadcast.board.test.ts b/src/gateway/server-broadcast.board.test.ts new file mode 100644 index 000000000000..065fb636cc6a --- /dev/null +++ b/src/gateway/server-broadcast.board.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGatewayBroadcaster } from "./server-broadcast.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; + +type RecordingSocket = { + bufferedAmount: number; + close: ReturnType; + send: ReturnType; + events: string[]; +}; + +function makeClient( + connId: string, + role: "node" | "operator", + scopes: string[], +): { client: GatewayWsClient; socket: RecordingSocket } { + const events: string[] = []; + const socket: RecordingSocket = { + bufferedAmount: 0, + close: vi.fn(), + send: vi.fn((payload: string) => { + events.push((JSON.parse(payload) as { event: string }).event); + }), + events, + }; + return { + client: { + socket: socket as unknown as GatewayWsClient["socket"], + connect: { role, scopes } as GatewayWsClient["connect"], + connId, + usesSharedGatewayAuth: false, + }, + socket, + }; +} + +describe("board event scope guards", () => { + it("delivers board events only to read-capable operators", () => { + const pairing = makeClient("pairing", "operator", ["operator.pairing"]); + const node = makeClient("node", "node", ["operator.read"]); + const read = makeClient("read", "operator", ["operator.read"]); + const write = makeClient("write", "operator", ["operator.write"]); + const admin = makeClient("admin", "operator", ["operator.admin"]); + const clients = new Set([pairing, node, read, write, admin].map((entry) => entry.client)); + const { broadcast } = createGatewayBroadcaster({ clients }); + + broadcast("board.changed", { sessionKey: "agent:main:main", revision: 1 }); + broadcast("board.command", { + sessionKey: "agent:main:main", + command: { kind: "focus_tab", tabId: "main" }, + }); + + expect(pairing.socket.events).toEqual([]); + expect(node.socket.events).toEqual([]); + expect(read.socket.events).toEqual(["board.changed", "board.command"]); + expect(write.socket.events).toEqual(["board.changed", "board.command"]); + expect(admin.socket.events).toEqual(["board.changed", "board.command"]); + }); +}); diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index cc2935950eaf..132ffbbf1565 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -33,6 +33,8 @@ import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js"; const EVENT_SCOPE_GUARDS: Record = { agent: [READ_SCOPE], chat: [READ_SCOPE], + "board.changed": [READ_SCOPE], + "board.command": [READ_SCOPE], "ui.command": [READ_SCOPE], "chat.send_timing": [READ_SCOPE], "chat.side_result": [READ_SCOPE], diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts index cd01ffe52e54..1de8c945e754 100644 --- a/src/gateway/server-http.probe.test.ts +++ b/src/gateway/server-http.probe.test.ts @@ -119,6 +119,14 @@ describe("gateway probe endpoints", () => { error: { code: "gateway_unavailable" }, }); + const blockedBoard = await sendGatewayRequest(server, { + path: "/__openclaw__/board/agent%3Amain%3Amain/status/index.html?bt=garbage", + }); + expect(blockedBoard.res.statusCode).toBe(503); + expect(JSON.parse(blockedBoard.getBody())).toMatchObject({ + error: { code: "gateway_unavailable" }, + }); + expect(resumeGatewaySuspend(prepared.suspensionId)).toEqual({ ok: true, status: "running", diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 866878a4e799..82622e72c374 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -98,6 +98,8 @@ const getControlUiModule = createLazyRuntimeModule(() => import("./control-ui.js const getCanvasServeModule = createLazyRuntimeModule(() => import("../canvas/serve.runtime.js")); +const getBoardHttpModule = createLazyRuntimeModule(() => import("./board-http.js")); + const getEmbeddingsHttpModule = createLazyRuntimeModule(() => import("./embeddings-http.js")); const getManagedImageAttachmentsModule = createLazyRuntimeModule( @@ -193,6 +195,10 @@ function isMcpAppStandalonePath(pathname: string): boolean { return pathname === "/__openclaw__/mcp-app" || pathname === "/__openclaw__/mcp-app/view"; } +function isBoardWidgetPath(pathname: string): boolean { + return pathname.startsWith("/__openclaw__/board/"); +} + function isEmbeddingsPath(pathname: string): boolean { return pathname === "/v1/embeddings"; } @@ -682,6 +688,15 @@ export function createGatewayHttpServer(opts: { ), }); } + if (isBoardWidgetPath(scopedRequestPath)) { + requestStages.push({ + name: "board-widget", + run: async () => + await runWithGatewayHttpWorkAdmission(res, async () => + (await getBoardHttpModule()).handleBoardHttpRequest(req, res), + ), + }); + } if (openResponsesEnabled && isOpenResponsesPath(scopedRequestPath)) { requestStages.push({ name: "openresponses", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index b05b0c3d2faf..7b1834a8a50d 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -61,6 +61,10 @@ const loadArtifactsHandlers = lazyHandlerModule( () => import("./server-methods/artifacts.js"), (module) => module.artifactsHandlers, ); +const loadBoardHandlers = lazyHandlerModule( + () => import("./server-methods/board.js"), + (module) => module.boardHandlers, +); const loadAuditHandlers = lazyHandlerModule( () => import("./server-methods/audit.js"), (module) => module.auditHandlers, @@ -372,6 +376,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["ui.command"], loadHandlers: loadUiCommandHandlers, }), + ...createLazyCoreHandlers({ + methods: ["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"], + loadHandlers: loadBoardHandlers, + }), ...createLazyCoreHandlers({ methods: ["voicewake.get", "voicewake.set"], loadHandlers: loadVoicewakeHandlers, diff --git a/src/gateway/server-methods/board.test.ts b/src/gateway/server-methods/board.test.ts new file mode 100644 index 000000000000..f4d9d9f84a91 --- /dev/null +++ b/src/gateway/server-methods/board.test.ts @@ -0,0 +1,350 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BoardSnapshot } from "../../../packages/gateway-protocol/src/index.js"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { resetBoardEventNoticeStateForTest } from "../../boards/board-notices.js"; +import { InMemoryBoardStore } from "../../boards/board-store.js"; +import { SqliteBoardStore } from "../../boards/sqlite-board-store.js"; +import { replaceSessionEntrySync } from "../../config/sessions/session-accessor.entry.js"; +import { peekSystemEvents, resetSystemEventsForTest } from "../../infra/system-events.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "../../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; +import { resolveCoreOperatorGatewayMethodScope } from "../methods/core-descriptors.js"; +import { createBoardHandlers } from "./board.js"; +import { sessionMutationHandlers } from "./sessions-mutations.js"; +import type { GatewayRequestContext, RespondFn } from "./types.js"; + +vi.mock("./sessions.runtime.js", () => ({ + performGatewaySessionReset: vi.fn(async ({ key, reason }: { key: string; reason: string }) => ({ + ok: true, + key, + agentId: "main", + entry: { sessionId: `reset-${reason}` }, + resolved: {}, + })), +})); + +function createHarness() { + const store = new InMemoryBoardStore(); + const broadcast = vi.fn(); + const handlers = createBoardHandlers(store); + const invoke = async (method: string, params: Record) => { + const respond = vi.fn(); + await handlers[method]!({ + req: { type: "req", id: "test", method, params }, + params, + client: null, + isWebchatConnect: () => false, + respond, + context: { broadcast } as unknown as GatewayRequestContext, + }); + return respond; + }; + return { store, broadcast, invoke }; +} + +describe("board gateway methods", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + + beforeEach(() => { + resetBoardEventNoticeStateForTest(); + resetSystemEventsForTest(); + }); + + afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + }); + + it("registers every contract method with its required scope", () => { + expect( + Object.fromEntries( + ["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"].map( + (method) => [method, resolveCoreOperatorGatewayMethodScope(method)], + ), + ), + ).toEqual({ + "board.get": "operator.read", + "board.update": "operator.write", + "board.widget.put": "operator.write", + "board.widget.grant": "operator.approvals", + "board.event": "operator.write", + }); + }); + + it("rejects malformed params before touching the store", async () => { + const { invoke, store } = createHarness(); + const response = await invoke("board.widget.put", { + sessionKey: "session", + name: "Invalid Name", + content: { kind: "html", html: "ok" }, + }); + expect(response).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + expect(store.listSessionsWithBoards()).toEqual([]); + }); + + it("adds fresh frame URLs only to admitted HTML widgets on board.get", async () => { + const { invoke } = createHarness(); + await invoke("board.widget.put", { + sessionKey: "agent:main:main", + name: "status", + content: { kind: "html", html: "

ok

" }, + declared: { + netOrigins: ["https://status.example"], + tools: ["status.refresh"], + }, + }); + await invoke("board.widget.put", { + sessionKey: "agent:main:main", + name: "app", + content: { + kind: "mcp-app", + descriptor: { + serverName: "server", + toolName: "tool", + uiResourceUri: "ui://resource", + originSessionKey: "origin", + toolCallId: "call", + }, + }, + }); + + const pendingResponse = await invoke("board.get", { sessionKey: "agent:main:main" }); + const pending = pendingResponse.mock.calls[0]?.[1] as BoardSnapshot; + expect(pending.widgets.find((widget) => widget.name === "status")).not.toHaveProperty( + "frameUrl", + ); + + await invoke("board.widget.grant", { + sessionKey: "agent:main:main", + name: "status", + decision: "granted", + revision: 1, + }); + await invoke("board.widget.put", { + sessionKey: "agent:main:main", + name: "rejected", + content: { kind: "html", html: "

no

" }, + declared: { tools: ["status.reject"] }, + }); + await invoke("board.widget.grant", { + sessionKey: "agent:main:main", + name: "rejected", + decision: "rejected", + revision: 1, + }); + + const firstResponse = await invoke("board.get", { sessionKey: "agent:main:main" }); + const first = firstResponse.mock.calls[0]?.[1] as BoardSnapshot; + const htmlFrameUrl = first.widgets.find((widget) => widget.name === "status")?.frameUrl; + expect(htmlFrameUrl).toMatch( + /^\/__openclaw__\/board\/agent%3Amain%3Amain\/status\/index\.html\?bt=v1\./u, + ); + expect(first.widgets.find((widget) => widget.name === "status")?.declaredSummary).toEqual([ + "Network access: https://status.example", + "Tool access: status.refresh", + ]); + expect(first.widgets.find((widget) => widget.name === "app")).not.toHaveProperty("frameUrl"); + expect(first.widgets.find((widget) => widget.name === "rejected")).not.toHaveProperty( + "frameUrl", + ); + + const secondResponse = await invoke("board.get", { sessionKey: "agent:main:main" }); + const second = secondResponse.mock.calls[0]?.[1] as BoardSnapshot; + expect(second.widgets.find((widget) => widget.name === "status")?.frameUrl).not.toBe( + htmlFrameUrl, + ); + }); + + it("applies updates and broadcasts board.changed", async () => { + const { invoke, broadcast } = createHarness(); + const response = await invoke("board.update", { + sessionKey: "session", + ops: [{ kind: "tab_create", tabId: "notes", title: "Notes" }], + }); + expect(response).toHaveBeenCalledWith( + true, + expect.objectContaining({ sessionKey: "session", revision: 1 }), + ); + expect(broadcast).toHaveBeenCalledWith("board.changed", { + sessionKey: "session", + revision: 1, + }); + }); + + it("puts widgets, emits iframe-specific changes, and grants declared capabilities", async () => { + const { invoke, broadcast } = createHarness(); + const put = await invoke("board.widget.put", { + sessionKey: "session", + name: "weather", + content: { kind: "html", html: "

weather

" }, + declared: { tools: ["weather.refresh"] }, + }); + expect(put).toHaveBeenCalledWith( + true, + expect.objectContaining({ + widgets: [expect.objectContaining({ name: "weather", grantState: "pending" })], + }), + ); + expect(put.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + widgets: [expect.objectContaining({ declaredSummary: ["Tool access: weather.refresh"] })], + }), + ); + expect(broadcast).toHaveBeenCalledWith("board.changed", { + sessionKey: "session", + revision: 1, + widget: "weather", + }); + + const grant = await invoke("board.widget.grant", { + sessionKey: "session", + name: "weather", + decision: "granted", + revision: 1, + }); + expect(grant).toHaveBeenCalledWith( + true, + expect.objectContaining({ + revision: 2, + widgets: [expect.objectContaining({ grantState: "granted" })], + }), + ); + expect(broadcast).toHaveBeenLastCalledWith("board.changed", { + sessionKey: "session", + revision: 2, + }); + }); + + it("supports rejected grants and rejects grants from non-pending state", async () => { + const { invoke } = createHarness(); + await invoke("board.widget.put", { + sessionKey: "session", + name: "widget", + content: { kind: "html", html: "ok" }, + declared: { netOrigins: ["https://example.com"] }, + }); + const rejected = await invoke("board.widget.grant", { + sessionKey: "session", + name: "widget", + decision: "rejected", + revision: 1, + }); + expect(rejected.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + widgets: [expect.objectContaining({ grantState: "rejected" })], + }), + ); + const repeated = await invoke("board.widget.grant", { + sessionKey: "session", + name: "widget", + decision: "granted", + revision: 1, + }); + expect(repeated.mock.calls[0]?.[0]).toBe(false); + }); + + it("rejects stale grant revisions without changing the pending widget", async () => { + const { invoke } = createHarness(); + await invoke("board.widget.put", { + sessionKey: "session", + name: "widget", + content: { kind: "html", html: "ok" }, + declared: { tools: ["widget.read"] }, + }); + const stale = await invoke("board.widget.grant", { + sessionKey: "session", + name: "widget", + decision: "granted", + revision: 2, + }); + expect(stale.mock.calls[0]?.[0]).toBe(false); + const current = await invoke("board.get", { sessionKey: "session" }); + expect(current.mock.calls[0]?.[1]).toMatchObject({ + widgets: [{ name: "widget", revision: 1, grantState: "pending" }], + }); + }); + + it("appends bounded dashboard notices and coalesces duplicate bursts", async () => { + const { invoke } = createHarness(); + await invoke("board.widget.put", { + sessionKey: "session", + name: "counter", + content: { kind: "html", html: "ok" }, + }); + const first = await invoke("board.event", { + sessionKey: "session", + widget: "counter", + payload: { count: 1 }, + }); + const duplicate = await invoke("board.event", { + sessionKey: "session", + widget: "counter", + payload: { count: 1 }, + }); + expect(first.mock.calls[0]?.[1]).toEqual({ ok: true, appended: true }); + expect(duplicate.mock.calls[0]?.[1]).toEqual({ ok: true, appended: false }); + expect(peekSystemEvents("session")).toEqual(['[dashboard] {"count":1} on widget counter']); + }); + + it("caps board.event payloads at 8KB and notices at 500 characters", async () => { + const { invoke } = createHarness(); + await invoke("board.widget.put", { + sessionKey: "session", + name: "counter", + content: { kind: "html", html: "ok" }, + }); + await invoke("board.event", { + sessionKey: "session", + widget: "counter", + payload: "x".repeat(1_000), + }); + expect(peekSystemEvents("session")[0]).toHaveLength(500); + const oversized = await invoke("board.event", { + sessionKey: "session", + widget: "counter", + payload: "x".repeat(8_193), + }); + expect(oversized.mock.calls[0]?.[0]).toBe(false); + }); + + it("keeps board state across the real sessions.reset handler", async () => { + const sessionKey = "agent:main:board-reset-proof"; + const stateDir = tempDirs.make("openclaw-board-reset-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + replaceSessionEntrySync( + { agentId: "main", sessionKey, storePath: database.path }, + { sessionId: "board-reset-proof", updatedAt: Date.now() }, + ); + const boardStore = new SqliteBoardStore({ + resolveSession: () => ({ agentId: "main", sessionKey }), + env, + }); + boardStore.putWidget({ + sessionKey, + name: "status", + content: { kind: "html", html: "ok" }, + }); + const respond = vi.fn(); + await sessionMutationHandlers["sessions.reset"]!({ + req: { type: "req", id: "reset", method: "sessions.reset", params: {} }, + params: { key: sessionKey, reason: "reset" }, + client: null, + isWebchatConnect: () => false, + respond, + context: { + broadcast: vi.fn(), + getSessionEventSubscriberConnIds: () => new Set(), + } as unknown as GatewayRequestContext, + }); + expect(respond.mock.calls[0]?.[0]).toBe(true); + expect(boardStore.getSnapshot(sessionKey).widgets).toHaveLength(1); + }); +}); diff --git a/src/gateway/server-methods/board.ts b/src/gateway/server-methods/board.ts new file mode 100644 index 000000000000..0f25ae3e851c --- /dev/null +++ b/src/gateway/server-methods/board.ts @@ -0,0 +1,170 @@ +import { + ErrorCodes, + errorShape, + formatValidationErrors, + type BoardEventParams, + type BoardUpdateParams, + type BoardWidgetGrantParams, + type BoardWidgetPutParams, + validateBoardEventParams, + validateBoardGetParams, + validateBoardUpdateParams, + validateBoardWidgetGrantParams, + validateBoardWidgetPutParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { BoardValidationError } from "../../boards/board-layout.js"; +import { appendBoardEventNotice, BoardEventPayloadError } from "../../boards/board-notices.js"; +import type { BoardStore } from "../../boards/board-store.js"; +import { boardStore } from "../board-store.js"; +import { buildBoardWidgetFrameUrl, createBoardViewTicket } from "../board-view-ticket.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +type NoticeAppender = typeof appendBoardEventNotice; + +function invalidParams( + method: string, + errors: unknown, + respond: Parameters[0]["respond"], +): void { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid ${method} params: ${formatValidationErrors(errors as never)}`, + ), + ); +} + +function respondBoardError( + error: unknown, + respond: Parameters[0]["respond"], +): void { + if (error instanceof BoardValidationError || error instanceof BoardEventPayloadError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); + return; + } + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(error))); +} + +export function createBoardHandlers( + store: BoardStore, + appendNotice: NoticeAppender = appendBoardEventNotice, +): GatewayRequestHandlers { + return { + "board.get": ({ params, respond }) => { + if (!validateBoardGetParams(params)) { + invalidParams("board.get", validateBoardGetParams.errors, respond); + return; + } + const snapshot = store.getSnapshot(params.sessionKey); + for (const widget of snapshot.widgets) { + if (widget.grantState !== "none" && widget.grantState !== "granted") { + continue; + } + const document = store.readWidgetHtml(snapshot.sessionKey, widget.name); + if (!document || !("html" in document) || document.revision !== widget.revision) { + continue; + } + const { ticket } = createBoardViewTicket({ + sessionKey: snapshot.sessionKey, + name: widget.name, + revision: widget.revision, + viewGeneration: document.viewGeneration, + }); + widget.frameUrl = buildBoardWidgetFrameUrl({ + sessionKey: snapshot.sessionKey, + name: widget.name, + ticket, + }); + } + respond(true, snapshot); + }, + "board.update": ({ params, respond, context }) => { + if (!validateBoardUpdateParams(params)) { + invalidParams("board.update", validateBoardUpdateParams.errors, respond); + return; + } + try { + const boardParams = params as BoardUpdateParams; + const snapshot = store.applyOps(boardParams.sessionKey, boardParams.ops); + if (boardParams.ops.length > 0) { + context.broadcast("board.changed", { + sessionKey: snapshot.sessionKey, + revision: snapshot.revision, + }); + } + respond(true, snapshot); + } catch (error) { + respondBoardError(error, respond); + } + }, + "board.widget.put": ({ params, respond, context }) => { + if (!validateBoardWidgetPutParams(params)) { + invalidParams("board.widget.put", validateBoardWidgetPutParams.errors, respond); + return; + } + try { + const boardParams = params as BoardWidgetPutParams; + const snapshot = store.putWidget(boardParams); + context.broadcast("board.changed", { + sessionKey: snapshot.sessionKey, + revision: snapshot.revision, + widget: boardParams.name, + }); + respond(true, snapshot); + } catch (error) { + respondBoardError(error, respond); + } + }, + "board.widget.grant": ({ params, respond, context }) => { + if (!validateBoardWidgetGrantParams(params)) { + invalidParams("board.widget.grant", validateBoardWidgetGrantParams.errors, respond); + return; + } + try { + const boardParams = params as BoardWidgetGrantParams; + const snapshot = store.grant( + boardParams.sessionKey, + boardParams.name, + boardParams.decision, + boardParams.revision, + ); + context.broadcast("board.changed", { + sessionKey: snapshot.sessionKey, + revision: snapshot.revision, + }); + respond(true, snapshot); + } catch (error) { + respondBoardError(error, respond); + } + }, + "board.event": ({ params, respond }) => { + if (!validateBoardEventParams(params)) { + invalidParams("board.event", validateBoardEventParams.errors, respond); + return; + } + try { + const boardParams = params as BoardEventParams; + const snapshot = store.getSnapshot(boardParams.sessionKey); + const widget = snapshot.widgets.some((candidate) => candidate.name === boardParams.widget); + if (!widget) { + throw new BoardValidationError( + "not_found", + `board widget not found: ${boardParams.widget}`, + ); + } + const appended = appendNotice({ + sessionKey: snapshot.sessionKey, + widget: boardParams.widget, + payload: boardParams.payload, + }); + respond(true, { ok: true, appended }); + } catch (error) { + respondBoardError(error, respond); + } + }, + }; +} + +export const boardHandlers = createBoardHandlers(boardStore); diff --git a/src/gateway/server.sessions.delete-board.test.ts b/src/gateway/server.sessions.delete-board.test.ts new file mode 100644 index 000000000000..852c642ce416 --- /dev/null +++ b/src/gateway/server.sessions.delete-board.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, test } from "vitest"; +import { SqliteBoardStore } from "../boards/sqlite-board-store.js"; +import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { testState, writeSessionStore } from "./test-helpers.js"; +import { + directSessionReq, + sessionStoreEntry, + setupGatewaySessionsTestHarness, + writeSingleLineSession, +} from "./test/server-sessions.test-helpers.js"; + +const { createSessionStoreDir } = setupGatewaySessionsTestHarness(); + +afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); +}); + +test("sessions.delete removes the session board from its agent database", async () => { + const { dir } = await createSessionStoreDir(); + await writeSingleLineSession(dir, "sess-board", "hello"); + await writeSessionStore({ + entries: { + "discord:group:board-delete": sessionStoreEntry("sess-board"), + }, + }); + const sessionKey = "agent:main:discord:group:board-delete"; + if (!testState.sessionStorePath) { + throw new Error("expected gateway session store path"); + } + const databasePath = resolveSqliteTargetFromSessionStorePath(testState.sessionStorePath, { + agentId: "main", + }).path; + if (!databasePath) { + throw new Error("expected gateway agent database path"); + } + const store = new SqliteBoardStore({ + resolveSession: () => ({ + agentId: "main", + path: databasePath, + sessionKey, + }), + env: process.env, + }); + store.putWidget({ + sessionKey, + name: "status", + content: { kind: "html", html: "ok" }, + }); + + const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { + key: "discord:group:board-delete", + }); + + expect(deleted.ok).toBe(true); + expect(deleted.payload?.deleted).toBe(true); + expect(store.getSnapshot(sessionKey)).toEqual({ + sessionKey, + revision: 0, + tabs: [], + widgets: [], + }); +}); diff --git a/src/state/openclaw-agent-board-schema.ts b/src/state/openclaw-agent-board-schema.ts new file mode 100644 index 000000000000..4ea041c8a1e1 --- /dev/null +++ b/src/state/openclaw-agent-board-schema.ts @@ -0,0 +1,21 @@ +import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js"; + +const BOARD_SCHEMA_START = "CREATE TABLE IF NOT EXISTS board_tabs ("; +const BOARD_SCHEMA_END = "CREATE TABLE IF NOT EXISTS heartbeat_outcomes ("; + +function splitBoardSchema(sql: string): { board: string; withoutBoard: string } { + const start = sql.indexOf(BOARD_SCHEMA_START); + const end = sql.indexOf(BOARD_SCHEMA_END, start); + if (start === -1 || end === -1) { + throw new Error("OpenClaw agent board schema markers are missing from the canonical schema."); + } + return { + board: sql.slice(start, end), + withoutBoard: `${sql.slice(0, start)}${sql.slice(end)}`, + }; +} + +const boardSchema = splitBoardSchema(OPENCLAW_AGENT_SCHEMA_SQL); + +export const OPENCLAW_AGENT_BOARD_SCHEMA_SQL = boardSchema.board; +export const OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL = boardSchema.withoutBoard; diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index 7ffe45a32056..461532e4aa7e 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -13,6 +13,7 @@ import { configureSqlitePreSchemaPragmas } from "../infra/sqlite-wal.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { VERSION } from "../version.js"; +import { OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL } from "./openclaw-agent-board-schema.js"; import { OPENCLAW_AGENT_SCHEMA_VERSION, type OpenClawAgentDatabaseOptions, @@ -512,7 +513,11 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): dropLegacySessionTranscriptSearchSchema(db); migrateMemoryIndexSourcesIdentity(db); migrateOpenClawAgentSchema(db); - db.exec(OPENCLAW_AGENT_SCHEMA_SQL); + db.exec( + previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION + ? OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL + : OPENCLAW_AGENT_SCHEMA_SQL, + ); migrateSessionTranscriptGenerations(db, previousVersion); migrateConversationDeliveryTargetColumn(db); migrateSessionTranscriptActiveProjection(db, previousVersion); diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts index ad446a8e114b..d125b098dade 100644 --- a/src/state/openclaw-agent-db.generated.d.ts +++ b/src/state/openclaw-agent-db.generated.d.ts @@ -30,6 +30,38 @@ export interface AuthProfileStore { updated_at: number; } +export interface BoardTabs { + chat_dock: Generated; + created_by: string; + position: number; + revision: number; + session_key: string; + tab_id: string; + title: string; +} + +export interface BoardWidgets { + content_kind: string; + created_at: number; + created_by: string; + descriptor_json: string | null; + grant_state: Generated; + granted_sha: string | null; + html: Uint8Array | null; + manifest: Generated; + name: string; + position: number; + revision: number; + session_key: string; + sha256: string; + size_h: number; + size_w: number; + tab_id: string; + title: string | null; + updated_at: number; + view_generation: string | null; +} + export interface CacheEntries { blob: Uint8Array | null; expires_at: number | null; @@ -295,6 +327,8 @@ export interface DB { acp_parent_stream_events: AcpParentStreamEvents; auth_profile_state: AuthProfileState; auth_profile_store: AuthProfileStore; + board_tabs: BoardTabs; + board_widgets: BoardWidgets; cache_entries: CacheEntries; conversation_deliveries: ConversationDeliveries; conversations: Conversations; diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index c2ae9d593020..c2cb32f78c08 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -568,6 +568,41 @@ describe("openclaw agent database", () => { expect(registered?.sizeBytes).toBeGreaterThan(0); }); + it("opens a v13 database that already contains additive board storage", () => { + expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(13); + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const opened = openOpenClawAgentDatabase({ agentId: "worker-1", env }); + const databasePath = opened.path; + closeOpenClawAgentDatabasesForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const existingV13 = new DatabaseSync(databasePath); + existingV13.exec(` + PRAGMA user_version = 13; + UPDATE schema_meta SET schema_version = 13 WHERE meta_key = 'primary'; + `); + existingV13.close(); + + const reopened = openOpenClawAgentDatabase({ agentId: "worker-1", env }); + expect( + reopened.db + .prepare( + "SELECT name, strict FROM pragma_table_list WHERE name IN ('board_tabs', 'board_widgets') ORDER BY name", + ) + .all(), + ).toEqual([ + { name: "board_tabs", strict: 1 }, + { name: "board_widgets", strict: 1 }, + ]); + expect(readSqliteNumberPragma(reopened.db, "user_version")).toBe(OPENCLAW_AGENT_SCHEMA_VERSION); + expect( + reopened.db + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'") + .get(), + ).toEqual({ schema_version: OPENCLAW_AGENT_SCHEMA_VERSION }); + }); + it("keeps additive heartbeat repair while upgrading schema version 12", () => { expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(13); const stateDir = createTempStateDir(); diff --git a/src/state/openclaw-agent-schema.generated.ts b/src/state/openclaw-agent-schema.generated.ts index a34751c189f6..f8e67d23e106 100644 --- a/src/state/openclaw-agent-schema.generated.ts +++ b/src/state/openclaw-agent-schema.generated.ts @@ -182,6 +182,48 @@ CREATE INDEX IF NOT EXISTS idx_agent_session_entries_status ON session_entries(status, session_key) WHERE status IS NOT NULL; +CREATE TABLE IF NOT EXISTS board_tabs ( + session_key TEXT NOT NULL, + tab_id TEXT NOT NULL, + title TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + chat_dock TEXT NOT NULL DEFAULT 'right' CHECK (chat_dock IN ('left', 'right', 'bottom', 'hidden')), + created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')), + revision INTEGER NOT NULL CHECK (revision >= 0), + PRIMARY KEY (session_key, tab_id) +) STRICT; + +CREATE TABLE IF NOT EXISTS board_widgets ( + session_key TEXT NOT NULL, + name TEXT NOT NULL, + tab_id TEXT NOT NULL, + title TEXT, + content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app')), + html BLOB, + descriptor_json TEXT, + sha256 TEXT NOT NULL, + view_generation TEXT, + revision INTEGER NOT NULL CHECK (revision >= 1), + size_w INTEGER NOT NULL CHECK (size_w BETWEEN 1 AND 12), + size_h INTEGER NOT NULL CHECK (size_h BETWEEN 1 AND 20), + position INTEGER NOT NULL CHECK (position >= 0), + manifest TEXT NOT NULL DEFAULT '{}', + grant_state TEXT NOT NULL DEFAULT 'none' CHECK (grant_state IN ('none', 'pending', 'granted', 'rejected')), + granted_sha TEXT, + created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_key, name), + FOREIGN KEY (session_key, tab_id) REFERENCES board_tabs(session_key, tab_id) ON DELETE CASCADE, + CHECK ( + (content_kind = 'html' AND html IS NOT NULL AND descriptor_json IS NULL AND view_generation IS NOT NULL) OR + (content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL) + ) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_agent_board_widgets_tab_position + ON board_widgets(session_key, tab_id, position); + CREATE TABLE IF NOT EXISTS heartbeat_outcomes ( session_key TEXT NOT NULL PRIMARY KEY, run_session_key TEXT NOT NULL, diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index 5454bde43314..ca041ae3ad8e 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -177,6 +177,48 @@ CREATE INDEX IF NOT EXISTS idx_agent_session_entries_status ON session_entries(status, session_key) WHERE status IS NOT NULL; +CREATE TABLE IF NOT EXISTS board_tabs ( + session_key TEXT NOT NULL, + tab_id TEXT NOT NULL, + title TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + chat_dock TEXT NOT NULL DEFAULT 'right' CHECK (chat_dock IN ('left', 'right', 'bottom', 'hidden')), + created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')), + revision INTEGER NOT NULL CHECK (revision >= 0), + PRIMARY KEY (session_key, tab_id) +) STRICT; + +CREATE TABLE IF NOT EXISTS board_widgets ( + session_key TEXT NOT NULL, + name TEXT NOT NULL, + tab_id TEXT NOT NULL, + title TEXT, + content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app')), + html BLOB, + descriptor_json TEXT, + sha256 TEXT NOT NULL, + view_generation TEXT, + revision INTEGER NOT NULL CHECK (revision >= 1), + size_w INTEGER NOT NULL CHECK (size_w BETWEEN 1 AND 12), + size_h INTEGER NOT NULL CHECK (size_h BETWEEN 1 AND 20), + position INTEGER NOT NULL CHECK (position >= 0), + manifest TEXT NOT NULL DEFAULT '{}', + grant_state TEXT NOT NULL DEFAULT 'none' CHECK (grant_state IN ('none', 'pending', 'granted', 'rejected')), + granted_sha TEXT, + created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_key, name), + FOREIGN KEY (session_key, tab_id) REFERENCES board_tabs(session_key, tab_id) ON DELETE CASCADE, + CHECK ( + (content_kind = 'html' AND html IS NOT NULL AND descriptor_json IS NULL AND view_generation IS NOT NULL) OR + (content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL) + ) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_agent_board_widgets_tab_position + ON board_widgets(session_key, tab_id, position); + CREATE TABLE IF NOT EXISTS heartbeat_outcomes ( session_key TEXT NOT NULL PRIMARY KEY, run_session_key TEXT NOT NULL,