feat(gateway): session dashboard domain — board RPCs, ticketed widgets, dashboard tool, per-agent persistence (#110960)

* feat(gateway): add session dashboard domain

* test(gateway): cover board event guards

* fix(gateway): authorize board widgets with view tickets

* feat(boards): persist dashboards in agent databases

* test(boards): use tracked SQLite temp directories

* test(gateway): isolate board deletion lifecycle coverage

* fix(boards): harden widget view ticket bindings

* fix(boards): lazily ensure additive board schema

* fix(gateway): complete dashboard integration metadata

* fix(boards): satisfy generated integration checks

* fix(boards): align persistence with repository guards

* fix(boards): retain notice test reset

* fix(protocol): generate dashboard union models

* fix(boards): harden dashboard persistence and serving

* fix(boards): satisfy dashboard CI guardrails
This commit is contained in:
Peter Steinberger
2026-07-18 20:17:51 -07:00
committed by GitHub
parent ed546bdcf5
commit 2f00a417ed
66 changed files with 4839 additions and 1 deletions

View File

@@ -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"),

View File

@@ -417,6 +417,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
"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,

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تفاصيل الجدول"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إمكانات الهاتف"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"غير متاح"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لصق الرمز المميز"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لا يوجد موفرون"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ميكروفون Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zeitplandetails"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonfunktionen"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nicht verfügbar"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Token einfügen"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Keine Anbieter"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-Mikrofon"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detalles de la programación"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Capacidades del teléfono"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No disponible"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pegar token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No hay proveedores"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Micrófono Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جزئیات زمان‌بندی"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"قابلیت‌های تلفن"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در دسترس نیست"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"چسباندن توکن"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ارائه‌دهنده‌ای وجود ندارد"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"میکروفون Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Détails de la planification"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fonctionnalités du téléphone"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Non disponible"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Coller le jeton"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aucun fournisseur"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microphone Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"शेड्यूल का विवरण"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन की क्षमताएँ"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"उपलब्ध नहीं"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"टोकन पेस्ट करें"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"कोई प्रदाता नहीं"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth माइक्रोफ़ोन"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detail Jadwal"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kemampuan Ponsel"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak tersedia"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tempel token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak ada penyedia"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dettagli pianificazione"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Funzionalità del telefono"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Non disponibile"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Incolla token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nessun provider"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microfono Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スケジュールの詳細"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"電話の機能"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"利用できません"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"トークンを貼り付け"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"プロバイダーがありません"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetoothマイク"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"일정 상세 정보"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화 기능"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"사용할 수 없음"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"토큰 붙여넣기"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"제공자 없음"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth 마이크"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Planningsdetails"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefoonmogelijkheden"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Niet beschikbaar"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Token plakken"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Geen providers"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-microfoon"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Szczegóły harmonogramu"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Funkcje telefonu"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Niedostępne"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wklej token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Brak dostawców"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detalhes do agendamento"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Recursos do telefone"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Não disponível"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Colar token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nenhum provedor"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microfone Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сведения о расписании"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Возможности телефона"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Недоступно"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вставить токен"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Нет провайдеров"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-микрофон"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Schemadetaljer"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonfunktioner"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inte tillgänglig"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Klistra in token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inga leverantörer"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-mikrofon"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รายละเอียดกำหนดการ"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ความสามารถของโทรศัพท์"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่พร้อมใช้งาน"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"วางโทเค็น"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่มีผู้ให้บริการ"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไมโครโฟน Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zamanlama Ayrıntısı"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon Özellikleri"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kullanılamıyor"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Token\'ı yapıştır"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sağlayıcı yok"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth mikrofonu"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відомості про розклад"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Можливості телефона"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Недоступно"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вставити токен"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Немає постачальників"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-мікрофон"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chi tiết lịch trình"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Khả năng của điện thoại"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không khả dụng"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dán token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không có nhà cung cấp"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Micrô Bluetooth"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"计划详情"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手机功能"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"不可用"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"粘贴令牌"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"无提供商"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"蓝牙麦克风"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"排程詳細資料"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手機功能"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"無法使用"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"貼上 token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"沒有提供者"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth 麥克風"</string>

View File

@@ -613,6 +613,7 @@
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Schedule Detail"</string>
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Phone Capabilities"</string>
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Not available"</string>
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Paste token"</string>
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No providers"</string>
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth microphone"</string>

View File

@@ -274,6 +274,16 @@
}
}
},
"dashboard": {
"emoji": "📋",
"title": "Dashboard",
"detailKeys": [
"action",
"tabId",
"name",
"title"
]
},
"nodes": {
"emoji": "📱",
"title": "Nodes",

View File

@@ -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)

View File

@@ -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: {
".": {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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<typeof BoardTabSchema>;
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<typeof BoardWidgetSchema>;
export const BoardSnapshotSchema = closedObject({
sessionKey: NonEmptyString,
revision: Type.Integer({ minimum: 0 }),
tabs: Type.Array(BoardTabSchema),
widgets: Type.Array(BoardWidgetSchema),
});
export type BoardSnapshot = Static<typeof BoardSnapshotSchema>;
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<typeof BoardOpSchema>;
export const BoardGetParamsSchema = closedObject({ sessionKey: NonEmptyString });
export type BoardGetParams = Static<typeof BoardGetParamsSchema>;
export const BoardUpdateParamsSchema = closedObject({
sessionKey: NonEmptyString,
ops: Type.Array(BoardOpSchema),
});
export type BoardUpdateParams = Static<typeof BoardUpdateParamsSchema>;
export const BoardMcpAppDescriptorSchema = closedObject({
serverName: NonEmptyString,
toolName: NonEmptyString,
uiResourceUri: NonEmptyString,
originSessionKey: NonEmptyString,
toolCallId: NonEmptyString,
});
export type BoardMcpAppDescriptor = Static<typeof BoardMcpAppDescriptorSchema>;
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<typeof BoardWidgetContentSchema>;
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<typeof BoardWidgetPutParamsSchema>;
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<typeof BoardWidgetGrantParamsSchema>;
export const BoardEventParamsSchema = closedObject({
sessionKey: NonEmptyString,
widget: BoardWidgetNameSchema,
payload: Type.Unknown(),
});
export type BoardEventParams = Static<typeof BoardEventParamsSchema>;
export const BoardChangedEventSchema = closedObject({
sessionKey: NonEmptyString,
revision: Type.Integer({ minimum: 0 }),
widget: Type.Optional(BoardWidgetNameSchema),
});
export type BoardChangedEvent = Static<typeof BoardChangedEventSchema>;
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<typeof BoardCommandSchema>;
export const BoardCommandEventSchema = closedObject({
sessionKey: NonEmptyString,
command: BoardCommandSchema,
});
export type BoardCommandEvent = Static<typeof BoardCommandEventSchema>;

View File

@@ -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,

View File

@@ -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" },

View File

@@ -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
? []
: [

View File

@@ -52,6 +52,7 @@ describe("tool-catalog", () => {
"spawn_task",
"dismiss_task",
"screen",
"dashboard",
"terminal",
"cron",
"get_goal",

View File

@@ -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",

View File

@@ -196,6 +196,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
},
},
},
dashboard: {
emoji: "📋",
title: "Dashboard",
detailKeys: ["action", "tabId", "name", "title"],
},
nodes: {
emoji: "📱",
title: "Nodes",

View File

@@ -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<string, unknown>]> = [];
const commands: Array<{ sessionKey: string; command: BoardCommand }> = [];
const callGateway: InProcessGatewayCaller = async <T>(
method: string,
params: Record<string, unknown>,
): Promise<T> => {
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 });
});
});

View File

@@ -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<string>;
broadcastToConnIds: (event: "board.command", payload: unknown, connIds: Set<string>) => 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<string, unknown>,
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<string, unknown>, 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, unknown>): 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<string, unknown>): 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<string>();
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<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const sessionKey = requireSessionKey(opts.agentSessionKey);
if (action === "read") {
return snapshotResult(await gatewayCall<BoardSnapshot>("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<BoardSnapshot>("board.update", {
sessionKey,
ops: [opForAction(action, params)],
}),
);
},
};
}

View File

@@ -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");
});
});

285
src/boards/board-layout.ts Normal file
View File

@@ -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<BoardSnapshot, "tabs" | "widgets">;
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<string, number>();
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);
}

View File

@@ -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<string, { summary: string; at: number }>();
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();
}

View File

@@ -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: "<p>one</p>" },
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: "<p>one</p>",
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: "<p>two</p>" },
});
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"]);
});
});

View File

@@ -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 = "<p>one</p>") {
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", "<p>two</p>");
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", "<main>ok</main>");
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: "<main>ok</main>",
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: "<p>ok</p>" },
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", "<p>updated</p>").widgets.map((widget) => widget.name),
).toEqual(["first", "second", "third"]);
expect(
store
.putWidget({
sessionKey: "session",
name: "first",
content: { kind: "html", html: "<p>moved</p>" },
placement: { after: "third" },
})
.widgets.map((widget) => widget.name),
).toEqual(["second", "third", "first"]);
});
});

279
src/boards/board-store.ts Normal file
View File

@@ -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<string, BoardWidgetDocument>;
};
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<string, StoredBoard>();
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();
}
}

View File

@@ -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<OpenClawAgentDatabase, "db" | "path">;
type SelectedBoardTabRow = Selectable<BoardTabRow>;
type SelectedBoardWidgetRow = Selectable<BoardWidgetRow>;
type StoredBoard = {
snapshot: BoardSnapshot;
tabRows: SelectedBoardTabRow[];
widgetRows: SelectedBoardWidgetRow[];
};
const ensuredBoardDatabases = new WeakSet<DatabaseSync>();
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<BoardDatabase>(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<BoardDatabase>(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<BoardDatabase>(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<BoardDatabase>(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<BoardDatabase>(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<BoardDatabase>(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<BoardDatabase>(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<BoardDatabase>(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<BoardDatabase>(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<string>();
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<BoardDatabase>(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();
}
}

View File

@@ -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<SessionBoardCleanupDatabase>(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<SessionLifecycleArtifactCleanupResult> {
@@ -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,

View File

@@ -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: "<!doctype html><p>Status</p>" },
});
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: "<p>one</p>" },
});
server = createServer((req, res) => {
const handled = handleBoardHttpRequest(req, res, {
store,
nowMs,
});
if (!handled) {
res.statusCode = 404;
res.end("unhandled");
}
});
await new Promise<void>((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<void>((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<string, string>; 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("<!doctype html><p>Status</p>");
});
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: "<p>two</p>" },
});
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("<p>two</p>");
});
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: "<p>old</p>" },
});
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: "<p>old</p>" },
});
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");
});
});

85
src/gateway/board-http.ts Normal file
View File

@@ -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;
}

View File

@@ -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,
};
},
});

View File

@@ -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<BoardViewTicketClaims>;
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)}`;
}

View File

@@ -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" },

View File

@@ -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<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
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"]);
});
});

View File

@@ -33,6 +33,8 @@ import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js";
const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
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],

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<string, unknown>) => {
const respond = vi.fn<RespondFn>();
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: "<p>ok</p>" },
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: "<p>no</p>" },
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: "<p>weather</p>" },
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<RespondFn>();
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<string>(),
} as unknown as GatewayRequestContext,
});
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(boardStore.getSnapshot(sessionKey).widgets).toHaveLength(1);
});
});

View File

@@ -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<GatewayRequestHandlers[string]>[0]["respond"],
): void {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid ${method} params: ${formatValidationErrors(errors as never)}`,
),
);
}
function respondBoardError(
error: unknown,
respond: Parameters<GatewayRequestHandlers[string]>[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);

View File

@@ -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: [],
});
});

View File

@@ -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;

View File

@@ -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);

View File

@@ -30,6 +30,38 @@ export interface AuthProfileStore {
updated_at: number;
}
export interface BoardTabs {
chat_dock: Generated<string>;
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<string>;
granted_sha: string | null;
html: Uint8Array | null;
manifest: Generated<string>;
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;

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,