diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index 9325aba5e..e42a1d99f 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -21,7 +21,7 @@ const enUS = {
create_knowledge_base: 'Create knowledge base',
get_pipeline: 'Read Pipeline',
get_knowledge_schema: 'Read knowledge engine schema',
- list_operation_logs: 'Read operation trace',
+ list_operation_logs: 'Read operation logs',
},
resources: {
models: 'Find chat models',
@@ -2713,7 +2713,7 @@ const enUS = {
title: 'Settings',
nav: {
workspace: 'Workspace',
- operationTrace: 'Traceability',
+ operationTrace: 'Operation Log',
models: 'Models',
api: 'API',
storage: 'Storage',
@@ -2721,21 +2721,21 @@ const enUS = {
},
},
operationTrace: {
- title: 'Operation traceability',
+ title: 'Operation Log',
description: 'Trace admin and owner edits and views, and what changed.',
+ reset: 'Reset',
+ settingsUpdated: 'Settings saved',
+ settingsUpdateFailed: 'Failed to save settings',
+ levelBadge: 'Level: {{level}}',
+ collectionSettings: 'Collection settings',
captureLevel: 'Capture level',
retention: 'Retention',
- retentionSummary: 'Keep {{days}} days · up to {{rows}} records',
retentionDays: 'Retention days',
maxRows: 'Max records',
dedupeWindow: 'Dedupe window (s)',
records: 'Operation records',
changesCount: '{{count}} change(s)',
hideDetails: 'Hide details',
- levelUpdated: 'Capture level updated',
- levelUpdateFailed: 'Failed to update capture level',
- retentionUpdated: 'Retention updated',
- retentionUpdateFailed: 'Failed to update retention',
export: 'Download logs',
exportFailed: 'Failed to download operation logs',
empty: 'No operation records yet',
@@ -2749,7 +2749,7 @@ const enUS = {
filterAllResources: 'All resources',
filterActor: 'Actor',
filterAllActors: 'All actors',
- loadFailed: 'Failed to load traceability data',
+ loadFailed: 'Failed to load operation logs',
refresh: 'Refresh',
save: 'Save',
redacted: 'Redacted',
@@ -2768,7 +2768,7 @@ const enUS = {
elidedValue: '{{count}} chars',
levels: {
off: {
- label: 'Tracing off',
+ label: 'Logging off',
},
mutation: {
label: 'Mutations only',
diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts
index c444e4d0b..b641f221a 100644
--- a/web/src/i18n/locales/es-ES.ts
+++ b/web/src/i18n/locales/es-ES.ts
@@ -21,7 +21,7 @@ const esES = {
create_knowledge_base: 'Crear base de conocimiento',
get_pipeline: 'Consultar Pipeline',
get_knowledge_schema: 'Consultar el esquema del motor de conocimiento',
- list_operation_logs: 'Consultar la trazabilidad de operaciones',
+ list_operation_logs: 'Consultar el registro de operaciones',
},
resources: {
models: 'Buscar modelos de chat',
@@ -2768,7 +2768,7 @@ const esES = {
title: 'Configuración',
nav: {
workspace: 'Workspace',
- operationTrace: 'Trazabilidad',
+ operationTrace: 'Registro',
models: 'Modelos',
api: 'API',
storage: 'Almacenamiento',
@@ -2776,21 +2776,21 @@ const esES = {
},
},
operationTrace: {
- title: 'Trazabilidad de operaciones',
+ title: 'Registro de operaciones',
description: 'Rastrea cambios y consultas de los administradores',
+ reset: 'Restablecer',
+ settingsUpdated: 'Configuración guardada',
+ settingsUpdateFailed: 'No se pudo guardar la configuración',
+ levelBadge: 'Nivel: {{level}}',
+ collectionSettings: 'Configuración de recopilación',
captureLevel: 'Nivel de captura',
retention: 'Retención',
- retentionSummary: 'Conservar {{days}} días · hasta {{rows}} registros',
retentionDays: 'Días de retención',
maxRows: 'Registros máximos',
dedupeWindow: 'Ventana de deduplicación (s)',
records: 'Registros de operación',
changesCount: '{{count}} cambio(s)',
hideDetails: 'Ocultar detalles',
- levelUpdated: 'Nivel de captura actualizado',
- levelUpdateFailed: 'No se pudo actualizar el nivel de captura',
- retentionUpdated: 'Retención actualizada',
- retentionUpdateFailed: 'No se pudo actualizar la retención',
export: 'Descargar registros',
exportFailed: 'No se pudieron descargar los registros',
empty: 'Aún no hay registros de operación',
@@ -2804,7 +2804,7 @@ const esES = {
filterAllResources: 'Todos los recursos',
filterActor: 'Actor',
filterAllActors: 'Todos los actores',
- loadFailed: 'No se pudieron cargar los datos de trazabilidad',
+ loadFailed: 'No se pudieron cargar los registros de operaciones',
refresh: 'Actualizar',
save: 'Guardar',
redacted: 'Oculto',
@@ -2823,7 +2823,7 @@ const esES = {
elidedValue: '{{count}} caracteres',
levels: {
off: {
- label: 'Sin trazabilidad',
+ label: 'Sin registro',
},
mutation: {
label: 'Solo cambios',
diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts
index 71f6099af..e2d123bf1 100644
--- a/web/src/i18n/locales/ja-JP.ts
+++ b/web/src/i18n/locales/ja-JP.ts
@@ -21,7 +21,7 @@ const jaJP = {
create_knowledge_base: 'ナレッジベース作成',
get_pipeline: 'Pipeline 参照',
get_knowledge_schema: 'エンジン設定の参照',
- list_operation_logs: '操作トレーサビリティの参照',
+ list_operation_logs: '操作ログの参照',
},
resources: {
models: 'チャットモデル検索',
@@ -2731,7 +2731,7 @@ const jaJP = {
title: '設定',
nav: {
workspace: 'ワークスペース',
- operationTrace: '操作トレーサビリティ',
+ operationTrace: '操作ログ',
models: 'モデル',
api: 'API',
storage: 'ストレージ',
@@ -2739,21 +2739,21 @@ const jaJP = {
},
},
operationTrace: {
- title: '操作トレーサビリティ',
+ title: '操作ログ',
description: '管理者とオーナーの操作を追跡します。',
+ reset: 'リセット',
+ settingsUpdated: '設定を保存しました',
+ settingsUpdateFailed: '設定の保存に失敗しました',
+ levelBadge: 'レベル:{{level}}',
+ collectionSettings: '収集設定',
captureLevel: 'キャプチャレベル',
retention: '保持ポリシー',
- retentionSummary: '{{days}} 日間保持 · 最大 {{rows}} 件',
retentionDays: '保持日数',
maxRows: '最大レコード数',
dedupeWindow: '重複排除ウィンドウ(秒)',
records: '操作レコード',
changesCount: '変更 {{count}} 件',
hideDetails: '詳細を隠す',
- levelUpdated: 'キャプチャレベルを更新しました',
- levelUpdateFailed: 'キャプチャレベルの更新に失敗しました',
- retentionUpdated: '保持ポリシーを更新しました',
- retentionUpdateFailed: '保持ポリシーの更新に失敗しました',
export: 'ログをダウンロード',
exportFailed: '操作ログのダウンロードに失敗しました',
empty: '操作レコードはまだありません',
@@ -2767,7 +2767,7 @@ const jaJP = {
filterAllResources: 'すべてのリソース',
filterActor: '実行者',
filterAllActors: 'すべての実行者',
- loadFailed: 'トレーサビリティデータの読み込みに失敗しました',
+ loadFailed: '操作ログデータの読み込みに失敗しました',
refresh: '更新',
save: '保存',
redacted: 'マスク済み',
@@ -2786,7 +2786,7 @@ const jaJP = {
elidedValue: '{{count}} 文字',
levels: {
off: {
- label: 'トレース無効',
+ label: 'ログ無効',
},
mutation: {
label: '変更のみ',
diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts
index a5db8d81e..bebd8a4ce 100644
--- a/web/src/i18n/locales/ru-RU.ts
+++ b/web/src/i18n/locales/ru-RU.ts
@@ -21,7 +21,7 @@ const ruRU = {
create_knowledge_base: 'Создать базу знаний',
get_pipeline: 'Посмотреть Pipeline',
get_knowledge_schema: 'Посмотреть схему движка знаний',
- list_operation_logs: 'Просмотр трассировки операций',
+ list_operation_logs: 'Просмотр журнала операций',
},
resources: {
models: 'Найти чат-модели',
@@ -2738,7 +2738,7 @@ const ruRU = {
title: 'Настройки',
nav: {
workspace: 'Workspace',
- operationTrace: 'Трассировка',
+ operationTrace: 'Журнал',
models: 'Модели',
api: 'API',
storage: 'Хранилище',
@@ -2746,21 +2746,21 @@ const ruRU = {
},
},
operationTrace: {
- title: 'Трассировка операций',
+ title: 'Журнал операций',
description: 'Отслеживание изменений и просмотров администраторов',
+ reset: 'Сбросить',
+ settingsUpdated: 'Настройки сохранены',
+ settingsUpdateFailed: 'Не удалось сохранить настройки',
+ levelBadge: 'Уровень: {{level}}',
+ collectionSettings: 'Настройки сбора',
captureLevel: 'Уровень записи',
retention: 'Хранение',
- retentionSummary: 'Хранить {{days}} дн. · до {{rows}} записей',
retentionDays: 'Дней хранения',
maxRows: 'Максимум записей',
dedupeWindow: 'Окно дедупликации (с)',
records: 'Записи операций',
changesCount: 'Изменений: {{count}}',
hideDetails: 'Скрыть детали',
- levelUpdated: 'Уровень записи обновлён',
- levelUpdateFailed: 'Не удалось обновить уровень записи',
- retentionUpdated: 'Хранение обновлено',
- retentionUpdateFailed: 'Не удалось обновить хранение',
export: 'Скачать журнал',
exportFailed: 'Не удалось скачать журнал операций',
empty: 'Записей операций пока нет',
@@ -2774,7 +2774,7 @@ const ruRU = {
filterAllResources: 'Все ресурсы',
filterActor: 'Исполнитель',
filterAllActors: 'Все исполнители',
- loadFailed: 'Не удалось загрузить данные трассировки',
+ loadFailed: 'Не удалось загрузить журнал операций',
refresh: 'Обновить',
save: 'Сохранить',
redacted: 'Скрыто',
@@ -2793,7 +2793,7 @@ const ruRU = {
elidedValue: '{{count}} символов',
levels: {
off: {
- label: 'Трассировка выключена',
+ label: 'Журнал выключен',
},
mutation: {
label: 'Только изменения',
diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts
index 522ffabde..beedf3e89 100644
--- a/web/src/i18n/locales/th-TH.ts
+++ b/web/src/i18n/locales/th-TH.ts
@@ -21,7 +21,7 @@ const thTH = {
create_knowledge_base: 'สร้างฐานความรู้',
get_pipeline: 'ดู Pipeline',
get_knowledge_schema: 'ดูสคีมาของ knowledge engine',
- list_operation_logs: 'ดูการติดตามการดำเนินการ',
+ list_operation_logs: 'ดูบันทึกการดำเนินการ',
},
resources: {
models: 'ค้นหาโมเดลแชต',
@@ -2665,7 +2665,7 @@ const thTH = {
title: 'การตั้งค่า',
nav: {
workspace: 'Workspace',
- operationTrace: 'การติดตาม',
+ operationTrace: 'บันทึกการดำเนินการ',
models: 'โมเดล',
api: 'API',
storage: 'พื้นที่จัดเก็บ',
@@ -2673,21 +2673,21 @@ const thTH = {
},
},
operationTrace: {
- title: 'การติดตามการดำเนินการ',
+ title: 'บันทึกการดำเนินการ',
description: 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ',
+ reset: 'รีเซ็ต',
+ settingsUpdated: 'บันทึกการตั้งค่าแล้ว',
+ settingsUpdateFailed: 'บันทึกการตั้งค่าไม่สำเร็จ',
+ levelBadge: 'ระดับ: {{level}}',
+ collectionSettings: 'การตั้งค่าการเก็บข้อมูล',
captureLevel: 'ระดับการบันทึก',
retention: 'นโยบายการเก็บรักษา',
- retentionSummary: 'เก็บ {{days}} วัน · สูงสุด {{rows}} รายการ',
retentionDays: 'จำนวนวันเก็บรักษา',
maxRows: 'จำนวนบันทึกสูงสุด',
dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)',
records: 'บันทึกการดำเนินการ',
changesCount: '{{count}} การเปลี่ยนแปลง',
hideDetails: 'ซ่อนรายละเอียด',
- levelUpdated: 'อัปเดตระดับการบันทึกแล้ว',
- levelUpdateFailed: 'อัปเดตระดับการบันทึกไม่สำเร็จ',
- retentionUpdated: 'อัปเดตนโยบายการเก็บรักษาแล้ว',
- retentionUpdateFailed: 'อัปเดตนโยบายการเก็บรักษาไม่สำเร็จ',
export: 'ดาวน์โหลดบันทึก',
exportFailed: 'ดาวน์โหลดบันทึกการดำเนินการไม่สำเร็จ',
empty: 'ยังไม่มีบันทึกการดำเนินการ',
@@ -2701,7 +2701,7 @@ const thTH = {
filterAllResources: 'ทุกทรัพยากร',
filterActor: 'ผู้ดำเนินการ',
filterAllActors: 'ผู้ดำเนินการทั้งหมด',
- loadFailed: 'โหลดข้อมูลการติดตามไม่สำเร็จ',
+ loadFailed: 'โหลดบันทึกการดำเนินการไม่สำเร็จ',
refresh: 'รีเฟรช',
save: 'บันทึก',
redacted: 'ปิดบังแล้ว',
@@ -2720,7 +2720,7 @@ const thTH = {
elidedValue: '{{count}} ตัวอักษร',
levels: {
off: {
- label: 'ปิดการติดตาม',
+ label: 'ปิดบันทึก',
},
mutation: {
label: 'เฉพาะการแก้ไข',
diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts
index 05bf02d0d..e31d0cced 100644
--- a/web/src/i18n/locales/vi-VN.ts
+++ b/web/src/i18n/locales/vi-VN.ts
@@ -22,7 +22,7 @@ const viVN = {
create_knowledge_base: 'Tạo cơ sở tri thức',
get_pipeline: 'Xem Pipeline',
get_knowledge_schema: 'Xem lược đồ knowledge engine',
- list_operation_logs: 'Truy vấn truy vết thao tác',
+ list_operation_logs: 'Truy vấn nhật ký thao tác',
},
resources: {
models: 'Tìm mô hình trò chuyện',
@@ -2700,7 +2700,7 @@ const viVN = {
title: 'Cài đặt',
nav: {
workspace: 'Workspace',
- operationTrace: 'Truy vết',
+ operationTrace: 'Nhật ký',
models: 'Mô hình',
api: 'API',
storage: 'Lưu trữ',
@@ -2708,21 +2708,21 @@ const viVN = {
},
},
operationTrace: {
- title: 'Truy vết thao tác',
+ title: 'Nhật ký thao tác',
description: 'Theo dõi thao tác sửa và xem của quản trị viên',
+ reset: 'Đặt lại',
+ settingsUpdated: 'Đã lưu cài đặt',
+ settingsUpdateFailed: 'Lưu cài đặt thất bại',
+ levelBadge: 'Cấp độ: {{level}}',
+ collectionSettings: 'Cài đặt thu thập',
captureLevel: 'Mức ghi nhận',
retention: 'Chính sách lưu trữ',
- retentionSummary: 'Lưu {{days}} ngày · tối đa {{rows}} bản ghi',
retentionDays: 'Số ngày lưu',
maxRows: 'Số bản ghi tối đa',
dedupeWindow: 'Khoảng khử trùng lặp (giây)',
records: 'Bản ghi thao tác',
changesCount: '{{count}} thay đổi',
hideDetails: 'Ẩn chi tiết',
- levelUpdated: 'Đã cập nhật mức ghi nhận',
- levelUpdateFailed: 'Cập nhật mức ghi nhận thất bại',
- retentionUpdated: 'Đã cập nhật chính sách lưu trữ',
- retentionUpdateFailed: 'Cập nhật chính sách lưu trữ thất bại',
export: 'Tải nhật ký',
exportFailed: 'Tải nhật ký thao tác thất bại',
empty: 'Chưa có bản ghi thao tác',
@@ -2736,7 +2736,7 @@ const viVN = {
filterAllResources: 'Tất cả tài nguyên',
filterActor: 'Người thực hiện',
filterAllActors: 'Tất cả người thực hiện',
- loadFailed: 'Tải dữ liệu truy vết thất bại',
+ loadFailed: 'Tải nhật ký thao tác thất bại',
refresh: 'Làm mới',
save: 'Lưu',
redacted: 'Đã ẩn',
@@ -2755,7 +2755,7 @@ const viVN = {
elidedValue: '{{count}} ký tự',
levels: {
off: {
- label: 'Tắt truy vết',
+ label: 'Tắt nhật ký',
},
mutation: {
label: 'Chỉ thao tác sửa',
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index 63dd4ec3b..53e1e5925 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -19,7 +19,7 @@ const zhHans = {
create_knowledge_base: '创建知识库',
get_pipeline: '查看 Pipeline',
get_knowledge_schema: '查看知识引擎配置',
- list_operation_logs: '查询操作溯源',
+ list_operation_logs: '查询操作日志',
},
resources: {
models: '查询聊天模型',
@@ -2563,7 +2563,7 @@ const zhHans = {
title: '设置',
nav: {
workspace: '工作区',
- operationTrace: '操作溯源',
+ operationTrace: '操作日志',
models: '模型',
api: 'API',
storage: '存储',
@@ -2571,21 +2571,21 @@ const zhHans = {
},
},
operationTrace: {
- title: '操作溯源',
+ title: '操作日志',
description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。',
+ reset: '重置',
+ settingsUpdated: '设置已保存',
+ settingsUpdateFailed: '保存设置失败',
+ levelBadge: '等级:{{level}}',
+ collectionSettings: '收集设置',
captureLevel: '操作等级',
retention: '保留策略',
- retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 条',
retentionDays: '保留天数',
maxRows: '最大记录数',
dedupeWindow: '去重窗口(秒)',
records: '操作记录',
changesCount: '{{count}} 项变更',
hideDetails: '收起详情',
- levelUpdated: '操作等级已更新',
- levelUpdateFailed: '操作等级更新失败',
- retentionUpdated: '保留策略已更新',
- retentionUpdateFailed: '保留策略更新失败',
export: '下载日志',
exportFailed: '下载操作日志失败',
empty: '暂无操作记录',
@@ -2599,7 +2599,7 @@ const zhHans = {
filterAllResources: '全部资源',
filterActor: '操作者',
filterAllActors: '全部操作者',
- loadFailed: '加载操作溯源数据失败',
+ loadFailed: '加载操作日志数据失败',
refresh: '刷新',
save: '保存',
redacted: '已脱敏',
@@ -2618,7 +2618,7 @@ const zhHans = {
elidedValue: '{{count}} 字符',
levels: {
off: {
- label: '关闭溯源',
+ label: '关闭日志',
},
mutation: {
label: '仅修改操作',
@@ -2666,8 +2666,8 @@ const zhHans = {
},
actions: {
audit_log_view: '查看操作日志',
- settings_update: '修改溯源设置',
- settings_view: '查看溯源设置',
+ settings_update: '修改日志设置',
+ settings_view: '查看日志设置',
member_invite: '邀请成员',
member_role_update: '修改成员角色',
member_remove: '移除成员',
diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts
index 8c0526ff7..40dee9b93 100644
--- a/web/src/i18n/locales/zh-Hant.ts
+++ b/web/src/i18n/locales/zh-Hant.ts
@@ -19,7 +19,7 @@ const zhHant = {
create_knowledge_base: '建立知識庫',
get_pipeline: '查看 Pipeline',
get_knowledge_schema: '查看知識引擎設定',
- list_operation_logs: '查詢操作溯源',
+ list_operation_logs: '查詢操作日誌',
},
resources: {
models: '查詢聊天模型',
@@ -2564,7 +2564,7 @@ const zhHant = {
title: '設定',
nav: {
workspace: 'Workspace',
- operationTrace: '操作溯源',
+ operationTrace: '操作日誌',
models: '模型',
api: 'API',
storage: '儲存',
@@ -2572,21 +2572,21 @@ const zhHant = {
},
},
operationTrace: {
- title: '操作溯源',
+ title: '操作日誌',
description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。',
+ reset: '重設',
+ settingsUpdated: '設定已儲存',
+ settingsUpdateFailed: '儲存設定失敗',
+ levelBadge: '等級:{{level}}',
+ collectionSettings: '收集設定',
captureLevel: '操作等級',
retention: '保留策略',
- retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 條',
retentionDays: '保留天數',
maxRows: '最大記錄數',
dedupeWindow: '去重視窗(秒)',
records: '操作記錄',
changesCount: '{{count}} 項變更',
hideDetails: '收合詳情',
- levelUpdated: '操作等級已更新',
- levelUpdateFailed: '操作等級更新失敗',
- retentionUpdated: '保留策略已更新',
- retentionUpdateFailed: '保留策略更新失敗',
export: '下載日誌',
exportFailed: '下載操作日誌失敗',
empty: '暫無操作記錄',
@@ -2600,7 +2600,7 @@ const zhHant = {
filterAllResources: '全部資源',
filterActor: '操作者',
filterAllActors: '全部操作者',
- loadFailed: '載入操作溯源資料失敗',
+ loadFailed: '載入操作日誌資料失敗',
refresh: '重新整理',
save: '儲存',
redacted: '已遮罩',
@@ -2619,7 +2619,7 @@ const zhHant = {
elidedValue: '{{count}} 字元',
levels: {
off: {
- label: '關閉溯源',
+ label: '關閉日誌',
},
mutation: {
label: '僅修改操作',
@@ -2667,8 +2667,8 @@ const zhHant = {
},
actions: {
audit_log_view: '查看操作日誌',
- settings_update: '修改溯源設定',
- settings_view: '查看溯源設定',
+ settings_update: '修改日誌設定',
+ settings_view: '查看日誌設定',
member_invite: '邀請成員',
member_role_update: '修改成員角色',
member_remove: '移除成員',
From e14e27742484c1bb76167d8033bdfd437aeedc8a Mon Sep 17 00:00:00 2001
From: Hyu
Date: Mon, 28 Sep 2026 22:38:30 +0800
Subject: [PATCH 011/140] fix(api): scope TOTP oversight to the caller's
workspace
The second-factor listing was instance-wide and the three mutating endpoints never checked that the target Account belongs to the caller's Workspace, so an owner or admin could see and reset another tenant's account. The listing now resolves the Workspace members and the mutations answer 404 for a foreign account, which does not disclose that it exists.
Also fixes the TOTP write path on Postgres: six write points passed timezone-aware datetimes into timestamp-without-time-zone columns, which asyncpg rejects, so every enroll, confirm, revoke and recovery-code consumption raised a 500 there. SQLite tolerated the same values.
The panel derives its oversight view from the API authorization instead of the length of the account list, which would have hidden it in a single-member Workspace.
---
.../pkg/api/http/controller/groups/user.py | 51 +++++++-
src/langbot/pkg/api/http/service/totp.py | 37 ++++--
.../api/test_totp_workspace_scope.py | 123 ++++++++++++++++++
.../AccountSettingsPanel.tsx | 8 +-
web/src/i18n/locales/en-US.ts | 2 +-
web/src/i18n/locales/es-ES.ts | 2 +-
web/src/i18n/locales/ja-JP.ts | 2 +-
web/src/i18n/locales/ru-RU.ts | 2 +-
web/src/i18n/locales/th-TH.ts | 2 +-
web/src/i18n/locales/vi-VN.ts | 2 +-
web/src/i18n/locales/zh-Hans.ts | 2 +-
web/src/i18n/locales/zh-Hant.ts | 2 +-
12 files changed, 208 insertions(+), 27 deletions(-)
create mode 100644 tests/integration/api/test_totp_workspace_scope.py
diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py
index 240d7a213..365f5964e 100644
--- a/src/langbot/pkg/api/http/controller/groups/user.py
+++ b/src/langbot/pkg/api/http/controller/groups/user.py
@@ -914,22 +914,52 @@ class UserRouterGroup(group.RouterGroup):
if access.membership.role not in ('owner', 'admin'):
raise PermissionError('Only Workspace owners and admins may manage other Accounts')
+ async def _require_target_in_workspace(
+ request_context: RequestContext,
+ target_account_uuid: str,
+ ) -> bool:
+ """Report whether the target Account belongs to the caller's Workspace.
+
+ A cross-Workspace Account is reported as absent, so the response never
+ confirms another tenant's Account existence.
+ """
+
+ try:
+ await self.ap.workspace_collaboration_service.resolve_account_workspace(
+ target_account_uuid,
+ request_context.workspace_uuid,
+ )
+ except Exception:
+ return False
+ return True
+
@self.route('/totp/accounts', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext) -> str:
- """List the second-factor state of every Account for owners/admins.
+ """List the second-factor state of the caller's Workspace Accounts.
- Oversight is deliberately instance-wide: managing the second factor
- of any Account is an owner/admin responsibility and is not scoped to
- the caller's Workspace.
+ Oversight is a Workspace responsibility: owners and admins answer for
+ the Accounts that belong to their own Workspace and must never see
+ another tenant's Accounts.
"""
try:
- await _require_workspace_manager(request_context)
+ access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
+ request_context.account_uuid,
+ request_context.workspace_uuid,
+ )
+ if access.membership.role not in ('owner', 'admin'):
+ raise PermissionError('Only Workspace owners and admins may manage other Accounts')
+ members = await self.ap.workspace_collaboration_service.list_members(
+ request_context.workspace_uuid,
+ access.membership,
+ )
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
- accounts = await self.ap.totp_service.list_account_states()
+ accounts = await self.ap.totp_service.list_account_states(
+ account_uuids=[member.membership.account_uuid for member in members],
+ )
return self.success(data={'accounts': accounts})
@self.route('/totp/accounts/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
@@ -948,6 +978,9 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
+ if not await _require_target_in_workspace(request_context, target_account_uuid):
+ return self.http_status(404, 'account_not_found', 'Account not found')
+
revoked = await self.ap.totp_service.revoke_for_account(target_account_uuid)
if not revoked:
return self.http_status(404, 'totp_not_enrolled', 'TOTP is not enabled for that Account')
@@ -978,6 +1011,9 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
+ if not await _require_target_in_workspace(request_context, target_account_uuid):
+ return self.http_status(404, 'account_not_found', 'Account not found')
+
target = await self.ap.totp_service.get_account(target_account_uuid)
if target is None:
return self.http_status(404, 'account_not_found', 'Account not found')
@@ -1022,6 +1058,9 @@ class UserRouterGroup(group.RouterGroup):
if not isinstance(code, str) or not code:
return self.fail(1, 'Missing code parameter')
+ if not await _require_target_in_workspace(request_context, target_account_uuid):
+ return self.http_status(404, 'account_not_found', 'Account not found')
+
try:
result = await self.ap.totp_service.confirm_enrollment(target_account_uuid, code)
except totp_module.TotpNotEnrolledError as e:
diff --git a/src/langbot/pkg/api/http/service/totp.py b/src/langbot/pkg/api/http/service/totp.py
index 5995e91ab..8fd4a1157 100644
--- a/src/langbot/pkg/api/http/service/totp.py
+++ b/src/langbot/pkg/api/http/service/totp.py
@@ -49,6 +49,17 @@ _TOTP_MAX_ATTEMPTS = 5
_RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
+def _utc_now_naive() -> datetime.datetime:
+ """UTC now without tzinfo, matching the credential ``timestamp`` columns.
+
+ The TOTP tables store ``timestamp without time zone``. asyncpg rejects an
+ offset-aware value for such a column (SQLite accepts it, which is why this
+ only surfaced on PostgreSQL), so every write converts at the boundary.
+ """
+
+ return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
+
+
class TotpError(ValueError):
"""Base class for TOTP second-factor failures."""
@@ -363,7 +374,7 @@ class TotpService:
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(totp_entity.TotpCredential.account_uuid == account_uuid)
- .values(disabled_at=datetime.datetime.now(datetime.timezone.utc))
+ .values(disabled_at=_utc_now_naive())
)
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
@@ -409,7 +420,7 @@ class TotpService:
counter = self._verify_counter(credential, code)
codes = _generate_recovery_codes()
- now = datetime.datetime.now(datetime.timezone.utc)
+ now = _utc_now_naive()
async with self._session_factory()() as session:
async with session.begin():
@@ -479,7 +490,7 @@ class TotpService:
if not await self.verify_code(account_uuid, code, allow_recovery=True):
raise TotpInvalidCodeError('Invalid TOTP code')
- now = datetime.datetime.now(datetime.timezone.utc)
+ now = _utc_now_naive()
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
@@ -497,11 +508,16 @@ class TotpService:
# ------------------------------------------------------------------
# owner/admin oversight
# ------------------------------------------------------------------
- async def list_account_states(self) -> list[dict[str, typing.Any]]:
- """Second-factor state for every Account, for owner/admin oversight.
+ async def list_account_states(
+ self,
+ *,
+ account_uuids: typing.Sequence[str],
+ ) -> list[dict[str, typing.Any]]:
+ """Second-factor state for the Accounts a Workspace owner/admin may see.
- Oversight is instance-wide by design: an owner/admin may manage the
- second factor of any Account.
+ Oversight belongs to the Workspace, so the caller supplies the member
+ Account UUIDs of its own Workspace: an owner/admin must never observe or
+ manage another tenant's Accounts.
Only state is returned: no secret and no recovery-code material.
"""
@@ -525,6 +541,7 @@ class TotpService:
credential.last_used_at,
sqlalchemy.func.coalesce(unused_codes.c.unused, 0),
)
+ .where(user.User.uuid.in_(tuple(account_uuids)))
.outerjoin(
credential,
sqlalchemy.and_(
@@ -558,7 +575,7 @@ class TotpService:
outstanding recovery codes are destroyed.
"""
- now = datetime.datetime.now(datetime.timezone.utc)
+ now = _utc_now_naive()
async with self._session_factory()() as session:
async with session.begin():
result = await session.execute(
@@ -630,7 +647,7 @@ class TotpService:
raise
if consume_counter:
- now = datetime.datetime.now(datetime.timezone.utc)
+ now = _utc_now_naive()
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
@@ -680,7 +697,7 @@ class TotpService:
totp_entity.TotpRecoveryCode.id == matched_id,
totp_entity.TotpRecoveryCode.used_at.is_(None),
)
- .values(used_at=datetime.datetime.now(datetime.timezone.utc))
+ .values(used_at=_utc_now_naive())
)
await session.commit()
return bool(result.rowcount)
diff --git a/tests/integration/api/test_totp_workspace_scope.py b/tests/integration/api/test_totp_workspace_scope.py
new file mode 100644
index 000000000..460dcbbb2
--- /dev/null
+++ b/tests/integration/api/test_totp_workspace_scope.py
@@ -0,0 +1,123 @@
+"""Workspace scoping for the owner/admin second-factor oversight endpoints.
+
+An owner or admin answers for the Accounts of its own Workspace. The oversight
+endpoints must never list, revoke, or re-bind another tenant's Account.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+
+pytestmark = pytest.mark.integration
+
+WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
+MEMBER_UUID = 'member-account'
+FOREIGN_UUID = 'foreign-account'
+
+
+def _access(account_uuid: str) -> SimpleNamespace:
+ return SimpleNamespace(
+ workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
+ membership=SimpleNamespace(
+ uuid=f'membership-{account_uuid}',
+ account_uuid=account_uuid,
+ role='owner',
+ projection_revision=1,
+ ),
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
+ )
+
+
+async def _resolve(account_uuid: str, _workspace_uuid: str | None) -> SimpleNamespace:
+ # The collaboration service hides Accounts that are not members here.
+ if account_uuid == FOREIGN_UUID:
+ raise WorkspaceNotFoundError('Workspace not found')
+ return _access(account_uuid)
+
+
+@pytest.fixture
+async def totp_admin_api():
+ accounts = {'member-token': SimpleNamespace(uuid=MEMBER_UUID, user='member@example.com')}
+ application = Mock()
+ application.deployment = SimpleNamespace(multi_workspace_enabled=False)
+ application.instance_config.data = {'system': {'allow_modify_login_info': True}}
+ application.persistence_mgr = SimpleNamespace(tenant_uow=None)
+ application.user_service.get_authenticated_account = AsyncMock(
+ side_effect=lambda token: accounts[token]
+ )
+ application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
+ side_effect=_resolve
+ )
+ application.workspace_collaboration_service.list_members = AsyncMock(
+ return_value=[SimpleNamespace(membership=SimpleNamespace(account_uuid=MEMBER_UUID))]
+ )
+ application.totp_service.list_account_states = AsyncMock(
+ return_value=[{'account_uuid': MEMBER_UUID, 'user': 'member', 'enabled': False}]
+ )
+ application.totp_service.revoke_for_account = AsyncMock(return_value=True)
+ application.totp_service.get_account = AsyncMock(
+ return_value=SimpleNamespace(uuid=FOREIGN_UUID, user='foreign@example.com')
+ )
+ application.totp_service.begin_enrollment = AsyncMock()
+ application.totp_service.confirm_enrollment = AsyncMock()
+
+ quart_app = quart.Quart(__name__)
+ router = UserRouterGroup(application, quart_app)
+ await router.initialize()
+ return application, quart_app.test_client()
+
+
+def _headers() -> dict[str, str]:
+ return {'Authorization': 'Bearer member-token', 'X-Workspace-Id': WORKSPACE_UUID}
+
+
+async def test_account_list_covers_only_the_callers_workspace(totp_admin_api):
+ application, client = totp_admin_api
+
+ response = await client.get('/api/v1/user/totp/accounts', headers=_headers())
+
+ assert response.status_code == 200
+ payload = await response.get_json()
+ assert [item['account_uuid'] for item in payload['data']['accounts']] == [MEMBER_UUID]
+ application.totp_service.list_account_states.assert_awaited_once_with(
+ account_uuids=[MEMBER_UUID]
+ )
+
+
+async def test_foreign_account_cannot_be_revoked(totp_admin_api):
+ application, client = totp_admin_api
+
+ response = await client.delete(
+ f'/api/v1/user/totp/accounts/{FOREIGN_UUID}',
+ headers=_headers(),
+ )
+
+ assert response.status_code == 404
+ assert (await response.get_json())['code'] == 'account_not_found'
+ application.totp_service.revoke_for_account.assert_not_awaited()
+
+
+async def test_foreign_account_cannot_be_re_bound(totp_admin_api):
+ application, client = totp_admin_api
+
+ enroll = await client.post(
+ f'/api/v1/user/totp/accounts/{FOREIGN_UUID}/enroll',
+ headers=_headers(),
+ )
+ confirm = await client.post(
+ f'/api/v1/user/totp/accounts/{FOREIGN_UUID}/enroll/confirm',
+ json={'code': '123456'},
+ headers=_headers(),
+ )
+
+ assert enroll.status_code == 404
+ assert confirm.status_code == 404
+ application.totp_service.begin_enrollment.assert_not_awaited()
+ application.totp_service.confirm_enrollment.assert_not_awaited()
diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx
index ea12d95f4..1839815ce 100644
--- a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx
+++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx
@@ -131,9 +131,11 @@ export default function AccountSettingsPanel({
async function loadTotpAccounts() {
try {
const res = await httpClient.getTotpAccounts();
- const list = res.accounts || [];
- setIsManager(list.length > 1);
- setTotpRows(list);
+ // The role rule lives in the API: owners and admins receive the Workspace
+ // list, everyone else receives 403. Deriving manager state from the list
+ // length would hide oversight in a single-member Workspace.
+ setIsManager(true);
+ setTotpRows(res.accounts || []);
} catch {
// A non-manager receives 403 here; the panel keeps working on own state.
setIsManager(false);
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index e42a1d99f..5d29d623c 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -2316,7 +2316,7 @@ const enUS = {
totpStatusDisabled: 'Not enabled',
totpCodesRemaining: '{{count}} recovery codes left',
totpManagerSectionDesc:
- 'Owners and admins can review and revoke the second factor of any Account.',
+ 'Owners and admins can review and revoke the second factor of Accounts in this Workspace.',
revokeTotp: 'Re-bind',
totpAdminResetTitle: 'Re-bind two-factor authentication for {{user}}',
totpAdminResetDesc:
diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts
index b641f221a..80876513f 100644
--- a/web/src/i18n/locales/es-ES.ts
+++ b/web/src/i18n/locales/es-ES.ts
@@ -2367,7 +2367,7 @@ const esES = {
totpStatusDisabled: 'No activada',
totpCodesRemaining: 'Quedan {{count}} códigos de recuperación',
totpManagerSectionDesc:
- 'Los propietarios y administradores pueden revisar y restablecer el segundo factor de cualquier cuenta.',
+ 'Los propietarios y administradores pueden revisar y restablecer el segundo factor de las cuentas de este espacio de trabajo.',
revokeTotp: 'Reasignar',
totpAdminResetTitle: 'Reasignar la verificación en dos pasos de {{user}}',
totpAdminResetDesc:
diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts
index e2d123bf1..0d8746171 100644
--- a/web/src/i18n/locales/ja-JP.ts
+++ b/web/src/i18n/locales/ja-JP.ts
@@ -2331,7 +2331,7 @@ const jaJP = {
totpStatusDisabled: '未設定',
totpCodesRemaining: 'リカバリーコード残り {{count}} 個',
totpManagerSectionDesc:
- 'オーナーと管理者はすべてのアカウントの二段階認証を確認・解除できます。',
+ 'オーナーと管理者はこのワークスペースのアカウントの二段階認証を確認・解除できます。',
revokeTotp: '再バインド',
totpAdminResetTitle: '{{user}} の二段階認証を再バインド',
totpAdminResetDesc:
diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts
index bebd8a4ce..eca8e3767 100644
--- a/web/src/i18n/locales/ru-RU.ts
+++ b/web/src/i18n/locales/ru-RU.ts
@@ -2339,7 +2339,7 @@ const ruRU = {
totpStatusDisabled: 'Не включено',
totpCodesRemaining: 'Осталось кодов восстановления: {{count}}',
totpManagerSectionDesc:
- 'Владельцы и администраторы могут просматривать и сбрасывать второй фактор любого аккаунта.',
+ 'Владельцы и администраторы могут просматривать и сбрасывать второй фактор аккаунтов этого рабочего пространства.',
revokeTotp: 'Перепривязать',
totpAdminResetTitle:
'Перепривязать двухфакторную аутентификацию для {{user}}',
diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts
index beedf3e89..99b465e5c 100644
--- a/web/src/i18n/locales/th-TH.ts
+++ b/web/src/i18n/locales/th-TH.ts
@@ -2268,7 +2268,7 @@ const thTH = {
totpStatusDisabled: 'ยังไม่เปิดใช้',
totpCodesRemaining: 'เหลือรหัสกู้คืน {{count}} รหัส',
totpManagerSectionDesc:
- 'เจ้าของและผู้ดูแลสามารถตรวจสอบและรีเซ็ตการยืนยันสองขั้นตอนของบัญชีใดก็ได้',
+ 'เจ้าของและผู้ดูแลสามารถตรวจสอบและรีเซ็ตการยืนยันสองขั้นตอนของบัญชีในพื้นที่ทำงานนี้',
revokeTotp: 'ผูกใหม่',
totpAdminResetTitle: 'ผูกการยืนยันสองขั้นตอนใหม่ให้ {{user}}',
totpAdminResetDesc:
diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts
index e31d0cced..5e3dbfc60 100644
--- a/web/src/i18n/locales/vi-VN.ts
+++ b/web/src/i18n/locales/vi-VN.ts
@@ -2302,7 +2302,7 @@ const viVN = {
totpStatusDisabled: 'Chưa bật',
totpCodesRemaining: 'Còn {{count}} mã khôi phục',
totpManagerSectionDesc:
- 'Chủ sở hữu và quản trị viên có thể xem và đặt lại yếu tố thứ hai của bất kỳ tài khoản nào.',
+ 'Chủ sở hữu và quản trị viên có thể xem và đặt lại yếu tố thứ hai của các tài khoản trong không gian làm việc này.',
revokeTotp: 'Liên kết lại',
totpAdminResetTitle: 'Liên kết lại xác thực hai bước cho {{user}}',
totpAdminResetDesc:
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index 53e1e5925..d206bea70 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -2175,7 +2175,7 @@ const zhHans = {
totpRecoveryCodesRegenerated: '已生成新的恢复代码',
totpStatusDisabled: '未启用',
totpCodesRemaining: '剩余 {{count}} 个恢复代码',
- totpManagerSectionDesc: '所有者和管理员可以查看并重置任意账户的两步验证。',
+ totpManagerSectionDesc: '所有者和管理员可以查看并重置本工作区账户的两步验证。',
revokeTotp: '重新绑定',
totpAdminResetTitle: '重新绑定 {{user}} 的两步验证',
totpAdminResetDesc:
diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts
index 40dee9b93..c6dd57b15 100644
--- a/web/src/i18n/locales/zh-Hant.ts
+++ b/web/src/i18n/locales/zh-Hant.ts
@@ -2172,7 +2172,7 @@ const zhHant = {
totpRecoveryCodesRegenerated: '已產生新的恢復代碼',
totpStatusDisabled: '未啟用',
totpCodesRemaining: '剩餘 {{count}} 個恢復代碼',
- totpManagerSectionDesc: '擁有者與管理員可以檢視並重設任意帳號的兩步驗證。',
+ totpManagerSectionDesc: '擁有者與管理員可以檢視並重設本工作區帳號的兩步驗證。',
revokeTotp: '重新綁定',
totpAdminResetTitle: '重新綁定 {{user}} 的兩步驗證',
totpAdminResetDesc:
From 6ee9c0a9a3986be3f8ae482142270d42c4826965 Mon Sep 17 00:00:00 2001
From: Hyu
Date: Mon, 28 Sep 2026 22:38:35 +0800
Subject: [PATCH 012/140] fix(operation-trace): bind the workspace scope on
every off-request write
Cloud runtime refuses unscoped database access and workspace_operation_logs is row-level-secured on the bound Workspace. The background writer, the retention pass and the metadata reads all run outside a request lifetime, so each of them failed and the failure was swallowed by the best-effort handler: enabling tracing recorded nothing at all.
The route wrapper also traces after the handler released its scope, so level resolution has to carry the Workspace itself. Without it the level read back as 0 and the request was dropped before it was even queued.
The startup gate now enumerates the Workspaces through this instance's execution bindings, because a cross-Workspace metadata scan cannot see them under RLS, and it runs after the Workspace service exists. The writer starts with a clean context so it cannot inherit the scope of the request that first enqueued a trace, and a dropped write is reported instead of being logged only at debug.
---
src/langbot/pkg/core/stages/build_app.py | 13 +-
src/langbot/pkg/operation_trace/service.py | 296 ++++++++++-------
.../test_operation_trace_tenant_scope.py | 303 ++++++++++++++++++
3 files changed, 489 insertions(+), 123 deletions(-)
create mode 100644 tests/unit_tests/test_operation_trace_tenant_scope.py
diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py
index 579f1049a..77d8788df 100644
--- a/src/langbot/pkg/core/stages/build_app.py
+++ b/src/langbot/pkg/core/stages/build_app.py
@@ -162,12 +162,6 @@ class BuildAppStage(stage.BootingStage):
ap.persistence_mgr = persistence_mgr_inst
await persistence_mgr_inst.initialize()
- # Persistence is ready now. Open the cheap global traceability gate if
- # any Workspace already has tracing enabled, so a restart resumes
- # recording without ever checking the database on the request path.
- if ap.workspace_settings_service is not None:
- await ap.workspace_settings_service.prime_global_flag()
-
if deployment.multi_workspace_enabled:
directory_projection_service = DirectoryProjectionService(
ap,
@@ -203,6 +197,13 @@ class BuildAppStage(stage.BootingStage):
await cloud_model_catalog_service.initialize()
ap.cloud_model_catalog_service = cloud_model_catalog_service
+ # Persistence and the Workspace directory are both ready now. Open the
+ # cheap global traceability gate if any Workspace already has tracing
+ # enabled, so a restart resumes recording without ever checking the
+ # database on the request path.
+ if ap.workspace_settings_service is not None:
+ await ap.workspace_settings_service.prime_global_flag()
+
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
ap,
workspace_service_inst,
diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py
index 82be57fcc..1d771645e 100644
--- a/src/langbot/pkg/operation_trace/service.py
+++ b/src/langbot/pkg/operation_trace/service.py
@@ -23,6 +23,8 @@ Human-readable labels are never returned from this module. The persisted
from __future__ import annotations
import asyncio
+import contextlib
+import contextvars
import dataclasses
import datetime
import enum
@@ -1198,6 +1200,9 @@ class WorkspaceSettingsService:
# refresh and page turn, which is what made reading the log feel slow.
self._integrity_cache: dict[str, dict[str, typing.Any]] = {}
self._dropped_count = 0
+ # Failed persistence attempts, so a silently broken writer (a missing
+ # tenant scope, an RLS rejection) is reported instead of only counted.
+ self._write_failures = 0
# Set while the queue is empty; lets tests/shutdown await a drain.
self._idle = asyncio.Event()
self._idle.set()
@@ -1232,59 +1237,116 @@ class WorkspaceSettingsService:
enable_globally()
self.ap.operation_trace_active = True
+ def _workspace_scope(self, workspace_uuid: str) -> typing.AsyncContextManager[typing.Any]:
+ """Carry a Workspace scope into persistence work that runs off-request.
+
+ Cloud runtime rejects every database call made without an explicit scope,
+ and the audit table is RLS-protected on ``langbot.workspace_uuid``, so the
+ background writer and the retention loop have to establish their own
+ scope. On the request path the caller already bound the Workspace: that
+ scope is the correct one, so the boundary is skipped rather than nested.
+ """
+
+ manager = getattr(self.ap, 'persistence_mgr', None)
+ tenant_scope = getattr(manager, 'tenant_scope', None)
+ current_scope = getattr(manager, 'current_scope', None)
+ if not callable(tenant_scope) or not workspace_uuid:
+ return contextlib.nullcontext()
+ if callable(current_scope):
+ try:
+ active = current_scope()
+ except Exception:
+ # A child task that inherited its parent's scope cannot use it;
+ # an explicit boundary of its own is exactly what it needs.
+ active = None
+ if active is not None:
+ return contextlib.nullcontext()
+ return tenant_scope(workspace_uuid)
+
async def prime_global_flag(self) -> None:
"""Open the cheap global gate at startup if a Workspace already opted in.
- Runs one bounded query during application build, never per request, so a
+ Runs once during application build, never per request, so a
previously-enabled Workspace keeps recording after a restart while a
- disabled instance still pays nothing on the hot path.
+ disabled instance still pays nothing on the hot path. Workspaces are
+ enumerated through the execution bindings this instance owns, because the
+ metadata table is RLS-protected per Workspace and a single cross-Workspace
+ scan can never see them in cloud runtime.
"""
try:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_metadata.WorkspaceMetadata.workspace_uuid)
- .where(
- persistence_metadata.WorkspaceMetadata.key == OPERATION_LEVEL_KEY,
- persistence_metadata.WorkspaceMetadata.value != str(OPERATION_LEVEL_NONE),
- )
- .limit(1)
- )
- if result.first() is not None:
- self._activate()
+ for workspace_uuid in await self._traced_workspace_candidates():
+ async with self._workspace_scope(workspace_uuid):
+ level = clamp_level(await self._read_metadata(workspace_uuid, OPERATION_LEVEL_KEY))
+ if level > OPERATION_LEVEL_NONE:
+ self._activate()
+ return
except Exception as exc: # pragma: no cover - defensive
self.ap.logger.debug(f'Operation trace global gate prime skipped: {exc}')
+ async def _traced_workspace_candidates(self) -> list[str]:
+ """Return the Workspaces that may have tracing enabled.
+
+ A multi-Workspace runtime enumerates the execution bindings discovered for
+ this instance. A single-database deployment can scan the metadata table
+ directly, which is also the path a lightweight test double takes.
+ """
+
+ list_bindings = getattr(
+ getattr(self.ap, 'workspace_service', None),
+ 'list_active_execution_bindings',
+ None,
+ )
+ if callable(list_bindings):
+ try:
+ bindings = await list_bindings()
+ except Exception as exc: # pragma: no cover - discovery is optional
+ self.ap.logger.debug(f'Operation trace Workspace discovery skipped: {exc}')
+ else:
+ return [str(binding.workspace_uuid) for binding in bindings]
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_metadata.WorkspaceMetadata.workspace_uuid).where(
+ persistence_metadata.WorkspaceMetadata.key == OPERATION_LEVEL_KEY,
+ persistence_metadata.WorkspaceMetadata.value != str(OPERATION_LEVEL_NONE),
+ )
+ )
+ return [str(workspace_uuid) for workspace_uuid in result.scalars().all()]
+
async def _read_metadata(self, workspace_uuid: str, key: str) -> str | None:
try:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_metadata.WorkspaceMetadata.value).where(
- persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid,
- persistence_metadata.WorkspaceMetadata.key == key,
+ # Resolved outside the request scope when the route wrapper records a
+ # request, so the read has to carry the Workspace itself.
+ async with self._workspace_scope(workspace_uuid):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_metadata.WorkspaceMetadata.value).where(
+ persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid,
+ persistence_metadata.WorkspaceMetadata.key == key,
+ )
)
- )
- return result.scalar_one_or_none()
+ return result.scalar_one_or_none()
except Exception as exc: # pragma: no cover - defensive
self.ap.logger.debug(f'Operation log metadata read failed for {key}: {exc}')
return None
async def _write_metadata(self, workspace_uuid: str, values: dict[str, str]) -> None:
- for key, value in values.items():
- try:
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_metadata.WorkspaceMetadata).where(
- persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid,
- persistence_metadata.WorkspaceMetadata.key == key,
+ async with self._workspace_scope(workspace_uuid):
+ for key, value in values.items():
+ try:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(persistence_metadata.WorkspaceMetadata).where(
+ persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid,
+ persistence_metadata.WorkspaceMetadata.key == key,
+ )
)
- )
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(persistence_metadata.WorkspaceMetadata).values(
- workspace_uuid=workspace_uuid,
- key=key,
- value=value,
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_metadata.WorkspaceMetadata).values(
+ workspace_uuid=workspace_uuid,
+ key=key,
+ value=value,
+ )
)
- )
- except Exception as exc: # pragma: no cover - defensive
- self.ap.logger.warning(f'Operation log metadata write failed for {key}: {exc}')
+ except Exception as exc: # pragma: no cover - defensive
+ self.ap.logger.warning(f'Operation log metadata write failed for {key}: {exc}')
async def _read_metadata_values(self, workspace_uuid: str, keys: typing.Iterable[str]) -> dict[str, str]:
"""Read several metadata rows for one Workspace in a single query."""
@@ -1293,16 +1355,17 @@ class WorkspaceSettingsService:
if not wanted:
return {}
try:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(
- persistence_metadata.WorkspaceMetadata.key,
- persistence_metadata.WorkspaceMetadata.value,
- ).where(
- persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid,
- persistence_metadata.WorkspaceMetadata.key.in_(wanted),
+ async with self._workspace_scope(workspace_uuid):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ persistence_metadata.WorkspaceMetadata.key,
+ persistence_metadata.WorkspaceMetadata.value,
+ ).where(
+ persistence_metadata.WorkspaceMetadata.workspace_uuid == workspace_uuid,
+ persistence_metadata.WorkspaceMetadata.key.in_(wanted),
+ )
)
- )
- return {row[0]: row[1] for row in result.all()}
+ return {row[0]: row[1] for row in result.all()}
except Exception as exc: # pragma: no cover - defensive
self.ap.logger.debug(f'Operation log metadata batch read failed: {exc}')
return {}
@@ -1583,7 +1646,13 @@ class WorkspaceSettingsService:
if self._writer_task is not None and not self._writer_task.done():
return
- self._writer_task = asyncio.get_running_loop().create_task(self._writer_loop())
+ # A fresh context: the writer outlives the request that first enqueued a
+ # trace. Inheriting that request's persistence scope would make every
+ # later write fail, because a child task may not use its parent's scope.
+ self._writer_task = asyncio.get_running_loop().create_task(
+ self._writer_loop(),
+ context=contextvars.Context(),
+ )
async def _writer_loop(self) -> None:
"""Drain the queue and persist traces sequentially.
@@ -1627,23 +1696,42 @@ class WorkspaceSettingsService:
try:
workspace_uuid = record_fields['workspace_uuid']
- # Off the request path: hash the dedupe material, resolve the actor
- # display name, then link and insert this row.
- record_fields['dedupe_key'] = hash_dedupe_material(record_fields.pop('_dedupe_material'))
- if record_fields.get('actor_name') is None and record_fields.get('actor_account_uuid'):
- await self._fill_actor_name(record_fields)
- if await self._is_duplicate_observation(workspace_uuid, record_fields['dedupe_key']):
- return False
- record_fields['prev_hash'] = await self._latest_record_hash(workspace_uuid)
- record_fields['record_hash'] = compute_record_hash(record_fields)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(persistence_operation_log.WorkspaceOperationLog).values(**record_fields)
- )
- return True
+ # The writer drains the queue outside any request, so it has to carry
+ # its own Workspace scope: cloud runtime refuses unscoped access and
+ # the audit table only accepts rows that match the bound Workspace.
+ async with self._workspace_scope(workspace_uuid):
+ # Off the request path: hash the dedupe material, resolve the actor
+ # display name, then link and insert this row.
+ record_fields['dedupe_key'] = hash_dedupe_material(record_fields.pop('_dedupe_material'))
+ if record_fields.get('actor_name') is None and record_fields.get('actor_account_uuid'):
+ await self._fill_actor_name(record_fields)
+ if await self._is_duplicate_observation(workspace_uuid, record_fields['dedupe_key']):
+ return False
+ record_fields['prev_hash'] = await self._latest_record_hash(workspace_uuid)
+ record_fields['record_hash'] = compute_record_hash(record_fields)
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_operation_log.WorkspaceOperationLog).values(**record_fields)
+ )
+ return True
except Exception as exc: # pragma: no cover - auditing is best effort
- self.ap.logger.debug(f'Operation log write skipped: {exc}')
+ self._note_write_failure(exc)
return False
+ def _note_write_failure(self, exc: BaseException) -> None:
+ """Report a dropped trace without letting it break the caller.
+
+ Recording is best effort, but an audit trail that silently stops growing
+ is indistinguishable from an idle Workspace. The first failure and then
+ every hundredth are surfaced; the rest stay at debug so a persistent
+ failure cannot flood the log.
+ """
+
+ self._write_failures += 1
+ if self._write_failures == 1 or self._write_failures % 100 == 0:
+ self.ap.logger.warning(f'Operation log write failed ({self._write_failures} so far): {exc}')
+ else:
+ self.ap.logger.debug(f'Operation log write skipped: {exc}')
+
async def _fill_actor_name(self, record_fields: dict[str, typing.Any]) -> None:
"""Resolve the actor display name off the request path."""
@@ -2344,7 +2432,9 @@ class WorkspaceSettingsService:
self._insert_counters[workspace_uuid] = pending
return
self._insert_counters[workspace_uuid] = 0
- await self._enforce_row_budget(workspace_uuid)
+ # Runs from the writer task, which holds no scope of its own.
+ async with self._workspace_scope(workspace_uuid):
+ await self._enforce_row_budget(workspace_uuid)
async def _enforce_row_budget(self, workspace_uuid: str) -> None:
"""Drop the oldest rows when the Workspace row budget is exceeded."""
@@ -2396,67 +2486,39 @@ class WorkspaceSettingsService:
by a privileged click.
"""
- days = (
- await self.get_retention_days(workspace_uuid) if retention_days is None else clamp_retention(retention_days)
- )
- budget = await self.get_max_rows(workspace_uuid) if max_rows is None else clamp_max_rows(max_rows)
- cutoff = _utcnow() - datetime.timedelta(days=days)
-
- expired = 0
- try:
- model = persistence_operation_log.WorkspaceOperationLog
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(model).where(
- model.workspace_uuid == workspace_uuid,
- model.created_at < cutoff,
- )
+ # The maintenance loop runs per Workspace without holding a scope, so
+ # retention has to bind one itself; on the settings route the request
+ # scope is already active and this is a no-op.
+ async with self._workspace_scope(workspace_uuid):
+ days = (
+ await self.get_retention_days(workspace_uuid)
+ if retention_days is None
+ else clamp_retention(retention_days)
)
- expired = int(result.rowcount or 0)
- except Exception as exc: # pragma: no cover - defensive
- self.ap.logger.debug(f'Operation log retention prune skipped: {exc}')
+ budget = await self.get_max_rows(workspace_uuid) if max_rows is None else clamp_max_rows(max_rows)
+ cutoff = _utcnow() - datetime.timedelta(days=days)
- if expired > 0:
- # Age-based retention removed the oldest rows, so any cached
- # verification -- including its boundary baseline link -- no longer
- # describes the surviving chain.
- self._invalidate_integrity_cache(workspace_uuid)
-
- total = await self.count_logs(workspace_uuid)
- trimmed = await self._delete_oldest(workspace_uuid, max(total - budget, 0))
- # The budget was just enforced in full, so the deferred counter restarts.
- self._insert_counters[workspace_uuid] = 0
- return {'expired': expired, 'trimmed': trimmed, 'remaining': await self.count_logs(workspace_uuid)}
-
- async def prune_all_workspaces(self) -> dict[str, int]:
- """Run retention pruning for every Workspace that enabled tracing.
-
- Returns a coarse report; individual Workspace failures are contained.
- """
-
- processed = 0
- removed = 0
- try:
- workspaces = (
- (
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_metadata.WorkspaceMetadata.workspace_uuid).where(
- persistence_metadata.WorkspaceMetadata.key == OPERATION_LEVEL_KEY
- )
+ expired = 0
+ try:
+ model = persistence_operation_log.WorkspaceOperationLog
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(model).where(
+ model.workspace_uuid == workspace_uuid,
+ model.created_at < cutoff,
)
)
- .scalars()
- .all()
- )
- except Exception as exc: # pragma: no cover - defensive
- self.ap.logger.debug(f'Operation log workspace enumeration skipped: {exc}')
- return {'workspaces': 0, 'removed': 0}
-
- for workspace_uuid in {item for item in workspaces if item}:
- try:
- report = await self.prune(workspace_uuid)
+ expired = int(result.rowcount or 0)
except Exception as exc: # pragma: no cover - defensive
- self.ap.logger.debug(f'Operation log prune failed for {workspace_uuid}: {exc}')
- continue
- processed += 1
- removed += report['expired'] + report['trimmed']
- return {'workspaces': processed, 'removed': removed}
+ self.ap.logger.debug(f'Operation log retention prune skipped: {exc}')
+
+ if expired > 0:
+ # Age-based retention removed the oldest rows, so any cached
+ # verification -- including its boundary baseline link -- no longer
+ # describes the surviving chain.
+ self._invalidate_integrity_cache(workspace_uuid)
+
+ total = await self.count_logs(workspace_uuid)
+ trimmed = await self._delete_oldest(workspace_uuid, max(total - budget, 0))
+ # The budget was just enforced in full, so the deferred counter restarts.
+ self._insert_counters[workspace_uuid] = 0
+ return {'expired': expired, 'trimmed': trimmed, 'remaining': await self.count_logs(workspace_uuid)}
diff --git a/tests/unit_tests/test_operation_trace_tenant_scope.py b/tests/unit_tests/test_operation_trace_tenant_scope.py
new file mode 100644
index 000000000..2ed132e7e
--- /dev/null
+++ b/tests/unit_tests/test_operation_trace_tenant_scope.py
@@ -0,0 +1,303 @@
+"""Regression tests for the operation-trace tenant scope.
+
+Cloud runtime refuses every database call made without an explicit scope, and the
+audit table is row-level-secured on the bound Workspace. The writer drains its
+queue outside any request lifetime, so these tests drive the real service through
+persistence doubles that enforce exactly that contract: without a bound scope the
+call fails. A writer (or a retention pass) that forgets to bind a scope records
+nothing, which is the defect pinned here.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import contextvars
+import datetime
+import logging
+import typing
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.authz import WorkspaceRole
+from langbot.pkg.api.http.context import PrincipalType
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence import metadata as metadata_module
+from langbot.pkg.entity.persistence import operation_log as operation_log_module
+from langbot.pkg.operation_trace import service as service_module
+from langbot.pkg.persistence.tenant_uow import CrossScopeTransactionError, TenantScopeRequiredError
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture(autouse=True)
+def _closed_gate(monkeypatch):
+ """The gate latch is process-wide: start every test from a disabled instance."""
+
+ monkeypatch.setattr(service_module, '_global_enabled', False)
+
+
+TRACED_WORKSPACE = '00000000-0000-0000-0000-00000000000a'
+IDLE_WORKSPACE = '00000000-0000-0000-0000-00000000000b'
+MODEL = operation_log_module.WorkspaceOperationLog
+LEVEL_KEY = service_module.OPERATION_LEVEL_KEY
+
+
+class _CloudRuntimePersistence:
+ """Persistence double mirroring the cloud-runtime scope contract."""
+
+ def __init__(self, engine) -> None:
+ self.engine = engine
+ self._bound: contextvars.ContextVar[tuple[str, typing.Any] | None] = contextvars.ContextVar(
+ 'bound_workspace', default=None
+ )
+
+ def current_scope(self) -> str | None:
+ bound = self._bound.get()
+ if bound is None:
+ return None
+ workspace_uuid, owner_task = bound
+ if owner_task is not asyncio.current_task():
+ # The real manager refuses a boundary a child task inherited.
+ raise CrossScopeTransactionError('scoped boundaries cannot be inherited by child tasks')
+ return workspace_uuid
+
+ @contextlib.asynccontextmanager
+ async def tenant_scope(self, workspace_uuid: str):
+ token = self._bound.set((workspace_uuid, asyncio.current_task()))
+ try:
+ yield self
+ finally:
+ self._bound.reset(token)
+
+ async def execute_async(self, *args, **kwargs):
+ if self.current_scope() is None:
+ raise TenantScopeRequiredError('cloud persistence access requires an explicit Workspace scope')
+ # The database carries the scope as an RLS setting, so a statement that
+ # writes another Workspace's rows would be rejected as well.
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+
+class _SingleDatabasePersistence:
+ """Persistence double for the unscoped single-database deployment."""
+
+ def __init__(self, engine) -> None:
+ self.engine = engine
+
+ async def execute_async(self, *args, **kwargs):
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+
+@pytest.fixture
+async def trace_env(tmp_path):
+ """A real service against SQLite, with the enforcement double installed."""
+
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "trace-scope.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ persistence = _CloudRuntimePersistence(engine)
+ application = SimpleNamespace(
+ persistence_mgr=persistence,
+ logger=logging.getLogger('operation-trace-scope-test'),
+ user_service=None,
+ operation_trace_active=False,
+ )
+ try:
+ yield service_module.WorkspaceSettingsService(application), application, engine
+ finally:
+ await engine.dispose()
+
+
+async def _set_level(engine, workspace_uuid: str, level: int) -> None:
+ async with engine.begin() as connection:
+ await connection.execute(
+ sqlalchemy.insert(metadata_module.WorkspaceMetadata).values(
+ workspace_uuid=workspace_uuid,
+ key=LEVEL_KEY,
+ value=str(level),
+ )
+ )
+
+
+async def _rows(engine) -> list:
+ async with engine.begin() as connection:
+ result = await connection.execute(sqlalchemy.select(MODEL))
+ return list(result.all())
+
+
+async def test_writer_binds_workspace_scope_when_recording(trace_env):
+ """A queued trace is written by the background writer holding its own scope."""
+
+ service, application, engine = trace_env
+ rule = service_module.classify('POST', '/api/v1/pipelines')
+
+ queued = await service.record(
+ TRACED_WORKSPACE,
+ rule=rule,
+ level=service_module.OPERATION_LEVEL_MUTATION,
+ http_method='POST',
+ route='/api/v1/pipelines',
+ )
+ assert queued is True
+
+ await service.flush_pending(timeout=5.0)
+
+ rows = await _rows(engine)
+ assert len(rows) == 1
+ assert rows[0].workspace_uuid == TRACED_WORKSPACE
+ assert service._write_failures == 0
+
+
+async def test_retention_prune_binds_workspace_scope(trace_env):
+ """The maintenance loop prunes without a request, so prune binds its own scope."""
+
+ service, application, engine = trace_env
+ stale = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(days=90)
+ async with engine.begin() as connection:
+ await connection.execute(
+ sqlalchemy.insert(MODEL).values(
+ workspace_uuid=TRACED_WORKSPACE,
+ level=service_module.OPERATION_LEVEL_MUTATION,
+ outcome='ok',
+ created_at=stale,
+ )
+ )
+ await _set_level(engine, TRACED_WORKSPACE, service_module.OPERATION_LEVEL_MUTATION)
+
+ report = await service.prune(TRACED_WORKSPACE, retention_days=30)
+
+ assert report['expired'] == 1
+ assert await _rows(engine) == []
+
+
+async def test_request_wrapper_records_after_the_handler_released_its_scope(trace_env):
+ """The route wrapper traces outside the request scope and must still record.
+
+ Level resolution and the queued write both happen after the handler released
+ its tenant scope. Each of them has to bind the Workspace itself: an unscoped
+ read reported level 0, so every request was dropped without a trace.
+ """
+
+ service, application, engine = trace_env
+ await _set_level(engine, TRACED_WORKSPACE, service_module.OPERATION_LEVEL_MUTATION)
+ context = SimpleNamespace(
+ workspace_uuid=TRACED_WORKSPACE,
+ workspace=SimpleNamespace(role=WorkspaceRole.OWNER.value),
+ principal=SimpleNamespace(
+ principal_type=PrincipalType.ACCOUNT,
+ account_uuid=None,
+ actor_account_uuid=None,
+ api_key_uuid=None,
+ ),
+ auth_type='user_token',
+ request_id='req-scope-test',
+ )
+
+ recorded = await service.record_request(
+ context,
+ method='POST',
+ route=f'/api/v1/workspaces/{TRACED_WORKSPACE}/invitations',
+ status_code=200,
+ )
+
+ assert recorded is True
+ await service.flush_pending(timeout=5.0)
+ rows = await _rows(engine)
+ assert len(rows) == 1
+ assert rows[0].action == 'member_invite'
+
+
+async def test_writer_started_inside_a_request_scope_still_records(trace_env):
+ """The writer outlives the request that first enqueued a trace.
+
+ A task created while its caller held a Workspace boundary inherits it, and a
+ child task may not use an inherited boundary. Starting the writer with the
+ caller's context made it reject every later write instead of recording it.
+ """
+
+ service, application, engine = trace_env
+ await _set_level(engine, TRACED_WORKSPACE, service_module.OPERATION_LEVEL_MUTATION)
+ rule = service_module.classify('POST', '/api/v1/pipelines')
+
+ async with application.persistence_mgr.tenant_scope(TRACED_WORKSPACE):
+ queued = await service.record(
+ TRACED_WORKSPACE,
+ rule=rule,
+ level=service_module.OPERATION_LEVEL_MUTATION,
+ http_method='POST',
+ route='/api/v1/pipelines',
+ )
+ assert queued is True
+ await service.flush_pending(timeout=5.0)
+
+ rows = await _rows(engine)
+ assert len(rows) == 1
+ assert service._write_failures == 0
+
+
+async def test_prime_opens_gate_from_discovered_workspace_bindings(trace_env):
+ """A restart resumes tracing: the gate opens from the Workspaces this instance owns."""
+
+ service, application, engine = trace_env
+ await _set_level(engine, IDLE_WORKSPACE, service_module.OPERATION_LEVEL_NONE)
+ await _set_level(engine, TRACED_WORKSPACE, service_module.OPERATION_LEVEL_MUTATION)
+
+ async def list_active_execution_bindings():
+ return [
+ SimpleNamespace(workspace_uuid=IDLE_WORKSPACE),
+ SimpleNamespace(workspace_uuid=TRACED_WORKSPACE),
+ ]
+
+ application.workspace_service = SimpleNamespace(list_active_execution_bindings=list_active_execution_bindings)
+
+ await service.prime_global_flag()
+
+ assert application.operation_trace_active is True
+
+
+async def test_prime_keeps_gate_closed_when_no_workspace_traces(trace_env):
+ """A disabled instance still pays nothing on the request path."""
+
+ service, application, engine = trace_env
+ await _set_level(engine, IDLE_WORKSPACE, service_module.OPERATION_LEVEL_NONE)
+
+ async def list_active_execution_bindings():
+ return [SimpleNamespace(workspace_uuid=IDLE_WORKSPACE)]
+
+ application.workspace_service = SimpleNamespace(list_active_execution_bindings=list_active_execution_bindings)
+
+ await service.prime_global_flag()
+
+ assert application.operation_trace_active is False
+
+
+async def test_prime_falls_back_to_metadata_scan_without_discovery(tmp_path):
+ """A single-database deployment without a Workspace directory still resumes."""
+
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "trace-single.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ application = SimpleNamespace(
+ persistence_mgr=_SingleDatabasePersistence(engine),
+ logger=logging.getLogger('operation-trace-single-test'),
+ user_service=None,
+ operation_trace_active=False,
+ )
+ try:
+ await _set_level(engine, TRACED_WORKSPACE, service_module.OPERATION_LEVEL_MUTATION)
+ service = service_module.WorkspaceSettingsService(application)
+
+ await service.prime_global_flag()
+
+ assert application.operation_trace_active is True
+ finally:
+ await engine.dispose()
From 6515ba3b94afca6d8121e8b862247844a1c4a688 Mon Sep 17 00:00:00 2001
From: Hyu
Date: Mon, 28 Sep 2026 23:53:01 +0800
Subject: [PATCH 013/140] fix(api): serve the passkey and password routes for
the authenticated Account
Passkeys and passwords belong to the Account, but those routes required a Workspace -- which the WebUI never sends for them -- and then resolved the caller by looking the login name up as an email address. Every Account whose login name is not its email answered 404, so the passkey panel could neither add nor list a key.
The routes now authenticate with the account token and address the Account the caller authenticated as, matching the self-service second-factor routes in the same file. /space/bind-authorize-url carried the same Workspace requirement.
The login page also surfaced a TypeError instead of the passkey login failure message when an assertion was rejected.
---
.../pkg/api/http/controller/groups/user.py | 80 ++++------
.../api/test_passkey_account_scope.py | 139 ++++++++++++++++++
web/src/app/login/page.tsx | 6 +
3 files changed, 175 insertions(+), 50 deletions(-)
create mode 100644 tests/integration/api/test_passkey_account_scope.py
diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py
index 365f5964e..ee2ee7dc9 100644
--- a/src/langbot/pkg/api/http/controller/groups/user.py
+++ b/src/langbot/pkg/api/http/controller/groups/user.py
@@ -267,8 +267,8 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'user': user_email})
- @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
+ @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
# Check if password change is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
@@ -282,13 +282,13 @@ class UserRouterGroup(group.RouterGroup):
new_password = json_data['new_password']
try:
- await self.ap.user_service.change_password(user_email, current_password, new_password)
+ await self.ap.user_service.change_password(account.normalized_email, current_password, new_password)
except argon2.exceptions.VerifyMismatchError:
return self.http_status(400, -1, 'Current password is incorrect')
except ValueError as e:
return self.http_status(400, -1, str(e))
- return self.success(data={'user': user_email})
+ return self.success(data={'user': account.user})
# Space OAuth endpoints (redirect flow)
@@ -323,19 +323,19 @@ class UserRouterGroup(group.RouterGroup):
except ValueError as e:
return self.fail(1, str(e))
- @self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(request_context: RequestContext) -> str:
+ @self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
"""Issue an account-bound, one-time Space OAuth redirect."""
redirect_uri = quart.request.args.get('redirect_uri', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
- if not request_context.account_uuid:
+ if not account.uuid:
return self.http_status(403, 'account_required', 'An Account is required')
try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=True)
state = await self.ap.user_service.issue_space_oauth_state(
'bind',
- account_uuid=request_context.account_uuid,
+ account_uuid=account.uuid,
)
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
@@ -507,8 +507,8 @@ class UserRouterGroup(group.RouterGroup):
capabilities['passkey_supported'] = True
return self.success(data={'initialized': True, **capabilities})
- @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
+ @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
"""Set password for Space account (first time) or change password"""
# Check if modifying login info is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
@@ -524,13 +524,9 @@ class UserRouterGroup(group.RouterGroup):
if not new_password:
return self.http_status(400, -1, 'New password is required')
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
- if user_obj is None:
- return self.http_status(404, -1, 'User not found')
-
try:
- await self.ap.user_service.set_password(user_email, new_password, current_password)
- return self.success(data={'user': user_email})
+ await self.ap.user_service.set_password(account.normalized_email, new_password, current_password)
+ return self.success(data={'user': account.user})
except ValueError as e:
return self.http_status(400, -1, str(e))
except argon2.exceptions.VerifyMismatchError:
@@ -595,8 +591,12 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
raise
- @self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
+ # Passkeys belong to the Account, not to a Workspace: these routes are
+ # ACCOUNT_TOKEN scoped for the same reason as the second-factor routes
+ # below. Resolving the caller through the login name would also miss every
+ # Account whose login name is not its email.
+ @self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
"""Generate WebAuthn registration options for current account."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
@@ -604,16 +604,12 @@ class UserRouterGroup(group.RouterGroup):
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
- if user_obj is None:
- return self.http_status(404, -1, 'User not found')
-
json_data = (await quart.request.json) or {}
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
- account_uuid=user_obj.uuid,
+ account_uuid=account.uuid,
rp_id=rp_id,
origin=origin,
rp_name='LangBot',
@@ -622,8 +618,8 @@ class UserRouterGroup(group.RouterGroup):
except Exception as e:
return self.fail(1, str(e))
- @self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
+ @self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
"""Verify WebAuthn registration response and save credential."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
@@ -631,10 +627,6 @@ class UserRouterGroup(group.RouterGroup):
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
- if user_obj is None:
- return self.http_status(404, -1, 'User not found')
-
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
@@ -700,14 +692,10 @@ class UserRouterGroup(group.RouterGroup):
except Exception as e:
return self.fail(1, str(e))
- @self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
+ @self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
"""List registered passkeys for the current user."""
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
- if user_obj is None:
- return self.http_status(404, -1, 'User not found')
-
- passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
+ passkeys = await self.ap.user_service.get_user_passkeys(account.uuid)
return self.success(
data=[
{
@@ -723,8 +711,8 @@ class UserRouterGroup(group.RouterGroup):
]
)
- @self.route('/passkey/', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str, passkey_uuid: str) -> str:
+ @self.route('/passkey/', methods=['PATCH'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account, passkey_uuid: str) -> str:
"""Rename a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
@@ -732,17 +720,13 @@ class UserRouterGroup(group.RouterGroup):
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
- if user_obj is None:
- return self.http_status(404, -1, 'User not found')
-
json_data = await quart.request.json
name = (json_data.get('name') or '').strip()
if not name:
return self.fail(1, 'Passkey name cannot be empty')
updated = await self.ap.user_service.rename_user_passkey(
- account_uuid=user_obj.uuid,
+ account_uuid=account.uuid,
passkey_uuid=passkey_uuid,
new_name=name,
)
@@ -750,8 +734,8 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(404, -1, 'Passkey not found')
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
- @self.route('/passkey/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str, passkey_uuid: str) -> str:
+ @self.route('/passkey/', methods=['DELETE'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account, passkey_uuid: str) -> str:
"""Delete/revoke a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
@@ -759,12 +743,8 @@ class UserRouterGroup(group.RouterGroup):
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
- if user_obj is None:
- return self.http_status(404, -1, 'User not found')
-
deleted = await self.ap.user_service.delete_user_passkey(
- account_uuid=user_obj.uuid,
+ account_uuid=account.uuid,
passkey_uuid=passkey_uuid,
)
if not deleted:
diff --git a/tests/integration/api/test_passkey_account_scope.py b/tests/integration/api/test_passkey_account_scope.py
new file mode 100644
index 000000000..8f6ce38bc
--- /dev/null
+++ b/tests/integration/api/test_passkey_account_scope.py
@@ -0,0 +1,139 @@
+"""Account scoping for the passkey and password routes.
+
+A passkey belongs to the Account, so these routes must answer for the Account the
+caller authenticated as. They previously required a Workspace (which the WebUI
+never sends for them) and then resolved the Account by looking the login name up
+as an email address, so every Account whose login name is not its email answered
+``404`` and the passkey feature was unreachable.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
+
+pytestmark = pytest.mark.integration
+
+ACCOUNT_UUID = '1905ad7c-168f-49f4-893e-e43614e4c4bf'
+LOGIN_NAME = 'dev'
+ACCOUNT_EMAIL = 'dev@local.test'
+
+
+@pytest.fixture
+async def account_api():
+ # The login name deliberately differs from the email: that is what made the
+ # email lookup miss the authenticated Account.
+ account = SimpleNamespace(
+ uuid=ACCOUNT_UUID,
+ user=LOGIN_NAME,
+ normalized_email=ACCOUNT_EMAIL,
+ password='',
+ account_type='space',
+ )
+ application = Mock()
+ application.deployment = SimpleNamespace(multi_workspace_enabled=False)
+ application.instance_config.data = {'system': {'allow_modify_login_info': True}}
+ application.persistence_mgr = SimpleNamespace(tenant_uow=None)
+ application.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ application.user_service.get_user_by_email = AsyncMock(return_value=None)
+ application.user_service.get_user_passkeys = AsyncMock(return_value=[])
+ application.user_service.generate_passkey_registration_options = AsyncMock(
+ return_value=({'challenge': 'probe'}, 'challenge-token')
+ )
+ application.user_service.verify_and_save_passkey_registration = AsyncMock(
+ return_value=SimpleNamespace(uuid='credential-uuid', name='probe', created_at=None)
+ )
+ application.user_service.rename_user_passkey = AsyncMock(
+ return_value=SimpleNamespace(uuid='credential-uuid', name='renamed')
+ )
+ application.user_service.delete_user_passkey = AsyncMock(return_value=True)
+ application.user_service.set_password = AsyncMock()
+ application.user_service.change_password = AsyncMock()
+
+ quart_app = quart.Quart(__name__)
+ router = UserRouterGroup(application, quart_app)
+ await router.initialize()
+ return application, quart_app.test_client()
+
+
+def _headers() -> dict[str, str]:
+ # The WebUI sends an account session and no Workspace for these routes.
+ return {'Authorization': 'Bearer account-token'}
+
+
+async def test_passkey_list_answers_without_a_workspace(account_api):
+ application, client = account_api
+
+ response = await client.get('/api/v1/user/passkeys', headers=_headers())
+
+ assert response.status_code == 200, await response.get_data(as_text=True)
+ application.user_service.get_user_passkeys.assert_awaited_once_with(ACCOUNT_UUID)
+ application.user_service.get_user_by_email.assert_not_awaited()
+
+
+async def test_passkey_registration_options_bind_the_authenticated_account(account_api):
+ application, client = account_api
+
+ response = await client.post(
+ '/api/v1/user/passkey/register/options',
+ json={'origin': 'http://localhost:3000'},
+ headers=_headers(),
+ )
+
+ assert response.status_code == 200, await response.get_data(as_text=True)
+ assert (await response.get_json())['data']['challenge_token'] == 'challenge-token'
+ options_call = application.user_service.generate_passkey_registration_options.await_args
+ assert options_call.kwargs['account_uuid'] == ACCOUNT_UUID
+ assert options_call.kwargs['rp_id'] == 'localhost'
+ application.user_service.get_user_by_email.assert_not_awaited()
+
+
+async def test_passkey_verify_and_revoke_address_the_authenticated_account(account_api):
+ application, client = account_api
+
+ verified = await client.post(
+ '/api/v1/user/passkey/register/verify',
+ json={'challenge_token': 'challenge-token', 'credential': {'id': 'cred'}},
+ headers=_headers(),
+ )
+ renamed = await client.patch(
+ '/api/v1/user/passkey/credential-uuid',
+ json={'name': 'renamed'},
+ headers=_headers(),
+ )
+ deleted = await client.delete('/api/v1/user/passkey/credential-uuid', headers=_headers())
+
+ assert verified.status_code == 200, await verified.get_data(as_text=True)
+ assert renamed.status_code == 200, await renamed.get_data(as_text=True)
+ assert deleted.status_code == 200, await deleted.get_data(as_text=True)
+ assert application.user_service.rename_user_passkey.await_args.kwargs['account_uuid'] == ACCOUNT_UUID
+ assert application.user_service.delete_user_passkey.await_args.kwargs['account_uuid'] == ACCOUNT_UUID
+ application.user_service.get_user_by_email.assert_not_awaited()
+
+
+async def test_password_routes_target_the_authenticated_account(account_api):
+ application, client = account_api
+
+ set_password = await client.post(
+ '/api/v1/user/set-password',
+ json={'new_password': 'new-secret'},
+ headers=_headers(),
+ )
+ change_password = await client.post(
+ '/api/v1/user/change-password',
+ json={'current_password': 'old-secret', 'new_password': 'new-secret'},
+ headers=_headers(),
+ )
+
+ assert set_password.status_code == 200, await set_password.get_data(as_text=True)
+ assert change_password.status_code == 200, await change_password.get_data(as_text=True)
+ # The service resolves the row by email, so the route hands it the email of the
+ # account the token identified -- never the login name it was given.
+ assert application.user_service.set_password.await_args.args[0] == ACCOUNT_EMAIL
+ assert application.user_service.change_password.await_args.args[0] == ACCOUNT_EMAIL
+ application.user_service.get_user_by_email.assert_not_awaited()
diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx
index d0134bb4c..d342909ed 100644
--- a/web/src/app/login/page.tsx
+++ b/web/src/app/login/page.tsx
@@ -212,6 +212,12 @@ export default function Login() {
);
const authResp = await startAuthentication({ optionsJSON: options });
const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp);
+ // A rejected assertion answers with an error payload and no session, so a
+ // missing token means the login failed rather than that the user cancelled.
+ if (!res?.token) {
+ toast.error(t('common.passkeyLoginFailed'));
+ return;
+ }
if (await finishLogin(res.token, res.user)) {
toast.success(t('common.passkeyLoginSuccess'));
}
From 1fde3b4283bead3335459f1004ea1127fd7c6081 Mon Sep 17 00:00:00 2001
From: RockChinQ <1010553892@qq.com>
Date: Tue, 29 Sep 2026 00:56:18 +0800
Subject: [PATCH 014/140] fix(certification): align certified sharing and
unsigned dedicated admission (#2610)
Integrate certification policy, SDK/Core semantics, and documentation on canonical branch. Resolve duplicated documentation and keep invalid Cloud signatures rejected.
---
docs/architecture/certified-plugins.md | 45 +++++++------
docs/pipeline-migration-config-map.zh-CN.md | 2 +-
src/langbot/pkg/plugin/certification.py | 11 +++-
src/langbot/pkg/plugin/connector.py | 5 +-
.../plugin/test_certified_plugin_admission.py | 2 +-
.../plugin/test_certified_plugin_policy.py | 64 +++++++++++++++++--
.../plugin/test_connector_methods.py | 1 +
web/src/i18n/locales/zh-Hans.ts | 3 +-
web/src/i18n/locales/zh-Hant.ts | 3 +-
9 files changed, 103 insertions(+), 33 deletions(-)
diff --git a/docs/architecture/certified-plugins.md b/docs/architecture/certified-plugins.md
index 7af3bd4f4..8dc54ae88 100644
--- a/docs/architecture/certified-plugins.md
+++ b/docs/architecture/certified-plugins.md
@@ -17,7 +17,10 @@ metadata.
## Trusted issuer configuration
-Configure the non-secret Ed25519 public-key ring in `data/config.yaml`:
+Hosted Cloud provisions the non-secret Ed25519 issuer public-key ring out of
+band. Key IDs must match the SDK envelope; values are base64 public keys, never
+private signing keys. Invalid configuration fails closed; keep old issuer keys
+through rotation while their signed archives remain installed.
```yaml
plugin:
@@ -39,10 +42,11 @@ PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON='{"ed25519:issuer":""}'
```
An **empty** ring is a supported state, not a misconfiguration. OSS defaults to
-it, so a self-hosted instance that has not provisioned any issuer key still
-installs packages (see the admission matrix below). Configure the ring to grant
-the shared-runtime profile; leave it empty to keep every package on the
-dedicated profile.
+it and supports one Workspace; configuring certification keys on OSS is not a
+supported way to enable cross-tenant sharing. Cloud provisions the ring to grant
+shared placement only to eligible v2 artifacts. Certification means eligibility
+for Cloud cross-tenant use of the same Worker and the same plugin/component
+singleton, never a dedicated certificate or intermediate trust tier.
## Admission matrix
@@ -52,21 +56,20 @@ dedicated profile.
| Cloud | no signature | any | install on dedicated worker, without shared eligibility |
| Cloud | malformed, untrusted, invalid, or non-shared declaration | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID`; do not treat a broken signature as unsigned |
| OSS | absent legacy envelope | any | admitted to the dedicated profile |
-| OSS | valid envelope declaring `shared-runtime-v1` + `stateless-v1` | any | selected shared singleton profile |
-| OSS | declaration signed by a **key this instance resolves** | false | reject with `CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` |
-| OSS | declaration signed by a **key this instance resolves** | true | admitted to the dedicated profile |
+| OSS | valid envelope declaring `shared-runtime-v1` + `stateless-v1` | any | dedicated only; OSS has no cross-tenant shared placement |
+| OSS | invalid declaration referencing a **key this instance resolves** | false | reject with `CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` |
+| OSS | invalid declaration referencing a **key this instance resolves** | true | admitted to the dedicated profile |
| OSS | declaration this instance **cannot resolve** (empty ring) | any | admitted to the dedicated profile |
-The OSS row that matters for availability is the last one. Marketplace
-packages are signed by the marketplace issuer and declare
-`shared-runtime-v1`, while OSS ships an empty key ring by default. Treating that
-as a rejection made every certified marketplace package uninstallable with
-`CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` before artifact storage. Because the
-certificate is signed by an issuer the instance does not declare trusted, no
-shared-runtime privilege may be granted, so admission degrades the install to
-the existing `oss_dev` dedicated profile and records
-`CERTIFIED_PLUGIN_OSS_UNTRUSTED_DEDICATED`. This is not an escalation: it
-withholds the shared profile rather than granting it.
+There is no dedicated certification, dedicated signature, or intermediate
+certification trust tier. Advisory review is an internal prerequisite, not an
+installation trust status. An `issued` marketplace badge, source candidate,
+matching version, or shared artifact/dependency tree alone does not prove Cloud
+admission or Worker sharing. Compare the downloaded archive's ZIP comment,
+normalized digest and signed claims with the public version record and deployed
+Core trust-key ring. Confirm two Workspace bindings share one Worker PID, one
+plugin object, and one object for each declared component before claiming live
+cross-tenant sharing.
A declaration is "resolvable" only when its `key_id` is present in the
configured ring. A parseable declaration from a resolved key that fails
@@ -78,7 +81,11 @@ resolved certificate identity are treated as untrusted and stay dedicated.
install request carries boolean `true`. The local upload endpoint accepts the
multipart field `administrator_force=true`; GitHub and marketplace install
payloads carry the same field. The existing resource-manage authorization fence
-protects those endpoints. A force never creates a Cloud dedicated fallback.
+protects those endpoints. A force never creates a Cloud dedicated fallback or
+certifies an invalid signature. A legacy v1 certificate may verify
+cryptographically, but without a signed stateless component claim it is
+rejected in Cloud; an old marketplace `issued` badge alone grants no placement.
+Unsigned Cloud archives are the dedicated fallback.
## Runtime and logs
diff --git a/docs/pipeline-migration-config-map.zh-CN.md b/docs/pipeline-migration-config-map.zh-CN.md
index 672a6c8c7..482a6bbeb 100644
--- a/docs/pipeline-migration-config-map.zh-CN.md
+++ b/docs/pipeline-migration-config-map.zh-CN.md
@@ -26,7 +26,7 @@
## 插件版本要求
-以下是迁移器要求的已发布 Certified Plugin 版本。旧版本即使名称相同,也不能作为当前 Cloud 迁移能力的证明。
+以下是迁移器要求的插件最低版本,不是认证版本清单。已发布的专属运行版本不等于已认证可跨工作区共享;旧版本即使名称相同,也不能作为当前 Cloud 迁移能力的证明。认证须由精确制品的有效签名及 `shared-runtime-v1`、`stateless-v1` 声明证明。
- `local-agent` → `LocalAgent` **0.1.10**。
- `dify-service-api` → `DifyAgent` **0.1.10**。
diff --git a/src/langbot/pkg/plugin/certification.py b/src/langbot/pkg/plugin/certification.py
index f5a73674c..b6eabaf0f 100644
--- a/src/langbot/pkg/plugin/certification.py
+++ b/src/langbot/pkg/plugin/certification.py
@@ -199,7 +199,7 @@ def decide_plugin_admission(
mode = DeploymentMode(deployment)
certificate = facts.certificate
- if certificate.is_valid_shared_runtime:
+ if mode is DeploymentMode.CLOUD and certificate.is_valid_shared_runtime:
return PluginAdmissionDecision(
AdmissionDisposition.SHARED_ELIGIBLE,
AdmissionCode.SHARED_ELIGIBLE,
@@ -213,6 +213,15 @@ def decide_plugin_admission(
DEDICATED_RUNTIME,
)
+ # OSS has no cross-tenant shared placement; a verified claim stays dedicated
+ # there without inventing a dedicated certification tier.
+ if mode is DeploymentMode.OSS and certificate.is_valid_shared_runtime:
+ return PluginAdmissionDecision(
+ AdmissionDisposition.DEDICATED_ALLOWED,
+ AdmissionCode.OSS_UNTRUSTED_DEDICATED,
+ DEDICATED_RUNTIME,
+ )
+
if mode is DeploymentMode.CLOUD:
return PluginAdmissionDecision(
AdmissionDisposition.REJECTED, AdmissionCode.CLOUD_CERTIFICATE_INVALID, DEDICATED_RUNTIME
diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py
index 4ae024792..c453e016a 100644
--- a/src/langbot/pkg/plugin/connector.py
+++ b/src/langbot/pkg/plugin/connector.py
@@ -345,8 +345,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
artifact_digest=setting.artifact_digest,
)
- @staticmethod
- def _execution_mode_from_setting(setting: persistence_plugin.PluginSetting) -> PluginExecutionMode:
+ def _execution_mode_from_setting(self, setting: persistence_plugin.PluginSetting) -> PluginExecutionMode:
+ if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
+ return PluginExecutionMode.DEDICATED
return execution_mode_for_persisted_installation(
artifact_digest=setting.artifact_digest,
install_info=setting.install_info,
diff --git a/tests/integration/plugin/test_certified_plugin_admission.py b/tests/integration/plugin/test_certified_plugin_admission.py
index ce9b5eb62..74cd4a2bd 100644
--- a/tests/integration/plugin/test_certified_plugin_admission.py
+++ b/tests/integration/plugin/test_certified_plugin_admission.py
@@ -30,7 +30,7 @@ pytestmark = pytest.mark.integration
[
('cloud', 'signed_shared', False, 'shared-runtime-v1'),
('cloud', 'legacy', False, 'dedicated'),
- ('oss', 'signed_shared', False, 'shared-runtime-v1'),
+ ('oss', 'signed_shared', False, 'dedicated'),
('oss', 'legacy', False, 'dedicated'),
('oss', 'forged_shared', True, 'dedicated'),
],
diff --git a/tests/unit_tests/plugin/test_certified_plugin_policy.py b/tests/unit_tests/plugin/test_certified_plugin_policy.py
index f2bf45fd7..29d0ae22f 100644
--- a/tests/unit_tests/plugin/test_certified_plugin_policy.py
+++ b/tests/unit_tests/plugin/test_certified_plugin_policy.py
@@ -12,7 +12,14 @@ from langbot_plugin.entities.io.context import PluginExecutionMode
@pytest.mark.parametrize(
('deployment', 'certificate', 'certificate_id', 'force', 'expected_disposition', 'expected_code'),
[
- ('cloud', ('valid', 'shared-runtime-v1'), 'issuer', False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'),
+ (
+ 'cloud',
+ ('valid', 'shared-runtime-v1'),
+ 'issuer',
+ False,
+ 'shared_eligible',
+ 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE',
+ ),
('cloud', ('absent', None), None, False, 'dedicated_allowed', 'CERTIFIED_PLUGIN_UNSIGNED_DEDICATED'),
('cloud', ('absent', None), None, True, 'dedicated_allowed', 'CERTIFIED_PLUGIN_UNSIGNED_DEDICATED'),
('cloud', ('malformed', None), None, False, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID'),
@@ -25,7 +32,14 @@ from langbot_plugin.entities.io.context import PluginExecutionMode
'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID',
),
('oss', ('absent', None), None, False, 'dedicated_allowed', 'CERTIFIED_PLUGIN_OSS_LEGACY_DEDICATED'),
- ('oss', ('valid', 'shared-runtime-v1'), 'issuer', False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'),
+ (
+ 'oss',
+ ('valid', 'shared-runtime-v1'),
+ 'issuer',
+ False,
+ 'dedicated_allowed',
+ 'CERTIFIED_PLUGIN_OSS_UNTRUSTED_DEDICATED',
+ ),
(
'oss',
('invalid', 'shared-runtime-v1'),
@@ -87,9 +101,7 @@ def test_admission_policy_enforces_certification_matrix(
verification=CertificateVerification(verification),
runtime_profile=runtime_profile,
component_model=(
- 'stateless-v1'
- if verification == 'valid' and runtime_profile == 'shared-runtime-v1'
- else None
+ 'stateless-v1' if verification == 'valid' and runtime_profile == 'shared-runtime-v1' else None
),
certificate_id=certificate_id,
),
@@ -135,10 +147,48 @@ def test_legacy_shared_certificate_without_stateless_contract_is_not_shared() ->
assert decision.disposition is AdmissionDisposition.REJECTED
+def test_oss_never_selects_shared_even_with_a_resolved_valid_certificate() -> None:
+ from langbot.pkg.plugin.certification import (
+ CertificateFacts,
+ CertificateVerification,
+ PluginCertificationFacts,
+ decide_plugin_admission,
+ )
+
+ facts = PluginCertificationFacts(
+ 'installation',
+ 'a' * 64,
+ CertificateFacts(
+ CertificateVerification.VALID,
+ 'shared-runtime-v1',
+ 'stateless-v1',
+ 'issuer',
+ ),
+ )
+ decision = decide_plugin_admission(deployment='oss', facts=facts)
+ assert decision.runtime_profile == 'dedicated'
+ assert decision.disposition.value == 'dedicated_allowed'
+
+
+def test_oss_reconcile_never_reuses_persisted_shared_placement() -> None:
+ from types import SimpleNamespace
+ from langbot.pkg.plugin.connector import PluginRuntimeConnector
+
+ connector = object.__new__(PluginRuntimeConnector)
+ connector.ap = SimpleNamespace(deployment=SimpleNamespace(mode='oss'))
+ setting = SimpleNamespace(
+ artifact_digest='a' * 64, install_info={'_certification': _complete_persisted_certification()}
+ )
+ assert connector._execution_mode_from_setting(setting) is PluginExecutionMode.DEDICATED
+
+
def test_oss_does_not_treat_valid_nonshared_signature_as_dedicated_certification() -> None:
from langbot.pkg.plugin.certification import (
- AdmissionDisposition, CertificateFacts, CertificateVerification,
- PluginCertificationFacts, decide_plugin_admission,
+ AdmissionDisposition,
+ CertificateFacts,
+ CertificateVerification,
+ PluginCertificationFacts,
+ decide_plugin_admission,
)
decision = decide_plugin_admission(
diff --git a/tests/unit_tests/plugin/test_connector_methods.py b/tests/unit_tests/plugin/test_connector_methods.py
index 99e56bd41..c971324ba 100644
--- a/tests/unit_tests/plugin/test_connector_methods.py
+++ b/tests/unit_tests/plugin/test_connector_methods.py
@@ -749,6 +749,7 @@ class TestSetPluginConfig:
"""Config changes are fenced by a new runtime revision."""
get_connector_module()
connector = create_mock_connector()
+ connector.ap.deployment.mode = 'cloud'
configure_handler(connector, AsyncMock())
connector.handler.register_installation_binding = Mock()
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index d206bea70..aa8fd2f9e 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -2175,7 +2175,8 @@ const zhHans = {
totpRecoveryCodesRegenerated: '已生成新的恢复代码',
totpStatusDisabled: '未启用',
totpCodesRemaining: '剩余 {{count}} 个恢复代码',
- totpManagerSectionDesc: '所有者和管理员可以查看并重置本工作区账户的两步验证。',
+ totpManagerSectionDesc:
+ '所有者和管理员可以查看并重置本工作区账户的两步验证。',
revokeTotp: '重新绑定',
totpAdminResetTitle: '重新绑定 {{user}} 的两步验证',
totpAdminResetDesc:
diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts
index c6dd57b15..438577fc4 100644
--- a/web/src/i18n/locales/zh-Hant.ts
+++ b/web/src/i18n/locales/zh-Hant.ts
@@ -2172,7 +2172,8 @@ const zhHant = {
totpRecoveryCodesRegenerated: '已產生新的恢復代碼',
totpStatusDisabled: '未啟用',
totpCodesRemaining: '剩餘 {{count}} 個恢復代碼',
- totpManagerSectionDesc: '擁有者與管理員可以檢視並重設本工作區帳號的兩步驗證。',
+ totpManagerSectionDesc:
+ '擁有者與管理員可以檢視並重設本工作區帳號的兩步驗證。',
revokeTotp: '重新綁定',
totpAdminResetTitle: '重新綁定 {{user}} 的兩步驗證',
totpAdminResetDesc:
From 6022c471d584524fadf4e5a293399dd160612a4a Mon Sep 17 00:00:00 2001
From: RockChinQ <1010553892@qq.com>
Date: Tue, 29 Sep 2026 21:25:19 +0800
Subject: [PATCH 015/140] fix(i18n): complete beta banner translations (#2613)
* fix(i18n): complete beta banner translations
* style(i18n): format beta banner messages
---
web/src/i18n/locales/es-ES.ts | 9 +++++++++
web/src/i18n/locales/ru-RU.ts | 9 +++++++++
web/src/i18n/locales/th-TH.ts | 9 +++++++++
web/src/i18n/locales/vi-VN.ts | 9 +++++++++
web/src/i18n/locales/zh-Hant.ts | 8 ++++++++
5 files changed, 44 insertions(+)
diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts
index 80876513f..31ef6e9e1 100644
--- a/web/src/i18n/locales/es-ES.ts
+++ b/web/src/i18n/locales/es-ES.ts
@@ -3383,5 +3383,14 @@ const esES = {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
},
+ beta_banner: {
+ message:
+ 'Este entorno está en fase beta. La estabilidad del servicio no está garantizada. Considera usar',
+ cloud_link: 'LangBot Cloud (entorno dedicado)',
+ or: 'o',
+ oss_link: 'la versión de código abierto autoalojada',
+ period: '.',
+ dismiss: 'Cerrar',
+ },
};
export default esES;
diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts
index eca8e3767..d7e5c9937 100644
--- a/web/src/i18n/locales/ru-RU.ts
+++ b/web/src/i18n/locales/ru-RU.ts
@@ -3342,5 +3342,14 @@ const ruRU = {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
},
+ beta_banner: {
+ message:
+ 'Эта среда находится в стадии бета-тестирования. Стабильность сервиса не гарантируется. Рекомендуем использовать',
+ cloud_link: 'LangBot Cloud (выделенная среда)',
+ or: 'или',
+ oss_link: 'версию с открытым исходным кодом на своём сервере',
+ period: '.',
+ dismiss: 'Закрыть',
+ },
};
export default ruRU;
diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts
index 99b465e5c..de83ce95b 100644
--- a/web/src/i18n/locales/th-TH.ts
+++ b/web/src/i18n/locales/th-TH.ts
@@ -3251,5 +3251,14 @@ const thTH = {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
},
+ beta_banner: {
+ message:
+ 'สภาพแวดล้อมนี้อยู่ระหว่างการทดสอบเบต้า ไม่รับประกันความเสถียรของบริการ โปรดพิจารณาใช้',
+ cloud_link: 'LangBot Cloud (สภาพแวดล้อมเฉพาะ)',
+ or: 'หรือ',
+ oss_link: 'เวอร์ชันโอเพนซอร์สที่โฮสต์เอง',
+ period: '',
+ dismiss: 'ปิด',
+ },
};
export default thTH;
diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts
index 5e3dbfc60..dd42a0af1 100644
--- a/web/src/i18n/locales/vi-VN.ts
+++ b/web/src/i18n/locales/vi-VN.ts
@@ -3299,5 +3299,14 @@ const viVN = {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
},
+ beta_banner: {
+ message:
+ 'Môi trường này đang trong giai đoạn thử nghiệm beta. Độ ổn định của dịch vụ không được đảm bảo. Hãy cân nhắc sử dụng',
+ cloud_link: 'LangBot Cloud (môi trường riêng)',
+ or: 'hoặc',
+ oss_link: 'phiên bản mã nguồn mở tự lưu trữ',
+ period: '.',
+ dismiss: 'Đóng',
+ },
};
export default viVN;
diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts
index 438577fc4..600ce965f 100644
--- a/web/src/i18n/locales/zh-Hant.ts
+++ b/web/src/i18n/locales/zh-Hant.ts
@@ -3125,5 +3125,13 @@ const zhHant = {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',
},
+ beta_banner: {
+ message: '此環境正處於 Beta 測試階段,服務穩定性尚未保證。建議使用',
+ cloud_link: 'LangBot Cloud(獨立環境)',
+ or: '或',
+ oss_link: '自行部署的開源版本',
+ period: '。',
+ dismiss: '關閉',
+ },
};
export default zhHant;
From 05de72570db675ee8aabd322994ff3ce6378fbec Mon Sep 17 00:00:00 2001
From: Hyu
Date: Wed, 30 Sep 2026 14:46:08 +0800
Subject: [PATCH 016/140] build(core): pin plugin SDK 0.7.5
Pick up the shared-worker startup and installation-binding fixes released as
langbot-plugin 0.7.5 (tag v0.7.5), so Core deployments stop installing the
0.7.4 wheel that cannot launch shared pool workers and drops the installation
binding on plugin dispatches and relays.
---
pyproject.toml | 2 +-
uv.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 8ad6dc9df..293a9de21 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -71,7 +71,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
- "langbot-plugin @ https://files.pythonhosted.org/packages/f2/c7/c870377771cbe27e047b19778b5f249b15efef52409358901af94d13e6c0/langbot_plugin-0.7.4-py3-none-any.whl#sha256=596ecd776c13efa6f6cfe178463dc4a967653238548c9569195cdbb5aa47506e",
+ "langbot-plugin @ https://files.pythonhosted.org/packages/51/cf/314abe6775b05f23381f66dba707756a984010b36e72a4aecc03d26d9b3c/langbot_plugin-0.7.5-py3-none-any.whl#sha256=a20fba9d331d29b0969dc4e12880269165a522f56fff146c0ef71f38a19bdc2e",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
diff --git a/uv.lock b/uv.lock
index ab0ba2de4..9821ed6dc 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2185,7 +2185,7 @@ requires-dist = [
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
- { name = "langbot-plugin", url = "https://files.pythonhosted.org/packages/f2/c7/c870377771cbe27e047b19778b5f249b15efef52409358901af94d13e6c0/langbot_plugin-0.7.4-py3-none-any.whl" },
+ { name = "langbot-plugin", url = "https://files.pythonhosted.org/packages/51/cf/314abe6775b05f23381f66dba707756a984010b36e72a4aecc03d26d9b3c/langbot_plugin-0.7.5-py3-none-any.whl" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2255,8 +2255,8 @@ dev = [
[[package]]
name = "langbot-plugin"
-version = "0.7.4"
-source = { url = "https://files.pythonhosted.org/packages/f2/c7/c870377771cbe27e047b19778b5f249b15efef52409358901af94d13e6c0/langbot_plugin-0.7.4-py3-none-any.whl" }
+version = "0.7.5"
+source = { url = "https://files.pythonhosted.org/packages/51/cf/314abe6775b05f23381f66dba707756a984010b36e72a4aecc03d26d9b3c/langbot_plugin-0.7.5-py3-none-any.whl" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2277,7 +2277,7 @@ dependencies = [
{ name = "websockets" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/f2/c7/c870377771cbe27e047b19778b5f249b15efef52409358901af94d13e6c0/langbot_plugin-0.7.4-py3-none-any.whl", hash = "sha256:596ecd776c13efa6f6cfe178463dc4a967653238548c9569195cdbb5aa47506e" },
+ { url = "https://files.pythonhosted.org/packages/51/cf/314abe6775b05f23381f66dba707756a984010b36e72a4aecc03d26d9b3c/langbot_plugin-0.7.5-py3-none-any.whl", hash = "sha256:a20fba9d331d29b0969dc4e12880269165a522f56fff146c0ef71f38a19bdc2e" },
]
[package.metadata]
From e3e45d7b3493208b1adbb46937afa3d9b2fe7a39 Mon Sep 17 00:00:00 2001
From: Hyu
Date: Wed, 30 Sep 2026 15:47:59 +0800
Subject: [PATCH 017/140] fix(api): keep the Workspace tenant scope for
streamed Agent debug runs
The NDJSON debug stream emits its frames after the request handler returned,
so the request-scoped tenant scope is already closed by then. The deferred
execution therefore failed with TenantScopeRequiredError on its first
persistence access and the client only saw a generic runner_error frame with
no log entry.
Re-enter the Workspace tenant scope around the deferred execution and log
unexpected failures with their traceback.
---
.../controller/groups/agent_debug_stream.py | 22 ++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py
index 272f7bd92..a0480541a 100644
--- a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py
+++ b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py
@@ -26,8 +26,25 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q
await queue.put({'kind': 'result', 'data': result})
async def execute() -> None:
+ application = getattr(service, 'ap', None)
+ persistence_mgr = getattr(application, 'persistence_mgr', None)
+ tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
+ scope = (
+ tenant_scope(context.workspace_uuid)
+ if callable(tenant_scope)
+ else contextlib.nullcontext()
+ )
try:
- result = await service.debug_agent(context, agent_uuid, payload, on_result=on_result)
+ # The streamed body is emitted after the request handler returned,
+ # so the request's tenant scope has already closed: carry the
+ # trusted Workspace identity into the deferred execution.
+ async with scope:
+ result = await service.debug_agent(
+ context,
+ agent_uuid,
+ payload,
+ on_result=on_result,
+ )
await queue.put({'kind': 'completed', 'data': result})
except Exception as exc:
# The stream still uses HTTP 200 when execution returns an
@@ -46,6 +63,9 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q
elif isinstance(exc, RunnerError):
code, message = 'runner_error', 'The Agent runner could not complete this test'
else:
+ logger = getattr(application, 'logger', None)
+ if logger is not None:
+ logger.exception('Agent debug stream execution failed')
code, message = 'runner_error', 'The Agent debug execution failed'
await queue.put({'kind': 'error', 'code': code, 'msg': message})
From 5758f207683310675cb1985483177d1cf9afa4ff Mon Sep 17 00:00:00 2001
From: Hyu
Date: Wed, 30 Sep 2026 15:47:59 +0800
Subject: [PATCH 018/140] fix(vector): do not bind a query dimension for
text-only search
A full-text search sends no query vector by design, but search() validated
len([]) == 0 as the expected embedding dimension, so the request failed with
'Embedding dimension 0 is not enabled for this deployment' and hid the
backend capability error. An empty query vector now means no expected
dimension to verify.
---
src/langbot/pkg/vector/mgr.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/langbot/pkg/vector/mgr.py b/src/langbot/pkg/vector/mgr.py
index 21e868223..03424af10 100644
--- a/src/langbot/pkg/vector/mgr.py
+++ b/src/langbot/pkg/vector/mgr.py
@@ -356,7 +356,10 @@ class VectorDBManager:
scope = await self._resolve_pgvector_scope(
execution_context,
knowledge_base_uuid,
- expected_dimension=len(query_vector),
+ # A text-only search sends no query vector; there is no dimension
+ # to bind, and validating an empty vector as dimension 0 would
+ # mask the backend capability error.
+ expected_dimension=len(query_vector) or None,
initialize_dimension=False,
)
results = await pgvector.search(
From 0c3917ee0fd189e1e5718efe4c6f1688c36f0943 Mon Sep 17 00:00:00 2001
From: Hyu
Date: Wed, 30 Sep 2026 15:50:48 +0800
Subject: [PATCH 019/140] style(api): format the streamed Agent debug helper
with ruff
---
.../pkg/api/http/controller/groups/agent_debug_stream.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py
index a0480541a..65c198c25 100644
--- a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py
+++ b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py
@@ -29,11 +29,7 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q
application = getattr(service, 'ap', None)
persistence_mgr = getattr(application, 'persistence_mgr', None)
tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
- scope = (
- tenant_scope(context.workspace_uuid)
- if callable(tenant_scope)
- else contextlib.nullcontext()
- )
+ scope = tenant_scope(context.workspace_uuid) if callable(tenant_scope) else contextlib.nullcontext()
try:
# The streamed body is emitted after the request handler returned,
# so the request's tenant scope has already closed: carry the
From ff3f3211206d14ce46470748dc7e771aaee6c6a0 Mon Sep 17 00:00:00 2001
From: Hyu
Date: Wed, 30 Sep 2026 18:44:16 +0800
Subject: [PATCH 020/140] fix(tools): resolve the sandbox interpreter for
workspace file scripts
The workspace file tools (read/write/edit/glob/grep) ran a script through
`python`, which a sandbox built from a host rootfs may not provide: those hosts
ship `python3` only, and a read-only /usr prevents creating a `python` shim
inside the jail. Every file tool then failed with `/bin/sh: 1: python: not
found`. Resolve the interpreter with `command -v python3 || command -v python`,
matching the interpreter the Box service already uses for its own scripts.
---
.../pkg/provider/tools/loaders/native.py | 9 +++++-
.../provider/test_tool_manager_native.py | 32 +++++++++++++++++++
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py
index 602c22f62..89a4de0b8 100644
--- a/src/langbot/pkg/provider/tools/loaders/native.py
+++ b/src/langbot/pkg/provider/tools/loaders/native.py
@@ -754,9 +754,16 @@ class NativeToolLoader(loader.ToolLoader):
return f'{str(base).rstrip("/")}/{relative}'
async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict:
+ # Sandbox images built from a host rootfs may only ship `python3`, and a
+ # read-only /usr prevents creating a `python` shim inside the jail, so
+ # resolve the interpreter instead of assuming its name.
+ command = (
+ 'PYTHON_BIN=$(command -v python3 || command -v python); '
+ f"\"$PYTHON_BIN\" - <<'PY'\n{script}\nPY"
+ )
result = await self.ap.box_service.execute_tool(
{
- 'command': f"python - <<'PY'\n{script}\nPY",
+ 'command': command,
'timeout_sec': 30,
},
query,
diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py
index 7dd09f32f..1940eaa7d 100644
--- a/tests/unit_tests/provider/test_tool_manager_native.py
+++ b/tests/unit_tests/provider/test_tool_manager_native.py
@@ -851,3 +851,35 @@ async def test_box_grep_script_serializes_optional_include_as_python(monkeypatch
values[statement.targets[0].id] = ast.literal_eval(statement.value)
assert values['include'] == include
assert values['path'] == '/workspace'
+
+
+@pytest.mark.asyncio
+async def test_workspace_file_script_resolves_the_sandbox_interpreter():
+ """Sandboxes built from a host rootfs may only ship `python3`."""
+
+ captured: dict = {}
+
+ async def execute_tool(payload, query):
+ captured.update(payload)
+ return {'ok': True, 'stdout': '{"ok": true}'}
+
+ box_service = SimpleNamespace(
+ available=True,
+ execute_tool=execute_tool,
+ require_workspace_sandbox=AsyncMock(),
+ )
+ loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
+ query = SimpleNamespace(
+ _execution_context=_CONTEXT,
+ bot_uuid=None,
+ pipeline_uuid=None,
+ query_uuid=None,
+ )
+ query._box_binding = RunBoxBinding('run', 'box', {}, 'run')
+
+ result = await loader._run_workspace_file_script('print("x")', query)
+
+ assert result == {'ok': True}
+ command = captured['command']
+ assert command.startswith('PYTHON_BIN=$(command -v python3 || command -v python)')
+ assert command.endswith(" - <<'PY'\nprint(\"x\")\nPY")
From 4fc700b7e010c7d974afcc77f502fd66c22b318f Mon Sep 17 00:00:00 2001
From: Hyu
Date: Wed, 30 Sep 2026 18:44:16 +0800
Subject: [PATCH 021/140] fix(tools): hide register_skill when Cloud sandboxes
cannot scan host directories
scan_skill_directory is disabled for Cloud-managed sandboxes, but the
register_skill tool was still advertised to the model whenever a backend was
available, so every call failed closed without a usable explanation. Advertise
register_skill only when the runtime may scan sandbox directories, and keep
activate available in both modes.
---
.../provider/tools/loaders/skill_authoring.py | 33 ++++++++++++++-----
tests/unit_tests/provider/test_skill_tools.py | 20 +++++++++++
2 files changed, 44 insertions(+), 9 deletions(-)
diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py
index 4a9b91459..92e6ea0a0 100644
--- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py
+++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py
@@ -35,10 +35,7 @@ class SkillToolLoader(loader.ToolLoader):
# Check if sandbox backend is available (same check as native tools)
self._sandbox_available = await self._check_sandbox_available()
if self._sandbox_available:
- self._tools = [
- self._build_activate_skill_tool(),
- self._build_register_skill_tool(),
- ]
+ self._tools = self._build_skill_tools()
else:
self.ap.logger.info(
'Skill tools (activate/register_skill) are NOT available. '
@@ -54,14 +51,32 @@ class SkillToolLoader(loader.ToolLoader):
if not await self._is_available():
return []
if not self._tools:
- self._tools = [
- self._build_activate_skill_tool(),
- self._build_register_skill_tool(),
- ]
+ self._tools = self._build_skill_tools()
return list(self._tools)
+ def _build_skill_tools(self) -> list[resource_tool.LLMTool]:
+ tools = [self._build_activate_skill_tool()]
+ if self._skill_registration_available():
+ tools.append(self._build_register_skill_tool())
+ else:
+ self.ap.logger.info(
+ 'register_skill is NOT available: Cloud sandboxes never scan arbitrary '
+ 'host skill directories. Install skills through the skill store instead.'
+ )
+ return tools
+
+ def _skill_registration_available(self) -> bool:
+ """Host skill scanning is disabled for Cloud-managed sandboxes."""
+
+ box_service = getattr(self.ap, 'box_service', None)
+ return not bool(getattr(box_service, 'managed_admission_required', False))
+
async def has_tool(self, name: str) -> bool:
- return await self._is_available() and name in SKILL_TOOL_NAMES
+ if not await self._is_available() or name not in SKILL_TOOL_NAMES:
+ return False
+ if name == REGISTER_SKILL_TOOL_NAME:
+ return self._skill_registration_available()
+ return True
async def _is_available(self) -> bool:
"""Check if skill tools should be available.
diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py
index b0e7288ca..3ca95b272 100644
--- a/tests/unit_tests/provider/test_skill_tools.py
+++ b/tests/unit_tests/provider/test_skill_tools.py
@@ -737,3 +737,23 @@ async def test_native_skill_tools_require_runner_box_binding():
with pytest.raises(BoxValidationError, match='Runner must bind a Box'):
await loader.invoke_tool('exec', {'command': 'true'}, _make_query())
ap.box_service.execute_tool.assert_not_awaited()
+
+
+ @pytest.mark.asyncio
+ async def test_register_skill_hidden_when_cloud_scanning_is_disabled(self):
+ from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
+
+ ap = _make_ap()
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
+ ap.box_service = SimpleNamespace(
+ available=True,
+ managed_admission_required=True,
+ get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
+ )
+
+ loader = SkillToolLoader(ap)
+ await loader.initialize()
+
+ assert [tool.name for tool in await loader.get_tools()] == ['activate']
+ assert await loader.has_tool('register_skill') is False
+ assert await loader.has_tool('activate') is True
From 5a31ac75fc7261b33e73c3673d550a4a53b054c6 Mon Sep 17 00:00:00 2001
From: Hyu
Date: Wed, 30 Sep 2026 18:45:00 +0800
Subject: [PATCH 022/140] style(tools): format the sandbox interpreter and
skill tool changes with ruff
---
src/langbot/pkg/provider/tools/loaders/native.py | 5 +----
tests/unit_tests/provider/test_skill_tools.py | 1 -
tests/unit_tests/provider/test_tool_manager_native.py | 2 +-
3 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py
index 89a4de0b8..201f4c4a1 100644
--- a/src/langbot/pkg/provider/tools/loaders/native.py
+++ b/src/langbot/pkg/provider/tools/loaders/native.py
@@ -757,10 +757,7 @@ class NativeToolLoader(loader.ToolLoader):
# Sandbox images built from a host rootfs may only ship `python3`, and a
# read-only /usr prevents creating a `python` shim inside the jail, so
# resolve the interpreter instead of assuming its name.
- command = (
- 'PYTHON_BIN=$(command -v python3 || command -v python); '
- f"\"$PYTHON_BIN\" - <<'PY'\n{script}\nPY"
- )
+ command = f'PYTHON_BIN=$(command -v python3 || command -v python); "$PYTHON_BIN" - <<\'PY\'\n{script}\nPY'
result = await self.ap.box_service.execute_tool(
{
'command': command,
diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py
index 3ca95b272..29af1a0b4 100644
--- a/tests/unit_tests/provider/test_skill_tools.py
+++ b/tests/unit_tests/provider/test_skill_tools.py
@@ -738,7 +738,6 @@ async def test_native_skill_tools_require_runner_box_binding():
await loader.invoke_tool('exec', {'command': 'true'}, _make_query())
ap.box_service.execute_tool.assert_not_awaited()
-
@pytest.mark.asyncio
async def test_register_skill_hidden_when_cloud_scanning_is_disabled(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py
index 1940eaa7d..f9f9bb879 100644
--- a/tests/unit_tests/provider/test_tool_manager_native.py
+++ b/tests/unit_tests/provider/test_tool_manager_native.py
@@ -882,4 +882,4 @@ async def test_workspace_file_script_resolves_the_sandbox_interpreter():
assert result == {'ok': True}
command = captured['command']
assert command.startswith('PYTHON_BIN=$(command -v python3 || command -v python)')
- assert command.endswith(" - <<'PY'\nprint(\"x\")\nPY")
+ assert command.endswith(' - <<\'PY\'\nprint("x")\nPY')
From 5f3eb34bafd3f7c87fb810711ae7fb37e4468402 Mon Sep 17 00:00:00 2001
From: RockChinQ <1010553892@qq.com>
Date: Wed, 30 Sep 2026 18:52:00 +0800
Subject: [PATCH 023/140] feat(market): show certification badges in Core
marketplace (#2615)
---
.../plugin-market/PluginMarketComponent.tsx | 1 +
.../plugin-market/RecommendationLists.tsx | 1 +
.../PluginMarketCardComponent.tsx | 4 ++
.../plugin-market-card/PluginMarketCardVO.ts | 3 +
web/src/app/infra/entities/plugin/index.ts | 1 +
.../market/CertifiedPluginBadge.tsx | 58 +++++++++++++++++++
web/src/i18n/locales/en-US.ts | 5 ++
web/src/i18n/locales/es-ES.ts | 5 ++
web/src/i18n/locales/ja-JP.ts | 5 ++
web/src/i18n/locales/ru-RU.ts | 5 ++
web/src/i18n/locales/th-TH.ts | 5 ++
web/src/i18n/locales/vi-VN.ts | 5 ++
web/src/i18n/locales/zh-Hans.ts | 5 ++
web/src/i18n/locales/zh-Hant.ts | 5 ++
14 files changed, 108 insertions(+)
create mode 100644 web/src/components/market/CertifiedPluginBadge.tsx
diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx
index 1dd640e0e..92520d8b2 100644
--- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx
+++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx
@@ -298,6 +298,7 @@ function MarketPageContent({
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
+ certificationStatus: plugin.certification_status,
components: plugin.components || {},
tags: plugin.tags || [],
type: plugin.type,
diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx
index 6f8dbfe11..a1861312d 100644
--- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx
+++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx
@@ -57,6 +57,7 @@ function pluginToVO(
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
+ certificationStatus: plugin.certification_status,
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
index c8393ac1b..cddd29c64 100644
--- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
+++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
@@ -1,3 +1,4 @@
+import { CertifiedPluginBadge } from '@/components/market/CertifiedPluginBadge';
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -276,6 +277,9 @@ export default function PluginMarketCardComponent({
{typeLabel}
)}
+ {cardVO.certificationStatus === 'issued' && (
+
+ )}