From 98d0dba6d46f056b292e06c59b9f63d90b72c9eb Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 31 Jul 2026 17:07:04 +0800 Subject: [PATCH 1/6] fix(web): restore locale key parity (#2370) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- web/src/i18n/locales/es-ES.ts | 100 ++++++++++++++++++++++++++++++++ web/src/i18n/locales/ja-JP.ts | 5 ++ web/src/i18n/locales/ru-RU.ts | 100 ++++++++++++++++++++++++++++++++ web/src/i18n/locales/th-TH.ts | 100 ++++++++++++++++++++++++++++++++ web/src/i18n/locales/vi-VN.ts | 100 ++++++++++++++++++++++++++++++++ web/src/i18n/locales/zh-Hant.ts | 100 ++++++++++++++++++++++++++++++++ 6 files changed, 505 insertions(+) diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 0c1879dcd..ebe3be98a 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -175,6 +175,8 @@ const esES = { more: 'Más ({{count}})', less: 'Menos', noItems: 'Sin elementos', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'Página no encontrada', @@ -324,6 +326,11 @@ const esES = { fallbackList: 'Modelos de respaldo', addFallback: 'Añadir modelo de respaldo', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'Bots', @@ -1315,6 +1322,13 @@ const esES = { 'Establece una contraseña para iniciar sesión con correo y contraseña', spaceEmailMismatch: 'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'Panel de control', @@ -1573,6 +1587,8 @@ const esES = { api: 'API', storage: 'Almacenamiento', account: 'Cuenta', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1894,6 +1910,90 @@ const esES = { unsupportedFileType: 'Tipo de archivo no admitido. Solo se admiten archivos .zip y .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + invitationCreated: 'Invitation created', + delivery: { + sent: 'Invitation sent', + link_only: 'Invitation link created', + failed: 'Invitation link created, but email could not be sent', + }, + invitationCreateFailed: 'Failed to create invitation', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default esES; diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 8591f8506..734cf7859 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1378,6 +1378,11 @@ const jaJP = { operator: 'オペレーター', viewer: '閲覧者', }, + + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', }, monitoring: { title: 'ダッシュボード', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index c081fefbf..5719b83cc 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -172,6 +172,8 @@ const ruRU = { less: 'Свернуть', noItems: 'Нет элементов', termsOfService: 'Условия обслуживания', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'Страница не найдена', @@ -323,6 +325,11 @@ const ruRU = { fallbackList: 'Резервные модели', addFallback: 'Добавить резервную модель', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'Боты', @@ -1292,6 +1299,13 @@ const ruRU = { setPasswordHint: 'Установите пароль для входа с email и паролем', spaceEmailMismatch: 'Email входа через Space не совпадает с email локальной учётной записи', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'Мониторинг', @@ -1549,6 +1563,8 @@ const ruRU = { api: 'API', storage: 'Хранилище', account: 'Аккаунт', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1862,6 +1878,90 @@ const ruRU = { unsupportedFileType: 'Неподдерживаемый тип файла. Поддерживаются только файлы .zip и .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + invitationCreated: 'Invitation created', + delivery: { + sent: 'Invitation sent', + link_only: 'Invitation link created', + failed: 'Invitation link created, but email could not be sent', + }, + invitationCreateFailed: 'Failed to create invitation', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default ruRU; diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 3af8ad285..6c26b9877 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -169,6 +169,8 @@ const thTH = { more: 'เพิ่มเติม ({{count}})', less: 'น้อยลง', noItems: 'ไม่มีรายการ', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'ไม่พบหน้า', @@ -310,6 +312,11 @@ const thTH = { fallbackList: 'โมเดลสำรอง', addFallback: 'เพิ่มโมเดลสำรอง', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'บอท', @@ -1260,6 +1267,13 @@ const thTH = { bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี', setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'แดชบอร์ด', @@ -1516,6 +1530,8 @@ const thTH = { api: 'API', storage: 'พื้นที่จัดเก็บ', account: 'บัญชี', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1819,6 +1835,90 @@ const thTH = { createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง', unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .zip และ .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + invitationCreated: 'Invitation created', + delivery: { + sent: 'Invitation sent', + link_only: 'Invitation link created', + failed: 'Invitation link created, but email could not be sent', + }, + invitationCreateFailed: 'Failed to create invitation', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default thTH; diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 0ed95e383..0b058ba7f 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -172,6 +172,8 @@ const viVN = { more: 'Thêm ({{count}})', less: 'Thu gọn', noItems: 'Không có mục nào', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'Không tìm thấy trang', @@ -319,6 +321,11 @@ const viVN = { fallbackList: 'Mô hình dự phòng', addFallback: 'Thêm mô hình dự phòng', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'Bot', @@ -1286,6 +1293,13 @@ const viVN = { setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu', spaceEmailMismatch: 'Email đăng nhập Space không khớp với email tài khoản cục bộ', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'Bảng điều khiển', @@ -1542,6 +1556,8 @@ const viVN = { api: 'API', storage: 'Lưu trữ', account: 'Tài khoản', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1853,6 +1869,90 @@ const viVN = { unsupportedFileType: 'Loại tệp không được hỗ trợ. Chỉ hỗ trợ tệp .zip và .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + invitationCreated: 'Invitation created', + delivery: { + sent: 'Invitation sent', + link_only: 'Invitation link created', + failed: 'Invitation link created, but email could not be sent', + }, + invitationCreateFailed: 'Failed to create invitation', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default viVN; diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index b98817e74..1f7023678 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -160,6 +160,8 @@ const zhHant = { more: '更多 ({{count}})', less: '收起', noItems: '暫無內容', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: '頁面不存在', @@ -300,6 +302,11 @@ const zhHant = { fallbackList: '備用模型', addFallback: '新增備用模型', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: '機器人', @@ -1216,6 +1223,13 @@ const zhHant = { bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起', setPasswordHint: '設定密碼後可使用電子郵件密碼登入', spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: '儀表盤', @@ -1471,6 +1485,8 @@ const zhHant = { api: 'API', storage: '儲存', account: '帳戶', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1762,6 +1778,90 @@ const zhHant = { saveFileSuccess: '檔案儲存成功', saveFileError: '檔案儲存失敗:', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + invitationCreated: 'Invitation created', + delivery: { + sent: 'Invitation sent', + link_only: 'Invitation link created', + failed: 'Invitation link created, but email could not be sent', + }, + invitationCreateFailed: 'Failed to create invitation', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default zhHant; From 9df021eb8f5c7bacfc58429169804d044210c52f Mon Sep 17 00:00:00 2001 From: Constantine <52125907+Constantine1916@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:25:19 +0800 Subject: [PATCH 2/6] fix(telegram): stop leaking bot token via Image.url (#2366) Telegram file.file_path is a full URL of the form https://api.telegram.org/file/bot/ that embeds the bot token. Since #2362 this URL was copied into Image.url, so the token was serialized into the message chain and thereby persisted to the monitoring database, shown in the dashboard, and forwarded to every installed plugin via event dispatch. Anyone with dashboard or plugin access could recover the token and take full control of the bot. Unlike the public CDN URLs used by the other adapters changed in #2362, Telegram file URLs are only usable with the embedded token, so there is no safe URL to expose. Store base64 only (as before #2362); the vision path already relies solely on base64, so nothing downstream changes. Add a regression test asserting the token never appears in the converted Image or the serialized message chain. Co-authored-by: Constantine1916 --- src/langbot/pkg/platform/sources/telegram.py | 5 +- .../platform/test_telegram_adapter.py | 69 ++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/platform/sources/telegram.py b/src/langbot/pkg/platform/sources/telegram.py index 7ff7109ca..f78b56893 100644 --- a/src/langbot/pkg/platform/sources/telegram.py +++ b/src/langbot/pkg/platform/sources/telegram.py @@ -179,10 +179,13 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte ) file_format = 'image/jpeg' + # NOTE: Telegram's file.file_path is a full URL of the form + # https://api.telegram.org/file/bot/ which embeds the + # bot token. Unlike the public CDN URLs used by other adapters, it + # cannot be exposed safely, so only base64 is stored here. encoded = await asyncio.to_thread(base64.b64encode, file_bytes) message_components.append( platform_message.Image( - url=file.file_path, base64=f'data:{file_format};base64,{encoded.decode("utf-8")}', ) ) diff --git a/tests/unit_tests/platform/test_telegram_adapter.py b/tests/unit_tests/platform/test_telegram_adapter.py index 885310914..bc7af1bf5 100644 --- a/tests/unit_tests/platform/test_telegram_adapter.py +++ b/tests/unit_tests/platform/test_telegram_adapter.py @@ -1,7 +1,8 @@ """Tests for Telegram Dify form callback helpers.""" import json -from unittest.mock import AsyncMock, MagicMock +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch import pytest from telegram import ForceReply @@ -9,8 +10,10 @@ from telegram import ForceReply import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message + from langbot.pkg.platform.sources.telegram import ( TelegramAdapter, + TelegramMessageConverter, _decode_telegram_base64_limited, _telegram_form_action_from_callback, _telegram_select_field_options, @@ -26,6 +29,70 @@ def test_telegram_base64_decode_is_bounded(monkeypatch): _decode_telegram_base64_limited('A' * 12) +TELEGRAM_BOT_TOKEN = '123456789:AAExampleBotTokenThatMustNotLeak' +TELEGRAM_FILE_URL = f'https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}/photos/file_0.jpg' + + +@pytest.mark.asyncio +async def test_telegram_photo_does_not_expose_bot_token_in_image_url(): + """Regression test for the Telegram bot-token leak. + + telegram.Bot builds file.file_path as + https://api.telegram.org/file/bot/, embedding the bot token. + The converter must not copy that URL into Image.url, or the token leaks to + the monitoring DB, dashboard and every installed plugin via the message + chain. Only base64 (which carries no token) may be stored. + """ + tg_file = MagicMock() + tg_file.file_path = TELEGRAM_FILE_URL + + photo_size = MagicMock() + photo_size.get_file = AsyncMock(return_value=tg_file) + + message = MagicMock() + message.text = None + message.caption = None + message.photo = [photo_size] + message.voice = None + message.document = None + + response = MagicMock() + response.headers = {} + + async def iter_chunked(_chunk_size): + yield b'\xff\xd8\xff\xe0jpeg-bytes' + + response.content.iter_chunked = iter_chunked + + @asynccontextmanager + async def fake_get(url): + yield response + + fake_session = MagicMock() + fake_session.get = fake_get + + with patch( + 'langbot.pkg.platform.sources.telegram.httpclient.get_session', + return_value=fake_session, + ): + chain = await TelegramMessageConverter.target2yiri(message, MagicMock(), 'bot-account') + + images = [c for c in chain if isinstance(c, platform_message.Image)] + assert len(images) == 1 + image = images[0] + + # The token-bearing URL must not be retained anywhere on the component. + assert not image.url + assert image.base64 is not None + assert image.base64.startswith('data:image/jpeg;base64,') + + # Belt-and-suspenders: the token must not appear in the serialized chain + # (this is what gets persisted to the monitoring DB and sent to plugins). + serialized = json.dumps(chain.model_dump(), ensure_ascii=False) + assert TELEGRAM_BOT_TOKEN not in serialized + assert 'api.telegram.org/file/bot' not in serialized + + def _select_form_data() -> dict: return { '_current_input_field': 'choice', From 404e3466d97b95f43ed67fb51bcfadb22af2cb1f Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 31 Jul 2026 17:41:55 +0800 Subject: [PATCH 3/6] feat(cloud): add scoped support admin sessions (#2369) * feat(cloud): add scoped support admin sessions * style(web): format support admin session changes * fix(cloud): isolate support adapter sessions * fix(cloud): authenticate plugin assets and report workspace resources --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/api/http/authz.py | 5 + src/langbot/pkg/api/http/context.py | 3 + src/langbot/pkg/api/http/controller/group.py | 126 ++++- .../groups/pipelines/websocket_chat.py | 30 +- .../controller/groups/platform/adapters.py | 2 + .../pkg/api/http/controller/groups/plugins.py | 73 ++- .../pkg/api/http/controller/groups/user.py | 13 + .../api/http/controller/groups/workspaces.py | 27 +- src/langbot/pkg/api/http/service/user.py | 7 +- src/langbot/pkg/cloud/launch.py | 59 ++- src/langbot/pkg/cloud/support_admin.py | 248 ++++++++++ src/langbot/pkg/core/app.py | 3 + src/langbot/pkg/core/stages/build_app.py | 2 + .../pkg/entity/persistence/support_admin.py | 35 ++ .../versions/0016_support_admin_sessions.py | 88 ++++ src/langbot/pkg/persistence/mgr.py | 1 + src/langbot/pkg/persistence/tenant_uow.py | 1 + .../pkg/platform/sources/websocket_manager.py | 8 +- src/langbot/pkg/telemetry/heartbeat.py | 45 ++ .../api/test_support_admin_launch.py | 453 ++++++++++++++++++ .../integration/api/test_user_space_oauth.py | 2 +- .../api/service/test_user_service.py | 1 + .../api/test_adapter_session_scoping.py | 30 ++ .../api/test_plugin_runtime_route_fence.py | 14 + tests/unit_tests/cloud/test_space_launch.py | 88 ++++ tests/unit_tests/telemetry/test_heartbeat.py | 33 +- web/src/app/auth/space/callback/page.tsx | 15 +- .../home-sidebar/SidebarDataContext.tsx | 17 +- .../knowledge/components/kb-form/KBForm.tsx | 19 +- web/src/app/home/layout.tsx | 3 +- .../ExtensionCardComponent.tsx | 11 +- .../plugin-readme/PluginReadme.tsx | 120 +++-- web/src/app/infra/http/BackendClient.ts | 30 +- web/src/app/infra/http/index.ts | 48 ++ .../components/AuthenticatedPluginIcon.tsx | 32 ++ .../hooks/useAuthenticatedPluginResource.ts | 71 +++ web/tests/unit/support-admin-session.test.mjs | 42 ++ 37 files changed, 1703 insertions(+), 102 deletions(-) create mode 100644 src/langbot/pkg/cloud/support_admin.py create mode 100644 src/langbot/pkg/entity/persistence/support_admin.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0016_support_admin_sessions.py create mode 100644 tests/integration/api/test_support_admin_launch.py create mode 100644 web/src/components/AuthenticatedPluginIcon.tsx create mode 100644 web/src/hooks/useAuthenticatedPluginResource.ts create mode 100644 web/tests/unit/support-admin-session.test.mjs diff --git a/src/langbot/pkg/api/http/authz.py b/src/langbot/pkg/api/http/authz.py index 36b796fe7..225e30aa8 100644 --- a/src/langbot/pkg/api/http/authz.py +++ b/src/langbot/pkg/api/http/authz.py @@ -74,6 +74,11 @@ class AuthorizationError(Exception): error_code = 'forbidden' +class AuthenticationDeniedError(AuthorizationError): + status_code = 401 + error_code = 'invalid_authentication' + + class WorkspaceRequiredError(AuthorizationError): status_code = 400 error_code = 'workspace_required' diff --git a/src/langbot/pkg/api/http/context.py b/src/langbot/pkg/api/http/context.py index 7880a1e15..f9c7b4258 100644 --- a/src/langbot/pkg/api/http/context.py +++ b/src/langbot/pkg/api/http/context.py @@ -9,6 +9,7 @@ class PrincipalType(enum.StrEnum): ACCOUNT = 'account' API_KEY = 'api_key' + SUPPORT_ADMIN = 'support_admin' SYSTEM = 'system' PUBLIC_BOT = 'public_bot' @@ -19,7 +20,9 @@ class PrincipalContext: principal_type: PrincipalType account_uuid: str | None = None + actor_account_uuid: str | None = None api_key_uuid: str | None = None + support_session_id: str | None = None @dataclasses.dataclass(frozen=True, slots=True) diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 1fc09a0b2..0917c6c6d 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -15,8 +15,17 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl from ....workspace.errors import WorkspaceNotFoundError from ....cloud.entitlements import EntitlementUnavailableError from ....core.errors import TaskCapacityError -from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission +from ..authz import ( + AuthenticationDeniedError, + AuthorizationError, + Permission, + PermissionDeniedError, + WorkspaceRequiredError, + permissions_for_role, + require_permission, +) from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext +from ....cloud.support_admin import SupportAdminSessionError if typing.TYPE_CHECKING: from ....core.app import Application @@ -51,6 +60,17 @@ class AuthType(enum.Enum): USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' +_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset( + { + Permission.OWNER_TRANSFER.value, + Permission.MEMBER_VIEW.value, + Permission.MEMBER_INVITE.value, + Permission.MEMBER_UPDATE_ROLE.value, + Permission.MEMBER_REMOVE.value, + } +) + + class RouterGroup(abc.ABC): name: str @@ -95,6 +115,10 @@ class RouterGroup(abc.ABC): return self.http_status(401, -1, 'No valid user token provided') try: + if self._is_support_admin_token(token): + raise AuthenticationDeniedError( + 'Support admin tokens cannot be refreshed or used on account endpoints' + ) account, user_email = await self._authenticate_account(token) # Account-token routes deliberately stop before Workspace # selection. They may bootstrap a selector, but cannot @@ -111,8 +135,13 @@ class RouterGroup(abc.ABC): return self.http_status(401, -1, 'No valid user token provided') try: - account, user_email = await self._authenticate_account(token) - request_context = await self._resolve_account_context(account, auth_type) + request_context = await self._authenticate_support_admin(token, auth_type) + if request_context is not None: + self._require_support_admin_route_allowed(rule, f, permission) + user_email = None + else: + account, user_email = await self._authenticate_account(token) + request_context = await self._resolve_account_context(account, auth_type) if permission is not None: if request_context is None: raise AuthorizationError('Workspace authorization is unavailable') @@ -141,10 +170,20 @@ class RouterGroup(abc.ABC): return self._auth_error_response(e) elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: + token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') + if token and self._is_support_admin_token(token): + try: + request_context = await self._authenticate_support_admin(token, auth_type) + if request_context is None: + raise AuthenticationDeniedError('Invalid support admin token') + self._require_support_admin_route_allowed(rule, f, permission) + if permission is not None: + require_permission(request_context, permission) + self._inject_handler_context(f, kwargs, None, request_context) + except Exception as e: + return self._auth_error_response(e) # Try API key first (check X-API-Key header) - api_key = quart.request.headers.get('X-API-Key', '') - - if api_key: + elif api_key := quart.request.headers.get('X-API-Key', ''): # API key authentication try: request_context = await self._authenticate_api_key(api_key, auth_type) @@ -155,8 +194,6 @@ class RouterGroup(abc.ABC): return self._auth_error_response(e) else: # Try user token authentication (Authorization header) - token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') - if not token: return self.http_status( 401, -1, 'No valid authentication provided (user token or API key required)' @@ -268,10 +305,83 @@ class RouterGroup(abc.ABC): raise ValueError('User not found') return account, account.user + def _is_support_admin_token(self, token: str) -> bool: + service = getattr(self.ap, 'support_admin_session_service', None) + detector = getattr(service, 'is_support_admin_token', None) + return callable(detector) and detector(token) is True + + async def _authenticate_support_admin( + self, + token: str, + auth_type: AuthType, + *, + workspace_uuid: str | None = None, + request_id: str | None = None, + ) -> RequestContext | None: + service = getattr(self.ap, 'support_admin_session_service', None) + detector = getattr(service, 'is_support_admin_token', None) + if service is None or not callable(detector) or detector(token) is not True: + return None + + requested_workspace_uuid = ( + workspace_uuid if workspace_uuid is not None else quart.request.headers.get('X-Workspace-Id') + ) + if not requested_workspace_uuid: + raise WorkspaceRequiredError('Support admin token requires an explicit Workspace selector') + try: + identity = await service.authenticate_token( + token, + requested_workspace_uuid=requested_workspace_uuid, + ) + except SupportAdminSessionError as exc: + raise AuthenticationDeniedError(str(exc)) from exc + + entitlement_revision = await self._resolve_entitlement_revision( + identity.instance_uuid, + identity.workspace_uuid, + ) + request_context = RequestContext( + instance_uuid=identity.instance_uuid, + placement_generation=identity.placement_generation, + request_id=request_id or self.request_id(), + auth_type=auth_type.value, + principal=PrincipalContext( + principal_type=PrincipalType.SUPPORT_ADMIN, + actor_account_uuid=identity.actor_account_uuid, + support_session_id=identity.grant_jti_hash, + ), + workspace=WorkspaceContext( + workspace_uuid=identity.workspace_uuid, + membership_uuid=None, + role='owner', + permissions=permissions_for_role('owner') - _SUPPORT_ADMIN_DENIED_PERMISSIONS, + membership_revision=0, + ), + entitlement_revision=entitlement_revision, + ) + quart.g.request_context = request_context + quart.g.workspace_membership = None + return request_context + + @staticmethod + def _require_support_admin_route_allowed( + rule: str, + handler: RouteCallable, + permission: Permission | str | None, + ) -> None: + parameters = inspect.signature(handler).parameters + if rule.startswith('/api/v1/user/') or 'account' in parameters or 'user_email' in parameters: + raise AuthenticationDeniedError('Support admin tokens are not permitted on account endpoints') + permission_value = permission.value if isinstance(permission, Permission) else permission + if permission_value in _SUPPORT_ADMIN_DENIED_PERMISSIONS: + raise PermissionDeniedError(permission_value) + async def _resolve_account_context( self, account: typing.Any, auth_type: AuthType, + *, + token: str | None = None, ) -> RequestContext | None: collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None) account_uuid = getattr(account, 'uuid', None) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py index 565107117..056217348 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -97,6 +97,16 @@ class WebSocketChatRouterGroup(group.RouterGroup): if not token or not workspace_uuid: raise ValueError('Authentication is required') + support_context = await self._authenticate_support_admin( + token, + group.AuthType.USER_TOKEN, + workspace_uuid=workspace_uuid, + request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()), + ) + if support_context is not None: + require_permission(support_context, Permission.RUNTIME_OPERATE) + return support_context, token + account, _ = await self._authenticate_account(token) account_uuid = getattr(account, 'uuid', None) collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None) @@ -131,6 +141,23 @@ class WebSocketChatRouterGroup(group.RouterGroup): ) -> RequestContext: """Recheck revocable account, membership, permission, and placement state.""" + if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN: + current_context = await self._authenticate_support_admin( + token, + group.AuthType.USER_TOKEN, + workspace_uuid=request_context.workspace_uuid, + request_id=request_context.request_id, + ) + if current_context is None or current_context.principal != request_context.principal: + raise ValueError('WebSocket support admin session changed') + if ( + current_context.instance_uuid != request_context.instance_uuid + or current_context.placement_generation != request_context.placement_generation + ): + raise ValueError('WebSocket authorization changed') + require_permission(current_context, Permission.RUNTIME_OPERATE) + return current_context + account, _ = await self._authenticate_account(token) account_uuid = getattr(account, 'uuid', None) if account_uuid != request_context.account_uuid: @@ -211,6 +238,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): scope=WebSocketScope.from_context(request_context), pipeline_uuid=pipeline_uuid, session_type=session_type, + trigger_principal=request_context.principal, metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, send_queue_size=( self.ap.instance_config.data.get('system', {}) @@ -391,7 +419,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): ) elif message_type == 'message': try: - await self._revalidate_websocket_authorization(request_context, token) + request_context = await self._revalidate_websocket_authorization(request_context, token) except Exception: await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'}) break diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index edfbf9615..75a046e8c 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -22,6 +22,7 @@ class _AdapterSessionScope: principal_type: str account_uuid: str | None api_key_uuid: str | None + support_session_id: str | None @classmethod def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope': @@ -33,6 +34,7 @@ class _AdapterSessionScope: principal_type=principal.principal_type.value, account_uuid=principal.account_uuid, api_key_uuid=principal.api_key_uuid, + support_session_id=principal.support_session_id, ) def matches(self, request_context: RequestContext) -> bool: diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index bbcda6c6b..7ad46d999 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -318,6 +318,13 @@ class PluginsRouterGroup(group.RouterGroup): ) return await operation() + async def _require_authenticated_plugin_runtime_context( + self, + request_context: RequestContext, + ) -> ExecutionContext: + """Fence an authenticated resource request to its injected Workspace.""" + return await self.ap.plugin_connector.require_workspace_context(request_context) + async def _require_public_plugin_runtime_context(self) -> ExecutionContext: """Resolve public assets only for the OSS singleton Workspace. @@ -372,7 +379,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugins = await self.ap.plugin_connector.list_plugins() return self.success(data={'plugins': redact_plugin_secrets(plugins)}) @@ -385,7 +392,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Get plugin debug information including debug URL and key""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) debug_info = await self.ap.plugin_connector.get_debug_info() # Get debug URL from config @@ -428,7 +435,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -469,7 +476,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -489,7 +496,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -506,7 +513,7 @@ class PluginsRouterGroup(group.RouterGroup): ) except ValueError as exc: return self.http_status(400, -1, str(exc)) - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config) return self.success(data={}) @@ -517,7 +524,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) language = quart.request.args.get('language', 'en') readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) return self.success(data={'readme': readme}) @@ -529,7 +536,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.AUDIT_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) try: limit = int(quart.request.args.get('limit', 200)) except (TypeError, ValueError): @@ -538,6 +545,44 @@ class PluginsRouterGroup(group.RouterGroup): logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level) return self.success(data={'logs': logs}) + @self.route( + '///authenticated-icon', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_VIEW, + ) + async def _( + author: str, + plugin_name: str, + request_context: RequestContext, + ) -> quart.Response: + await self._require_authenticated_plugin_runtime_context(request_context) + icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) + icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64']) + return quart.Response(icon_bytes, mimetype=icon_data['mime_type']) + + @self.route( + '///authenticated-assets/', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_VIEW, + ) + async def _( + author: str, + plugin_name: str, + filepath: str, + request_context: RequestContext, + ) -> quart.Response: + await self._require_authenticated_plugin_runtime_context(request_context) + asset_path = _normalize_plugin_asset_path(filepath) + if asset_path is None: + return quart.Response('Asset not found', status=404) + asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) + if not asset_data.get('asset_base64'): + return quart.Response('Asset not found', status=404) + asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64']) + return quart.Response(asset_bytes, mimetype=asset_data['mime_type']) + @self.route( '///icon', methods=['GET'], @@ -596,7 +641,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: """Forward a page API request to the plugin.""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json if not isinstance(data, dict): return self.http_status(400, -1, 'invalid request body') @@ -625,7 +670,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Get releases from a GitHub repository URL""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json repo_url = data.get('repo_url', '') @@ -705,7 +750,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Get assets from a specific GitHub release""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json owner = data.get('owner', '') repo = data.get('repo', '') @@ -901,7 +946,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(request_context: RequestContext) -> str: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') @@ -942,7 +987,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Upload a file for plugin configuration""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') @@ -974,7 +1019,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(file_key: str, request_context: RequestContext) -> str: """Delete a plugin configuration file""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'): return self.http_status(400, -1, 'invalid file key') diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 8fa38bf73..8b86ba361 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -396,6 +396,19 @@ class UserRouterGroup(group.RouterGroup): launch_assertion, expected_workspace_uuid=workspace_uuid, ) + if launch.get('launch_mode') == 'support_admin': + token = launch.get('support_admin_token') + if not token: + raise SpaceLaunchError('Support admin launch session was not issued') + return self.success( + data={ + 'token': token, + 'workspace_uuid': launch['workspace_uuid'], + 'principal_type': 'support_admin', + 'actor_account_uuid': launch['actor_account_uuid'], + } + ) + account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) if account is None: raise SpaceLaunchError('Launch Account is not projected into Core') diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 5c43cf64b..6b6817b07 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -5,7 +5,7 @@ import typing import quart from ...authz import Permission, permissions_for_role -from ...context import RequestContext +from ...context import PrincipalType, RequestContext from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership from .....entity.persistence.workspace import WorkspaceSource @@ -120,9 +120,6 @@ class WorkspacesRouterGroup(group.RouterGroup): @self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW) async def _(request_context: RequestContext) -> typing.Any: membership = quart.g.workspace_membership - account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid) - if account is None: - return self.http_status(401, 'invalid_authentication', 'Account not found') workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid) plan_name: str | None = None resolver = getattr(self.ap, 'entitlement_resolver', None) @@ -132,6 +129,28 @@ class WorkspacesRouterGroup(group.RouterGroup): minimum_revision=request_context.entitlement_revision, ) plan_name = entitlement.plan_name + if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN: + return self.success( + data={ + 'workspace': _workspace_payload(workspace), + 'membership': { + 'uuid': None, + 'workspace_uuid': request_context.workspace_uuid, + 'account_uuid': None, + 'email': None, + 'role': 'owner', + 'status': 'active', + 'joined_at': None, + 'created_at': None, + }, + 'permissions': sorted(request_context.workspace.permissions), + 'placement_generation': request_context.placement_generation, + 'plan_name': plan_name, + } + ) + account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid) + if account is None: + return self.http_status(401, 'invalid_authentication', 'Account not found') return self.success( data={ 'workspace': _workspace_payload(workspace), diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 93a3441ad..d1ae53608 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -400,7 +400,10 @@ class UserService: return await self.generate_jwt_token(user_obj) - async def generate_jwt_token(self, account: user.User | str) -> str: + async def generate_jwt_token( + self, + account: user.User | str, + ) -> str: jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] jwt_expire = self.ap.instance_config.data['system']['jwt']['expire'] @@ -413,7 +416,7 @@ class UserService: # Lightweight unit-test and bootstrap callers may not have persistence wired. account_obj = None - payload = { + payload: dict[str, typing.Any] = { 'user': user_email, 'iss': self._jwt_identity()[0], 'aud': self._jwt_identity()[1], diff --git a/src/langbot/pkg/cloud/launch.py b/src/langbot/pkg/cloud/launch.py index ac5006870..ddc808beb 100644 --- a/src/langbot/pkg/cloud/launch.py +++ b/src/langbot/pkg/cloud/launch.py @@ -15,12 +15,15 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from .support_admin import SupportAdminReplayError, SupportAdminSessionError, hash_grant_jti + if typing.TYPE_CHECKING: from ..core.app import Application CONTROL_PLANE_TYP = 'langbot-control-plane+jwt' LAUNCH_KIND = 'workspace.launch' +SUPPORT_ADMIN_LAUNCH_KIND = 'workspace.support_admin_launch' EXPECTED_ISSUER = 'langbot-space' EXPECTED_AUDIENCE = 'langbot-cloud-runtime' _CONSUMED_JTI_MAX_ENTRIES = 4096 @@ -123,15 +126,60 @@ class SpaceLaunchService: payload = claims.get('payload') if not isinstance(payload, dict): raise SpaceLaunchError('Launch assertion payload must be a JSON object') - account_uuid = _required_string(payload, 'account_uuid') + kind = _required_string(claims, 'kind') workspace_uuid = _required_string(payload, 'workspace_uuid') if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid: raise SpaceLaunchError('Launch assertion targets another Workspace') - await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1)) - return { + if kind == SUPPORT_ADMIN_LAUNCH_KIND: + if 'account_uuid' in payload: + raise SpaceLaunchError('Admin launch assertion must not identify a customer Account') + if payload.get('launch_mode') != 'support_admin' or payload.get('principal_type') != 'support_admin': + raise SpaceLaunchError('Admin launch principal must be support_admin') + actor_account_uuid = _required_string(payload, 'actor_account_uuid') + if _required_string(payload, 'effective_role') != 'owner': + raise SpaceLaunchError('Admin launch effective role must be owner') + issued_at = _required_int(claims, 'iat') + expires_at = _required_int(claims, 'exp', minimum=1) + if expires_at - issued_at > 90: + raise SpaceLaunchError('Admin launch assertion lifetime exceeds 90 seconds') + grant_jti_hash = hash_grant_jti(_required_string(claims, 'jti')) + result = { + 'workspace_uuid': workspace_uuid, + 'launch_mode': 'support_admin', + 'actor_account_uuid': actor_account_uuid, + 'effective_role': 'owner', + 'grant_jti_hash': grant_jti_hash, + } + support_service = getattr(self.ap, 'support_admin_session_service', None) + if support_service is None or not callable(getattr(support_service, 'consume_launch_grant', None)): + raise SpaceLaunchError('Durable support admin session service is unavailable') + try: + support_session = await support_service.consume_launch_grant( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + ) + except SupportAdminReplayError as exc: + raise SpaceLaunchError('Launch assertion has already been consumed') from exc + except SupportAdminSessionError as exc: + raise SpaceLaunchError(str(exc)) from exc + result['support_admin_token'] = support_session.token + self.ap.logger.info( + 'cloud_support_admin_launch_consumed actor_account_uuid=%s workspace_uuid=%s', + result['actor_account_uuid'], + workspace_uuid, + ) + return result + + if payload.get('launch_mode') is not None: + raise SpaceLaunchError('Launch assertion mode is unsupported') + account_uuid = _required_string(payload, 'account_uuid') + result = { 'account_uuid': account_uuid, 'workspace_uuid': workspace_uuid, } + await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1)) + return result def _verify_assertion(self, token: str) -> dict[str, typing.Any]: if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): @@ -169,8 +217,9 @@ class SpaceLaunchService: raise SpaceLaunchError('Launch assertion subject targets another instance') if _required_string(claims, 'instance_uuid') != instance_uuid: raise SpaceLaunchError('Launch assertion instance UUID does not match this Core') - if _required_string(claims, 'kind') != LAUNCH_KIND: - raise SpaceLaunchError('Launch assertion kind is not workspace.launch') + kind = _required_string(claims, 'kind') + if kind not in {LAUNCH_KIND, SUPPORT_ADMIN_LAUNCH_KIND}: + raise SpaceLaunchError('Launch assertion kind is not supported') issued_at = _required_int(claims, 'iat') not_before = _required_int(claims, 'nbf') diff --git a/src/langbot/pkg/cloud/support_admin.py b/src/langbot/pkg/cloud/support_admin.py new file mode 100644 index 000000000..bd0607a20 --- /dev/null +++ b/src/langbot/pkg/cloud/support_admin.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import dataclasses +import datetime +import hashlib +import re +import time +import typing + +import jwt +from sqlalchemy.exc import IntegrityError + +from ..entity.persistence.support_admin import SupportAdminTemporarySession +from ..workspace.errors import WorkspaceError + +if typing.TYPE_CHECKING: + from ..core.app import Application + + +SUPPORT_ADMIN_TOKEN_TYP = 'langbot-support-admin+jwt' +SUPPORT_ADMIN_TOKEN_KIND = 'support_admin.session' +SUPPORT_ADMIN_EFFECTIVE_ROLE = 'owner' +SUPPORT_ADMIN_MAX_TOKEN_SECONDS = 300 +_SHA256_HEX = re.compile(r'^[0-9a-f]{64}$') + + +class SupportAdminSessionError(ValueError): + """Raised when a support-admin session or token is not admissible.""" + + +class SupportAdminReplayError(SupportAdminSessionError): + """Raised when a launch grant JTI has already been consumed.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class IssuedSupportAdminSession: + token: str + grant_jti_hash: str + workspace_uuid: str + actor_account_uuid: str + issued_at: datetime.datetime + expires_at: datetime.datetime + + +@dataclasses.dataclass(frozen=True, slots=True) +class SupportAdminSessionIdentity: + grant_jti_hash: str + workspace_uuid: str + actor_account_uuid: str + instance_uuid: str + placement_generation: int + + +def hash_grant_jti(jti: str) -> str: + return hashlib.sha256(jti.encode('utf-8')).hexdigest() + + +class SupportAdminSessionService: + """Issue and validate temporary Workspace-scoped support-admin sessions.""" + + def __init__( + self, + ap: Application, + *, + wall_time: typing.Callable[[], float] = time.time, + ) -> None: + self.ap = ap + self._wall_time = wall_time + + async def consume_launch_grant( + self, + *, + grant_jti_hash: str, + workspace_uuid: str, + actor_account_uuid: str, + ) -> IssuedSupportAdminSession: + self._validate_grant_hash(grant_jti_hash) + if not workspace_uuid or not actor_account_uuid: + raise SupportAdminSessionError('Support admin session requires an actor and Workspace') + + issued_at = self._utcnow() + expires_at = issued_at + datetime.timedelta(seconds=SUPPORT_ADMIN_MAX_TOKEN_SECONDS) + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if not callable(tenant_uow): + raise SupportAdminSessionError('Support admin sessions require tenant persistence') + + try: + async with tenant_uow(workspace_uuid) as uow: + await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session) + uow.session.add( + SupportAdminTemporarySession( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + issued_at=issued_at, + expires_at=expires_at, + ) + ) + await uow.session.flush() + except IntegrityError as exc: + raise SupportAdminReplayError('Launch assertion has already been consumed') from exc + except WorkspaceError as exc: + raise SupportAdminSessionError('Workspace is unavailable for support access') from exc + + return IssuedSupportAdminSession( + token=self._encode_token( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + issued_at=issued_at, + expires_at=expires_at, + ), + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + issued_at=issued_at, + expires_at=expires_at, + ) + + def is_support_admin_token(self, token: str) -> bool: + """Return True only for compact JWTs marked as support-admin tokens.""" + + if not isinstance(token, str) or token.count('.') != 2: + return False + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError: + return False + if header.get('typ') == SUPPORT_ADMIN_TOKEN_TYP: + return True + try: + payload = jwt.decode(token, options={'verify_signature': False}) + except jwt.PyJWTError: + return False + return payload.get('kind') == SUPPORT_ADMIN_TOKEN_KIND + + async def authenticate_token( + self, + token: str, + *, + requested_workspace_uuid: str | None, + ) -> SupportAdminSessionIdentity: + if not self.is_support_admin_token(token): + raise SupportAdminSessionError('Not a support admin token') + workspace_uuid = (requested_workspace_uuid or '').strip() + if not workspace_uuid: + raise SupportAdminSessionError('Support admin token requires an explicit Workspace selector') + + jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] + try: + payload = jwt.decode( + token, + jwt_secret, + algorithms=['HS256'], + issuer='langbot-core', + audience=self._audience(workspace_uuid), + options={'require': ['exp', 'iat', 'nbf', 'iss', 'aud']}, + ) + except jwt.PyJWTError as exc: + raise SupportAdminSessionError('Invalid support admin token') from exc + self._validate_payload(payload, workspace_uuid) + grant_jti_hash = payload['grant_jti_hash'] + actor_account_uuid = payload['actor_account_uuid'] + + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if not callable(tenant_uow): + raise SupportAdminSessionError('Support admin sessions require tenant persistence') + + now = self._utcnow() + async with tenant_uow(workspace_uuid) as uow: + session = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash) + if ( + session is None + or session.workspace_uuid != workspace_uuid + or session.actor_account_uuid != actor_account_uuid + or session.revoked_at is not None + or session.expires_at <= now + ): + raise SupportAdminSessionError('Support admin session is inactive') + binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session) + session.last_used_at = now + await uow.session.flush() + + return SupportAdminSessionIdentity( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + instance_uuid=binding.instance_uuid, + placement_generation=binding.placement_generation, + ) + + async def revoke_session(self, grant_jti_hash: str, workspace_uuid: str) -> None: + self._validate_grant_hash(grant_jti_hash) + now = self._utcnow() + async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow: + row = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash) + if row is not None and row.revoked_at is None: + row.revoked_at = now + + def _encode_token( + self, + *, + grant_jti_hash: str, + workspace_uuid: str, + actor_account_uuid: str, + issued_at: datetime.datetime, + expires_at: datetime.datetime, + ) -> str: + jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] + payload: dict[str, typing.Any] = { + 'kind': SUPPORT_ADMIN_TOKEN_KIND, + 'iss': 'langbot-core', + 'aud': self._audience(workspace_uuid), + 'sub': f'support-admin:{actor_account_uuid}', + 'iat': issued_at, + 'nbf': issued_at, + 'exp': expires_at, + 'actor_account_uuid': actor_account_uuid, + 'workspace_uuid': workspace_uuid, + 'effective_role': SUPPORT_ADMIN_EFFECTIVE_ROLE, + 'grant_jti_hash': grant_jti_hash, + } + return jwt.encode(payload, jwt_secret, algorithm='HS256', headers={'typ': SUPPORT_ADMIN_TOKEN_TYP}) + + def _validate_payload(self, payload: dict[str, typing.Any], workspace_uuid: str) -> None: + if payload.get('kind') != SUPPORT_ADMIN_TOKEN_KIND: + raise SupportAdminSessionError('Invalid support admin token kind') + if payload.get('workspace_uuid') != workspace_uuid: + raise SupportAdminSessionError('Support admin session is scoped to another Workspace') + if payload.get('effective_role') != SUPPORT_ADMIN_EFFECTIVE_ROLE: + raise SupportAdminSessionError('Invalid support admin token role') + actor_account_uuid = payload.get('actor_account_uuid') + if not isinstance(actor_account_uuid, str) or not actor_account_uuid.strip(): + raise SupportAdminSessionError('Invalid support admin actor') + grant_jti_hash = payload.get('grant_jti_hash') + if not isinstance(grant_jti_hash, str) or not _SHA256_HEX.match(grant_jti_hash): + raise SupportAdminSessionError('Invalid support admin grant') + + def _audience(self, workspace_uuid: str) -> str: + return f'langbot-support-admin:{self.ap.workspace_service.instance_uuid}:{workspace_uuid}' + + @staticmethod + def _validate_grant_hash(grant_jti_hash: str) -> None: + if not _SHA256_HEX.match(grant_jti_hash): + raise SupportAdminSessionError('Invalid support admin grant') + + def _utcnow(self) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self._wall_time(), datetime.UTC).replace(tzinfo=None) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index bb8d76a2f..bfd01387d 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -51,6 +51,7 @@ from ..workspace import collaboration as workspace_collaboration_module from ..workspace import invitation_delivery as invitation_delivery_module from ..cloud import bootstrap as cloud_bootstrap_module from ..cloud import launch as cloud_launch_module +from ..cloud import support_admin as cloud_support_admin_module from ..cloud import directory_projection as cloud_directory_projection_module from ..cloud import entitlements as cloud_entitlements_module from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType @@ -136,6 +137,8 @@ class Application: space_launch_service: cloud_launch_module.SpaceLaunchService = None + support_admin_session_service: cloud_support_admin_module.SupportAdminSessionService = None + deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 813a6c4e6..b08fdfaf7 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -42,6 +42,7 @@ from ...workspace import collaboration as workspace_collaboration_module from ...workspace import invitation_delivery as invitation_delivery_module from ...cloud import bootstrap as cloud_bootstrap from ...cloud import launch as cloud_launch_module +from ...cloud import support_admin as cloud_support_admin_module from ...cloud.directory import directory_projection_limits_from_config from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.entitlements import EntitlementResolver @@ -180,6 +181,7 @@ class BuildAppStage(stage.BootingStage): workspace_service_inst, ) ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap) + ap.support_admin_session_service = cloud_support_admin_module.SupportAdminSessionService(ap) ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap) user_service_inst = user_service.UserService(ap) diff --git a/src/langbot/pkg/entity/persistence/support_admin.py b/src/langbot/pkg/entity/persistence/support_admin.py new file mode 100644 index 000000000..3575d0135 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/support_admin.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import sqlalchemy + +from .base import Base + + +class SupportAdminTemporarySession(Base): + """Temporary support-admin Workspace access session.""" + + __tablename__ = 'support_admin_temporary_sessions' + + grant_jti_hash = sqlalchemy.Column(sqlalchemy.String(64), primary_key=True) + workspace_uuid = sqlalchemy.Column( + sqlalchemy.String(36), + sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'), + nullable=False, + ) + actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False) + issued_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) + expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) + revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + + __table_args__ = ( + sqlalchemy.Index( + 'ix_support_admin_sessions_workspace_expiry', + 'workspace_uuid', + 'expires_at', + ), + sqlalchemy.CheckConstraint( + 'length(grant_jti_hash) = 64', + name='ck_support_admin_sessions_grant_jti_hash', + ), + ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0016_support_admin_sessions.py b/src/langbot/pkg/persistence/alembic/versions/0016_support_admin_sessions.py new file mode 100644 index 000000000..28a1b34d6 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0016_support_admin_sessions.py @@ -0,0 +1,88 @@ +"""add temporary support-admin sessions + +Revision ID: 0016_support_admin_sessions +Revises: 0015_cloud_core_collab +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = '0016_support_admin_sessions' +down_revision = '0015_cloud_core_collab' +branch_labels = None +depends_on = None + + +_TABLE_NAME = 'support_admin_temporary_sessions' +_POLICY_NAME = 'langbot_workspace_isolation' +_TENANT_SETTING = 'langbot.workspace_uuid' + + +def _setting(name: str) -> str: + return f"NULLIF(current_setting('{name}', true), '')" + + +def _quote(conn: sa.Connection, identifier: str) -> str: + return conn.dialect.identifier_preparer.quote(identifier) + + +def upgrade() -> None: + conn = op.get_bind() + existing_tables = set(sa.inspect(conn).get_table_names()) + if _TABLE_NAME not in existing_tables: + op.create_table( + _TABLE_NAME, + sa.Column('grant_jti_hash', sa.String(64), nullable=False), + sa.Column( + 'workspace_uuid', + sa.String(36), + sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('actor_account_uuid', sa.String(36), nullable=False), + sa.Column('issued_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('revoked_at', sa.DateTime(), nullable=True), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.CheckConstraint( + 'length(grant_jti_hash) = 64', + name='ck_support_admin_sessions_grant_jti_hash', + ), + sa.PrimaryKeyConstraint('grant_jti_hash'), + ) + op.create_index( + 'ix_support_admin_sessions_workspace_expiry', + _TABLE_NAME, + ['workspace_uuid', 'expires_at'], + unique=False, + ) + + if conn.dialect.name != 'postgresql': + return + + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}' + op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + if conn.dialect.name == 'postgresql': + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.drop_index('ix_support_admin_sessions_workspace_expiry', table_name=_TABLE_NAME) + op.drop_table(_TABLE_NAME) diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 9565dfdd0..121f19de5 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -54,6 +54,7 @@ _ALEMBIC_TENANT_TABLES = { 'workspace_memberships', 'workspace_invitations', 'workspace_execution_states', + 'support_admin_temporary_sessions', 'workspace_metadata', 'api_keys', 'bots', diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 27db2d64a..53be402fa 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -43,6 +43,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = { 'workspace_memberships': 'workspace_uuid', 'workspace_invitations': 'workspace_uuid', 'workspace_execution_states': 'workspace_uuid', + 'support_admin_temporary_sessions': 'workspace_uuid', 'workspace_metadata': 'workspace_uuid', 'api_keys': 'workspace_uuid', 'bots': 'workspace_uuid', diff --git a/src/langbot/pkg/platform/sources/websocket_manager.py b/src/langbot/pkg/platform/sources/websocket_manager.py index 99ef98bba..03f94e517 100644 --- a/src/langbot/pkg/platform/sources/websocket_manager.py +++ b/src/langbot/pkg/platform/sources/websocket_manager.py @@ -9,7 +9,7 @@ from datetime import datetime import pydantic -from ...api.http.context import ExecutionContext +from ...api.http.context import ExecutionContext, PrincipalContext logger = logging.getLogger(__name__) _SESSION_FILTER_UNSET = object() @@ -95,6 +95,9 @@ class WebSocketConnection(pydantic.BaseModel): metadata: dict = pydantic.Field(default_factory=dict) """连接元数据(可存储额外信息)""" + trigger_principal: PrincipalContext | None = None + """Authenticated principal that opened this dashboard connection.""" + @property def scope(self) -> WebSocketScope: return WebSocketScope( @@ -112,6 +115,7 @@ class WebSocketConnection(pydantic.BaseModel): workspace_uuid=self.workspace_uuid, placement_generation=self.placement_generation, pipeline_uuid=self.pipeline_uuid, + trigger_principal=self.trigger_principal, ) @@ -138,6 +142,7 @@ class WebSocketConnectionManager: pipeline_uuid: str, session_type: str, metadata: dict | None = None, + trigger_principal: PrincipalContext | None = None, session_id: str | None = None, send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE, max_connections: int = 1024, @@ -174,6 +179,7 @@ class WebSocketConnectionManager: session_id=session_id, websocket=websocket, metadata=metadata or {}, + trigger_principal=trigger_principal, send_queue=asyncio.Queue(maxsize=send_queue_size), ) diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py index 3c3efb507..c535120ca 100644 --- a/src/langbot/pkg/telemetry/heartbeat.py +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -52,6 +52,47 @@ async def _count( return -1 +async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dict]: + """Summarize already-loaded Cloud registries without per-tenant SQL.""" + persistence_mgr = ap.persistence_mgr + if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime': + return [] + + bindings = await ap.workspace_service.list_active_execution_bindings() + counts = { + binding.workspace_uuid: { + 'workspace_uuid': binding.workspace_uuid, + 'bot_count': 0, + 'pipeline_count': 0, + 'knowledge_base_count': 0, + 'plugin_count': 0, + 'mcp_server_count': 0, + 'extension_count': 0, + } + for binding in bindings + } + + for key in getattr(ap.platform_mgr, '_bots_by_key', {}): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['bot_count'] += 1 + for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['pipeline_count'] += 1 + for key in getattr(ap.rag_mgr, 'knowledge_bases', {}): + if len(key) >= 1 and key[0] in counts: + counts[key[0]]['knowledge_base_count'] += 1 + for key in getattr(ap.tool_mgr.mcp_tool_loader, '_sessions', {}): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['mcp_server_count'] += 1 + for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items(): + if workspace_uuid in counts: + counts[workspace_uuid]['plugin_count'] = len(installations) + + for resource in counts.values(): + resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count'] + return list(counts.values()) + + async def build_heartbeat_payload(ap: core_app.Application) -> dict: """Collect the anonymous instance profile snapshot.""" from ..entity.persistence import bot as persistence_bot @@ -136,6 +177,10 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict: except Exception: pass + workspace_resources = await _cloud_workspace_resource_counts(ap) + if workspace_resources: + features['workspace_resources'] = workspace_resources + return { 'event_type': 'instance_heartbeat', 'query_id': '', diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py new file mode 100644 index 000000000..94647a7f1 --- /dev/null +++ b/tests/integration/api/test_support_admin_launch.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import base64 +import datetime +import json +import logging +import time +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from quart import Quart +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from langbot.pkg.api.http.authz import Permission +from langbot.pkg.api.http.context import PrincipalType, RequestContext +from langbot.pkg.api.http.controller import group +from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup +from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService +from langbot.pkg.cloud.support_admin import SupportAdminSessionService +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.support_admin import SupportAdminTemporarySession +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import ( + Workspace, + WorkspaceExecutionState, + WorkspaceMembership, +) +from langbot.pkg.workspace.service import WorkspaceService + + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +INSTANCE_UUID = 'instance-support-admin' +WORKSPACE_UUID = '10000000-0000-4000-8000-000000000001' +OTHER_WORKSPACE_UUID = '10000000-0000-4000-8000-000000000002' +ACTOR_ACCOUNT_UUID = '20000000-0000-4000-8000-000000000001' +KEY_ID = 'support-admin-key-1' + + +def _base64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') + + +def _sign(private_key: Ed25519PrivateKey, claims: dict, *, key_id: str = KEY_ID) -> str: + header = {'alg': 'EdDSA', 'kid': key_id, 'typ': 'langbot-control-plane+jwt'} + encoded_header = _base64url(json.dumps(header, separators=(',', ':')).encode('utf-8')) + encoded_claims = _base64url(json.dumps(claims, separators=(',', ':')).encode('utf-8')) + signing_input = f'{encoded_header}.{encoded_claims}' + return f'{signing_input}.{_base64url(private_key.sign(signing_input.encode("ascii")))}' + + +def _admin_claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE_UUID) -> dict: + return { + 'iss': 'langbot-space', + 'aud': 'langbot-cloud-runtime', + 'sub': f'langbot-instance:{INSTANCE_UUID}', + 'jti': jti or str(uuid.uuid4()), + 'iat': now, + 'nbf': now - 5, + 'exp': now + 90, + 'instance_uuid': INSTANCE_UUID, + 'kind': 'workspace.support_admin_launch', + 'payload': { + 'workspace_uuid': workspace_uuid, + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': ACTOR_ACCOUNT_UUID, + 'effective_role': 'owner', + }, + } + + +@group.group_class('support_admin_probe', '/api/v1/support-admin-probe') +class SupportAdminProbeGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/user-token', auth_type=group.AuthType.USER_TOKEN, permission=Permission.WORKSPACE_VIEW) + async def _(request_context: RequestContext) -> str: + return self.success(data=_context_payload(request_context)) + + @self.route( + '/member-operation', + auth_type=group.AuthType.USER_TOKEN, + permission=Permission.MEMBER_VIEW, + ) + async def member_operation(request_context: RequestContext) -> str: + return self.success(data=_context_payload(request_context)) + + @self.route( + '/user-token-or-api-key', + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.WORKSPACE_VIEW, + ) + async def _(request_context: RequestContext) -> str: + return self.success(data=_context_payload(request_context)) + + +def _context_payload(request_context: RequestContext) -> dict: + return { + 'principal_type': request_context.principal.principal_type.value, + 'actor_account_uuid': request_context.principal.actor_account_uuid, + 'account_uuid': request_context.principal.account_uuid, + 'role': request_context.workspace.role, + 'membership_uuid': request_context.workspace.membership_uuid, + 'permissions': sorted(request_context.workspace.permissions), + } + + +class _TenantUow: + def __init__(self, engine): + self._engine = engine + self.session = None + self._transaction = None + + async def __aenter__(self): + session_factory = async_sessionmaker(self._engine, expire_on_commit=False) + self.session = session_factory() + self._transaction = await self.session.begin() + return self + + async def __aexit__(self, exc_type, exc, traceback): + try: + if exc_type is None: + await self._transaction.commit() + else: + await self._transaction.rollback() + finally: + await self.session.close() + + +class _TenantScope: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return False + + +class _PersistenceManager: + def __init__(self, engine): + self._engine = engine + self.mode = SimpleNamespace(value='oss_compat') + + def get_db_engine(self): + return self._engine + + def tenant_uow(self, workspace_uuid: str): + del workspace_uuid + return _TenantUow(self._engine) + + def tenant_scope(self, workspace_uuid: str): + del workspace_uuid + return _TenantScope() + + +@pytest.fixture +async def support_admin_api(tmp_path): + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "support-admin.db"}') + async with engine.begin() as connection: + await connection.run_sync( + Base.metadata.create_all, + tables=[ + User.__table__, + Workspace.__table__, + WorkspaceExecutionState.__table__, + WorkspaceMembership.__table__, + SupportAdminTemporarySession.__table__, + ], + ) + for workspace_uuid, slug in ( + (WORKSPACE_UUID, 'support-admin-a'), + (OTHER_WORKSPACE_UUID, 'support-admin-b'), + ): + await connection.execute( + sqlalchemy.insert(Workspace).values( + uuid=workspace_uuid, + instance_uuid=INSTANCE_UUID, + name=slug, + slug=slug, + type='team', + status='active', + source='cloud_projection', + projection_revision=1, + ) + ) + await connection.execute( + sqlalchemy.insert(WorkspaceExecutionState).values( + workspace_uuid=workspace_uuid, + instance_uuid=INSTANCE_UUID, + active_generation=1, + state='active', + write_fenced=False, + source='cloud', + desired_state_revision=1, + ) + ) + + app = SimpleNamespace() + app.persistence_mgr = _PersistenceManager(engine) + app.instance_config = SimpleNamespace( + data={ + 'system': { + 'jwt': {'secret': 'support-admin-secret', 'expire': 3600}, + 'websocket_retention': {}, + }, + 'space': { + 'launch': { + 'control_plane_public_key': _base64url(public_key), + } + }, + 'api': {'global_api_key': ''}, + } + ) + app.logger = logging.getLogger('support-admin-test') + app.deployment = SimpleNamespace(mode='cloud', multi_workspace_enabled=True, verification_key_id=KEY_ID) + app.directory_projection_service = SimpleNamespace(require_ready=lambda: None) + app.workspace_service = WorkspaceService(app, instance_uuid=INSTANCE_UUID) + app.entitlement_resolver = SimpleNamespace( + instance_uuid=INSTANCE_UUID, + resolve=AsyncMock(return_value=SimpleNamespace(entitlement_revision=7)), + ) + app.support_admin_session_service = SupportAdminSessionService(app) + app.space_launch_service = SpaceLaunchService(app) + app.user_service = SimpleNamespace() + app.user_service.get_authenticated_account = AsyncMock(side_effect=AssertionError('normal account auth used')) + app.user_service.verify_jwt_token = AsyncMock(side_effect=AssertionError('normal token verification used')) + app.user_service.get_user_by_email = AsyncMock(side_effect=AssertionError('user lookup used')) + app.apikey_service = SimpleNamespace() + app.apikey_service.authenticate_api_key = AsyncMock( + return_value=SimpleNamespace( + instance_uuid=INSTANCE_UUID, + workspace_uuid=OTHER_WORKSPACE_UUID, + placement_generation=1, + api_key_uuid='api-key', + permissions=frozenset(permission.value for permission in Permission), + ) + ) + + quart_app = Quart(__name__) + await UserRouterGroup(app, quart_app).initialize() + await SupportAdminProbeGroup(app, quart_app).initialize() + + yield app, quart_app.test_client(), engine, private_key + await engine.dispose() + + +async def _issue_support_token(app, private_key: Ed25519PrivateKey, *, jti: str | None = None) -> dict[str, str]: + launch = await app.space_launch_service.consume_assertion( + _sign(private_key, _admin_claims(now=int(time.time()), jti=jti)), + expected_workspace_uuid=WORKSPACE_UUID, + ) + return launch + + +def _auth(token: str, workspace_uuid: str = WORKSPACE_UUID) -> dict[str, str]: + return {'Authorization': f'Bearer {token}', 'X-Workspace-Id': workspace_uuid} + + +async def test_support_admin_membership_only_routes_are_denied(support_admin_api): + app, client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + + response = await client.get( + '/api/v1/support-admin-probe/member-operation', + headers=_auth(launch['support_admin_token']), + ) + + assert response.status_code == 403 + assert (await response.get_json())['code'] == 'permission_denied' + + +async def test_support_admin_check_token_is_rejected(support_admin_api): + app, client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + + response = await client.get('/api/v1/user/check-token', headers=_auth(launch['support_admin_token'])) + + assert response.status_code == 401 + assert (await response.get_json())['code'] == 'invalid_authentication' + + +async def test_support_admin_cross_workspace_denied_for_user_token_and_or_api_key(support_admin_api): + app, client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + + missing_selector = await client.get( + '/api/v1/support-admin-probe/user-token', + headers={'Authorization': f'Bearer {launch["support_admin_token"]}'}, + ) + user_response = await client.get( + '/api/v1/support-admin-probe/user-token', + headers=_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID), + ) + either_response = await client.get( + '/api/v1/support-admin-probe/user-token-or-api-key', + headers={ + **_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID), + 'X-API-Key': 'valid-api-key', + }, + ) + + assert missing_selector.status_code == 400 + assert user_response.status_code == 401 + assert either_response.status_code == 401 + app.apikey_service.authenticate_api_key.assert_not_awaited() + + +async def test_support_admin_request_context_has_actor_owner_and_no_membership(support_admin_api): + app, client, engine, private_key = support_admin_api + before_count = await _membership_count(engine) + launch = await _issue_support_token(app, private_key) + + response = await client.get( + '/api/v1/support-admin-probe/user-token', + headers=_auth(launch['support_admin_token']), + ) + + assert response.status_code == 200 + data = (await response.get_json())['data'] + permissions = set(data.pop('permissions')) + assert Permission.WORKSPACE_VIEW.value in permissions + assert Permission.RESOURCE_MANAGE.value in permissions + assert not permissions.intersection( + { + Permission.OWNER_TRANSFER.value, + Permission.MEMBER_VIEW.value, + Permission.MEMBER_INVITE.value, + Permission.MEMBER_UPDATE_ROLE.value, + Permission.MEMBER_REMOVE.value, + } + ) + assert data == { + 'principal_type': PrincipalType.SUPPORT_ADMIN.value, + 'actor_account_uuid': ACTOR_ACCOUNT_UUID, + 'account_uuid': None, + 'role': 'owner', + 'membership_uuid': None, + } + assert await _membership_count(engine) == before_count + + +async def test_support_admin_missing_workspace_is_controlled_launch_failure(support_admin_api): + app, _client, engine, private_key = support_admin_api + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.delete(WorkspaceExecutionState).where(WorkspaceExecutionState.workspace_uuid == WORKSPACE_UUID) + ) + + with pytest.raises(SpaceLaunchError, match='unavailable'): + await _issue_support_token(app, private_key) + + +async def test_support_admin_launch_replay_is_durable_across_service_instances(support_admin_api): + app, _client, _engine, private_key = support_admin_api + jti = str(uuid.uuid4()) + + await _issue_support_token(app, private_key, jti=jti) + second_service = SpaceLaunchService(app) + + with pytest.raises(SpaceLaunchError, match='already been consumed'): + await second_service.consume_assertion( + _sign(private_key, _admin_claims(now=int(time.time()), jti=jti)), + expected_workspace_uuid=WORKSPACE_UUID, + ) + + +async def test_support_admin_persisted_expiry_and_revocation_are_enforced(support_admin_api): + app, client, engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + token = launch['support_admin_token'] + + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(SupportAdminTemporarySession) + .where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash']) + .values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(minutes=1)) + ) + expired = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(token)) + assert expired.status_code == 401 + + second = await _issue_support_token(app, private_key) + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(SupportAdminTemporarySession) + .where(SupportAdminTemporarySession.grant_jti_hash == second['grant_jti_hash']) + .values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None)) + ) + revoked = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(second['support_admin_token'])) + assert revoked.status_code == 401 + + +async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api): + app, _client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + captured_contexts = [] + + class Adapter: + async def handle_websocket_message(self, connection, data): + del data + captured_contexts.append(connection.execution_context) + await connection.send_queue.put({'type': 'handled'}) + connection.is_active = False + + app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1'))) + app.platform_mgr = SimpleNamespace( + get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=Adapter())) + ) + + quart_app = Quart(__name__) + await WebSocketChatRouterGroup(app, quart_app).initialize() + + async with quart_app.test_client().websocket('/api/v1/pipelines/pipeline-1/ws/connect') as websocket: + await websocket.send( + json.dumps( + { + 'type': 'authenticate', + 'token': launch['support_admin_token'], + 'workspace_uuid': WORKSPACE_UUID, + } + ) + ) + connected = json.loads(await websocket.receive()) + assert connected['type'] == 'connected' + await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]})) + handled = json.loads(await websocket.receive()) + assert handled['type'] == 'handled' + + assert captured_contexts + principal = captured_contexts[0].trigger_principal + assert principal is not None + assert principal.principal_type == PrincipalType.SUPPORT_ADMIN + assert principal.actor_account_uuid == ACTOR_ACCOUNT_UUID + + +async def _membership_count(engine) -> int: + async with engine.connect() as connection: + return int( + await connection.scalar( + sqlalchemy.select(sqlalchemy.func.count()).select_from(WorkspaceMembership), + ) + or 0 + ) diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 17569707c..deb1e93e2 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -270,7 +270,7 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): response = await client.get( '/api/v1/user/space-credits', - headers={'Authorization': 'Bearer account-token', 'X-Workspace-UUID': WORKSPACE_UUID}, + headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, ) assert response.status_code == 200 diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index 96d501b95..a3479725e 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -418,6 +418,7 @@ class TestUserServiceGenerateJwtToken: assert token is not None + class TestUserServiceVerifyJwtToken: """Tests for verify_jwt_token method.""" diff --git a/tests/unit_tests/api/test_adapter_session_scoping.py b/tests/unit_tests/api/test_adapter_session_scoping.py index 3e8642aad..4eb28d1b4 100644 --- a/tests/unit_tests/api/test_adapter_session_scoping.py +++ b/tests/unit_tests/api/test_adapter_session_scoping.py @@ -149,6 +149,36 @@ async def test_session_scope_matches_exact_tenant_placement_and_principal(): assert sessions == {} +async def test_support_admin_sessions_are_scoped_to_the_persisted_grant(): + def support_context(grant_jti_hash: str) -> RequestContext: + return RequestContext( + instance_uuid='instance-test', + placement_generation=1, + request_id='request-test', + auth_type='support-admin', + principal=PrincipalContext( + principal_type=PrincipalType.SUPPORT_ADMIN, + actor_account_uuid='support-actor', + support_session_id=grant_jti_hash, + ), + workspace=WorkspaceContext( + workspace_uuid='workspace-a', + membership_uuid=None, + role='owner', + permissions=frozenset({'resource.manage'}), + ), + ) + + first_context = support_context('a' * 64) + second_context = support_context('b' * 64) + sessions: dict[str, dict] = {'session-test': {'status': 'waiting'}} + _bind_session_scope(sessions['session-test'], first_context) + + assert _get_owned_session(sessions, 'session-test', second_context) is None + assert _pop_owned_session(sessions, 'session-test', second_context) is None + assert _get_owned_session(sessions, 'session-test', first_context) is sessions['session-test'] + + async def test_session_capacity_evicts_oldest_session_in_same_workspace(): owner_context = _request_context() sessions: dict[str, dict] = {} diff --git a/tests/unit_tests/api/test_plugin_runtime_route_fence.py b/tests/unit_tests/api/test_plugin_runtime_route_fence.py index 0bc324e83..3594a7374 100644 --- a/tests/unit_tests/api/test_plugin_runtime_route_fence.py +++ b/tests/unit_tests/api/test_plugin_runtime_route_fence.py @@ -46,6 +46,20 @@ def plugin_router_cls(): yield PluginsRouterGroup +@pytest.mark.asyncio +async def test_authenticated_plugin_resource_fences_injected_workspace_context(plugin_router_cls): + connector = SimpleNamespace( + require_workspace_context=AsyncMock(return_value=CONTEXT), + ) + router = object.__new__(plugin_router_cls) + router.ap = SimpleNamespace(plugin_connector=connector) + + result = await router._require_authenticated_plugin_runtime_context(CONTEXT) + + assert result == CONTEXT + connector.require_workspace_context.assert_awaited_once_with(CONTEXT) + + @pytest.mark.asyncio async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls): connector = SimpleNamespace( diff --git a/tests/unit_tests/cloud/test_space_launch.py b/tests/unit_tests/cloud/test_space_launch.py index 14b6b7f17..be9c7c11a 100644 --- a/tests/unit_tests/cloud/test_space_launch.py +++ b/tests/unit_tests/cloud/test_space_launch.py @@ -11,6 +11,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService +from langbot.pkg.cloud.support_admin import SupportAdminReplayError pytestmark = pytest.mark.asyncio @@ -57,9 +58,21 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService: encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) + consumed: set[str] = set() + + class DurableSupportAdminService: + async def consume_launch_grant(self, **kwargs): + grant_hash = kwargs['grant_jti_hash'] + if grant_hash in consumed: + raise SupportAdminReplayError('already consumed') + consumed.add(grant_hash) + return SimpleNamespace(token='support-admin-token') + app = SimpleNamespace( deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID), workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID), + logger=SimpleNamespace(info=lambda *args, **kwargs: None), + support_admin_session_service=DurableSupportAdminService(), instance_config=SimpleNamespace( data={ 'space': { @@ -86,6 +99,81 @@ async def test_consumes_valid_workspace_launch_assertion_once(): await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + +async def test_consumes_admin_owner_launch_once_and_validates_claims(): + private_key = Ed25519PrivateKey.generate() + now = int(time.time()) + service = _service(private_key, now=now) + claims = _claims(now=now) + claims['kind'] = 'workspace.support_admin_launch' + claims['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + } + ) + claims['payload'].pop('account_uuid') + token = _sign(private_key, claims) + + launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + assert launch == { + 'workspace_uuid': WORKSPACE_UUID, + 'launch_mode': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + 'grant_jti_hash': launch['grant_jti_hash'], + 'support_admin_token': 'support-admin-token', + } + with pytest.raises(SpaceLaunchError, match='already been consumed'): + await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + invalid = _claims(now=now) + invalid['kind'] = 'workspace.support_admin_launch' + invalid['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'member', + } + ) + invalid['payload'].pop('account_uuid') + with pytest.raises(SpaceLaunchError, match='effective role'): + await service.consume_assertion(_sign(private_key, invalid), expected_workspace_uuid=WORKSPACE_UUID) + + too_long = _claims(now=now) + too_long['kind'] = 'workspace.support_admin_launch' + too_long['exp'] = now + 91 + too_long['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + } + ) + too_long['payload'].pop('account_uuid') + with pytest.raises(SpaceLaunchError, match='lifetime exceeds 90 seconds'): + await service.consume_assertion(_sign(private_key, too_long), expected_workspace_uuid=WORKSPACE_UUID) + + impersonating = _claims(now=now) + impersonating['kind'] = 'workspace.support_admin_launch' + impersonating['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + } + ) + with pytest.raises(SpaceLaunchError, match='customer Account'): + await service.consume_assertion(_sign(private_key, impersonating), expected_workspace_uuid=WORKSPACE_UUID) + + async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch): private_key = Ed25519PrivateKey.generate() now = int(time.time()) diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py index a8dfae936..24c25b4ee 100644 --- a/tests/unit_tests/telemetry/test_heartbeat.py +++ b/tests/unit_tests/telemetry/test_heartbeat.py @@ -106,15 +106,31 @@ class TestBuildHeartbeatPayload: side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs') ) ap.pipeline_mgr = SimpleNamespace( - _pipelines_by_key={'pipeline-a': object(), 'pipeline-b': object()}, + _pipelines_by_key={ + ('instance-a', 'workspace-a', 'pipeline-a'): object(), + ('instance-a', 'workspace-a', 'pipeline-b'): object(), + }, ) + ap.platform_mgr._bots_by_key = { + ('instance-a', 'workspace-a', 'bot-a'): object(), + } ap.tool_mgr = SimpleNamespace( mcp_tool_loader=SimpleNamespace( - _sessions={'mcp-a': object(), 'mcp-b': object(), 'mcp-c': object()}, + _sessions={ + ('instance-a', 'workspace-a', 1, 'mcp-a'): object(), + ('instance-a', 'workspace-a', 1, 'mcp-b'): object(), + ('instance-a', 'workspace-a', 1, 'mcp-c'): object(), + }, ), ) ap.rag_mgr = SimpleNamespace( - knowledge_bases={'kb-a': object()}, + knowledge_bases={('workspace-a', 'kb-a'): object()}, + ) + ap.plugin_connector._workspace_installations = { + 'workspace-a': {'plugin-a', 'plugin-b'}, + } + ap.workspace_service.list_active_execution_bindings = AsyncMock( + return_value=[SimpleNamespace(workspace_uuid='workspace-a')], ) payload = await heartbeat.build_heartbeat_payload(ap) @@ -124,6 +140,17 @@ class TestBuildHeartbeatPayload: assert features['mcp_server_count'] == 3 assert features['knowledge_base_count'] == 1 assert features['bot_count'] == 1 + assert features['workspace_resources'] == [ + { + 'workspace_uuid': 'workspace-a', + 'bot_count': 1, + 'pipeline_count': 2, + 'knowledge_base_count': 1, + 'plugin_count': 2, + 'mcp_server_count': 3, + 'extension_count': 5, + } + ] ap.persistence_mgr.execute_async.assert_not_awaited() @pytest.mark.asyncio diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 33f3a4846..1028de6ac 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { httpClient } from '@/app/infra/http/HttpClient'; import { beginAuthenticatedSession, + beginSupportAdminSession, bootstrapWorkspaceSession, getPendingInvitationToken, } from '@/app/infra/http'; @@ -27,8 +28,10 @@ import langbotIcon from '@/app/assets/langbot-logo.webp'; type SpaceOAuthLoginResult = { token: string; - user: string; + user?: string; workspace_uuid?: string; + principal_type?: 'account' | 'support_admin'; + actor_account_uuid?: string; }; const pendingSpaceOAuthLogins = new Map< @@ -94,6 +97,16 @@ function SpaceOAuthCallbackContent() { return; } + if (response.principal_type === 'support_admin') { + if (!response.workspace_uuid) { + throw new Error('Support admin launch did not include a Workspace'); + } + beginSupportAdminSession(response.token, response.workspace_uuid); + await bootstrapWorkspaceSession(); + navigate('/home', { replace: true }); + return; + } + beginAuthenticatedSession(response.token, response.user); if (getPendingInvitationToken()) { navigate('/invitations/accept', { replace: true }); diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index 6f2adb3d1..58bd5f6fa 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -166,6 +166,19 @@ export function SidebarDataProvider({ // Deduplicate plugins by composite key (prefer debug over installed) const pluginMap = new Map(); + const pluginIconURLs = new Map( + await Promise.all( + pluginsResp.plugins.map(async (plugin) => { + const meta = plugin.manifest.manifest.metadata; + const author = meta.author ?? ''; + const name = meta.name; + const url = await httpClient + .getAuthenticatedPluginIconURL(author, name) + .catch(() => ''); + return [`${author}/${name}`, url] as const; + }), + ), + ); for (const plugin of pluginsResp.plugins) { const meta = plugin.manifest.manifest.metadata; const author = meta.author ?? ''; @@ -184,7 +197,7 @@ export function SidebarDataProvider({ const item: SidebarEntityItem = { id: compositeKey, name: extractI18nObject(meta.label), - iconURL: httpClient.getPluginIconURL(author, name), + iconURL: pluginIconURLs.get(compositeKey) || '', installSource: plugin.install_source, installInfo: plugin.install_info, hasUpdate, @@ -218,7 +231,7 @@ export function SidebarDataProvider({ pluginAuthor: author, pluginName: name, pluginLabel: label, - pluginIconURL: httpClient.getPluginIconURL(author, name), + pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '', pageId: page.id, path: page.path, }); diff --git a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx index 398a97f8c..c585e0ff9 100644 --- a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx +++ b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx @@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; +import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon'; import { Input } from '@/components/ui/input'; import EmojiPicker from '@/components/ui/emoji-picker'; import { @@ -428,12 +429,9 @@ export default function KBForm({ ); return (
- @@ -459,12 +457,9 @@ export default function KBForm({ value={engine.plugin_id} >
- {extractI18nObject(engine.name)} diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index fc91f7805..767355f48 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -17,6 +17,7 @@ import { bootstrapWorkspaceSession, systemInfo, initializeSystemInfo, + isSupportAdminSession, useCurrentWorkspace, } from '@/app/infra/http'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -156,7 +157,7 @@ export default function HomeLayout({ // selected Workspace's wizard state. useEffect(() => { if (!identityReady) return; - if (systemInfo.wizard_status === 'none') { + if (systemInfo?.wizard_status === 'none' && !isSupportAdminSession()) { navigate('/wizard', { replace: true }); } }, [identityReady, navigate]); diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx index 781b92b35..e3c030091 100644 --- a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx @@ -13,7 +13,7 @@ import { Puzzle, } from 'lucide-react'; import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http'; -import { httpClient } from '@/app/infra/http/HttpClient'; +import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { @@ -39,6 +39,11 @@ export default function ExtensionCardComponent({ const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); const [iconFailed, setIconFailed] = useState(false); + const authenticatedIcon = useAuthenticatedPluginIcon( + cardVO.author, + cardVO.name, + cardVO.type === 'plugin', + ); const FallbackIcon = cardVO.type === 'mcp' @@ -47,8 +52,8 @@ export default function ExtensionCardComponent({ ? Sparkles : Puzzle; const iconSrc = - cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name); - const showFallback = iconFailed || !iconSrc; + cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL; + const showFallback = iconFailed || authenticatedIcon.error || !iconSrc; const getTypeLabel = (type: ExtensionType) => { switch (type) { diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx index 4f69c51e7..a8a5dd2ad 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx @@ -10,6 +10,74 @@ import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import { getAPILanguageCode } from '@/i18n/I18nProvider'; import '@/styles/github-markdown.css'; +import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource'; + +function AuthenticatedReadmeImage({ + author, + name, + filepath, + alt, + ...props +}: { + author: string; + name: string; + filepath: string; + alt?: string; +} & React.ImgHTMLAttributes) { + const { url, error } = useAuthenticatedPluginAsset(author, name, filepath); + if (error) + return ( + {alt || filepath} + ); + if (!url) + return ( + + ); + return ( + {alt + ); +} + +function PluginReadmeImage({ + author, + name, + src, + alt, + ...props +}: { + author: string; + name: string; + src?: string; + alt?: string; +} & React.ImgHTMLAttributes) { + const imageSrc = typeof src === 'string' ? src : ''; + if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) { + return ( + {alt + ); + } + let filepath = imageSrc.replace(/^(\.\/|\/)+/, ''); + filepath = filepath.replace(/^assets\//, ''); + return ( + + ); +} export default function PluginReadme({ pluginAuthor, @@ -71,49 +139,15 @@ export default function PluginReadme({
    {children}
), li: ({ children }) =>
  • {children}
  • , - img: ({ src, alt, ...props }) => { - let imageSrc = src || ''; - - if (typeof imageSrc !== 'string') { - return ( - {alt - ); - } - - if ( - imageSrc && - !imageSrc.startsWith('http://') && - !imageSrc.startsWith('https://') && - !imageSrc.startsWith('data:') - ) { - imageSrc = imageSrc.replace(/^(\.\/|\/)+/, ''); - - if (!imageSrc.startsWith('assets/')) { - imageSrc = `assets/${imageSrc}`; - } - - const assetPath = imageSrc.replace(/^assets\//, ''); - imageSrc = httpClient.getPluginAssetURL( - pluginAuthor, - pluginName, - assetPath, - ); - } - - return ( - {alt - ); - }, + img: ({ src, alt, ...props }) => ( + + ), }} > {readme} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index e1733eaff..63d6f37c8 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -710,6 +710,32 @@ export class BackendClient extends BaseHttpClient { ); } + private async getAuthenticatedObjectURL(path: string): Promise { + const response = await this.instance.get(path, { + responseType: 'blob', + }); + return URL.createObjectURL(response.data); + } + + public getAuthenticatedPluginAssetURL( + author: string, + name: string, + filepath: string, + ): Promise { + return this.getAuthenticatedObjectURL( + `/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`, + ); + } + + public getAuthenticatedPluginIconURL( + author: string, + name: string, + ): Promise { + return this.getAuthenticatedObjectURL( + `/api/v1/plugins/${author}/${name}/authenticated-icon`, + ); + } + public async pluginPageApi( author: string, name: string, @@ -1327,8 +1353,10 @@ export class BackendClient extends BaseHttpClient { launchAssertion?: string, ): Promise<{ token: string; - user: string; + user?: string; workspace_uuid?: string; + principal_type?: 'account' | 'support_admin'; + actor_account_uuid?: string; }> { const response = await this.instance.post( '/api/v1/user/space/callback', diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index 6223144bb..0014bc272 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -217,10 +217,34 @@ export function beginAuthenticatedSession( if (typeof window === 'undefined') return; localStorage.removeItem('token'); localStorage.removeItem('userEmail'); + localStorage.removeItem('authPrincipalType'); localStorage.setItem('token', token); if (userEmail) localStorage.setItem('userEmail', userEmail); } +export function beginSupportAdminSession( + token: string, + workspaceUuid: string, +): void { + userInfo = null; + clearWorkspaceSelection(); + clearWorkspaceBootstrapSnapshot(); + + if (typeof window === 'undefined') return; + localStorage.removeItem('token'); + localStorage.removeItem('userEmail'); + localStorage.setItem('token', token); + localStorage.setItem('authPrincipalType', 'support_admin'); + setActiveWorkspaceUuid(workspaceUuid); +} + +export function isSupportAdminSession(): boolean { + return ( + typeof window !== 'undefined' && + localStorage.getItem('authPrincipalType') === 'support_admin' + ); +} + async function initializeSelectedWorkspace( workspaceUuid: string, workspaces: WorkspaceBootstrapEntry[], @@ -252,6 +276,27 @@ async function initializeSelectedWorkspace( export async function bootstrapWorkspaceSession( options: WorkspaceBootstrapOptions = {}, ): Promise { + if (isSupportAdminSession()) { + const selectedWorkspaceUuid = getActiveWorkspaceUuid(); + if (!selectedWorkspaceUuid) { + throw new Error('Support admin session is missing its Workspace scope'); + } + if ( + options.preferredWorkspaceUuid && + options.preferredWorkspaceUuid !== selectedWorkspaceUuid + ) { + throw new Error('Support admin session cannot change Workspace scope'); + } + await initializeWorkspaceInfo(); + const workspace = getCurrentWorkspaceSnapshot(); + if (!workspace || workspace.workspace.uuid !== selectedWorkspaceUuid) { + clearWorkspaceSelection(); + throw new Error('Support admin Workspace scope could not be initialized'); + } + clearWorkspaceBootstrapSnapshot(); + return { status: 'ready', workspace, workspaces: [] }; + } + if (options.resetSelection) { clearWorkspaceSelection(); clearWorkspaceBootstrapSnapshot(); @@ -339,6 +384,9 @@ export const clearUserInfo = (): void => { userInfo = null; clearWorkspaceSelection(); clearWorkspaceBootstrapSnapshot(); + if (typeof window !== 'undefined') { + localStorage.removeItem('authPrincipalType'); + } }; export { diff --git a/web/src/components/AuthenticatedPluginIcon.tsx b/web/src/components/AuthenticatedPluginIcon.tsx new file mode 100644 index 000000000..fd4055cf4 --- /dev/null +++ b/web/src/components/AuthenticatedPluginIcon.tsx @@ -0,0 +1,32 @@ +import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource'; +import { cn } from '@/lib/utils'; + +export function AuthenticatedPluginIcon({ + author, + name, + alt = '', + className, +}: { + author: string; + name: string; + alt?: string; + className?: string; +}) { + const icon = useAuthenticatedPluginIcon( + author, + name, + Boolean(author && name), + ); + + if (!icon.url || icon.error) { + return ( + + ); + } + + return {alt}; +} diff --git a/web/src/hooks/useAuthenticatedPluginResource.ts b/web/src/hooks/useAuthenticatedPluginResource.ts new file mode 100644 index 000000000..8ec46df27 --- /dev/null +++ b/web/src/hooks/useAuthenticatedPluginResource.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +export function useAuthenticatedPluginIcon( + author: string, + name: string, + enabled = true, +): { url: string; error: boolean } { + const [url, setURL] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + if (!enabled) { + setURL(''); + setError(false); + return; + } + let active = true; + let objectURL = ''; + setURL(''); + setError(false); + httpClient + .getAuthenticatedPluginIconURL(author, name) + .then((nextURL) => { + objectURL = nextURL; + if (active) setURL(nextURL); + else URL.revokeObjectURL(nextURL); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { + active = false; + if (objectURL) URL.revokeObjectURL(objectURL); + }; + }, [author, enabled, name]); + + return { url, error }; +} + +export function useAuthenticatedPluginAsset( + author: string, + name: string, + filepath: string, +): { url: string; error: boolean } { + const [url, setURL] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + let active = true; + let objectURL = ''; + setURL(''); + setError(false); + httpClient + .getAuthenticatedPluginAssetURL(author, name, filepath) + .then((nextURL) => { + objectURL = nextURL; + if (active) setURL(nextURL); + else URL.revokeObjectURL(nextURL); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { + active = false; + if (objectURL) URL.revokeObjectURL(objectURL); + }; + }, [author, name, filepath]); + + return { url, error }; +} diff --git a/web/tests/unit/support-admin-session.test.mjs b/web/tests/unit/support-admin-session.test.mjs new file mode 100644 index 000000000..3e5135a77 --- /dev/null +++ b/web/tests/unit/support-admin-session.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('support-admin launch stores a scoped principal instead of starting an Account session', () => { + const callback = read('src/app/auth/space/callback/page.tsx'); + assert.match(callback, /response\.principal_type === 'support_admin'/); + assert.match( + callback, + /beginSupportAdminSession\(response\.token, response\.workspace_uuid\)/, + ); +}); + +test('support-admin workspace bootstrap never calls Account bootstrap', () => { + const source = read('src/app/infra/http/index.ts'); + assert.match(source, /export function beginSupportAdminSession/); + assert.match(source, /export function isSupportAdminSession\(\): boolean/); + const supportBranch = source.indexOf('if (isSupportAdminSession())'); + const accountBootstrap = source.indexOf( + 'backendClient.getWorkspaceBootstrap()', + ); + assert.ok(supportBranch >= 0); + assert.ok(accountBootstrap > supportBranch); + assert.match( + source.slice(supportBranch, accountBootstrap), + /initializeWorkspaceInfo\([\s\S]*status: 'ready'/, + ); + assert.match( + source, + /localStorage\.setItem\('authPrincipalType', 'support_admin'\)/, + ); + const homeLayout = read('src/app/home/layout.tsx'); + assert.match(homeLayout, /!isSupportAdminSession\(\)/); +}); From 5d9fd15671ac4cb82e4365e07d9e579420724cc8 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 31 Jul 2026 23:35:14 +0800 Subject: [PATCH 4/6] feat: use workspace identity for telemetry (#2371) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../versions/0009_workspace_tenancy_kernel.py | 13 +- .../versions/0017_oss_workspace_identity.py | 177 ++++++++++++++++++ .../pkg/pipeline/process/handlers/chat.py | 4 +- src/langbot/pkg/telemetry/heartbeat.py | 71 +++++-- src/langbot/pkg/telemetry/identity.py | 16 ++ src/langbot/pkg/telemetry/telemetry.py | 20 +- src/langbot/pkg/workspace/identity.py | 20 ++ src/langbot/pkg/workspace/service.py | 3 +- .../persistence/test_workspace_migration.py | 90 +++++++++ tests/unit_tests/telemetry/test_heartbeat.py | 61 +++--- tests/unit_tests/telemetry/test_telemetry.py | 27 +++ .../telemetry/test_workspace_identity.py | 31 +++ .../workspace/test_workspace_service.py | 2 + 13 files changed, 493 insertions(+), 42 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py create mode 100644 src/langbot/pkg/telemetry/identity.py create mode 100644 src/langbot/pkg/workspace/identity.py create mode 100644 tests/unit_tests/telemetry/test_workspace_identity.py diff --git a/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py b/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py index d0a6c233e..1da7d0286 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py +++ b/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py @@ -18,6 +18,17 @@ down_revision = '0008_mcp_resource_prefs' branch_labels = None depends_on = None +_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76') + + +def _workspace_uuid_from_instance_id(instance_id: str) -> str: + value = instance_id.strip() + candidate = value[len('instance_') :] if value.startswith('instance_') else value + try: + return str(uuid.UUID(candidate)) + except ValueError: + return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value)) + def _table_names(conn: sa.Connection) -> set[str]: return set(sa.inspect(conn).get_table_names()) @@ -403,7 +414,7 @@ def _bootstrap_default_workspace(conn: sa.Connection) -> None: .values(created_by_account_uuid=owner_account_uuid) ) else: - workspace_uuid = str(uuid.uuid4()) + workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid) conn.execute( workspaces.insert().values( uuid=workspace_uuid, diff --git a/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py b/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py new file mode 100644 index 000000000..5574c96c1 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py @@ -0,0 +1,177 @@ +"""align the OSS Workspace UUID with the persisted instance identity + +Revision ID: 0017_oss_workspace_identity +Revises: 0016_support_admin_sessions +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import uuid + +import sqlalchemy as sa +from alembic import op + + +revision = '0017_oss_workspace_identity' +down_revision = '0016_support_admin_sessions' +branch_labels = None +depends_on = None + +_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76') +_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid' + + +def _workspace_uuid_from_instance_id(instance_id: str) -> str: + value = instance_id.strip() + candidate = value[len('instance_') :] if value.startswith('instance_') else value + try: + return str(uuid.UUID(candidate)) + except ValueError: + return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value)) + + +def _quote(conn: sa.Connection, identifier: str) -> str: + return conn.dialect.identifier_preparer.quote(identifier) + + +def _defer_foreign_keys(conn: sa.Connection, inspector: sa.Inspector, table_names: list[str]) -> None: + """Allow the transaction to re-key a connected tenant graph atomically.""" + + if conn.dialect.name == 'sqlite': + conn.execute(sa.text('PRAGMA defer_foreign_keys = ON')) + return + if conn.dialect.name != 'postgresql': + raise RuntimeError(f'Unsupported Workspace identity migration dialect: {conn.dialect.name}') + + for table_name in table_names: + for foreign_key in inspector.get_foreign_keys(table_name): + constraint_name = foreign_key.get('name') + if not constraint_name: + continue + conn.execute( + sa.text( + f'ALTER TABLE {_quote(conn, table_name)} ' + f'ALTER CONSTRAINT {_quote(conn, constraint_name)} DEFERRABLE INITIALLY DEFERRED' + ) + ) + + +def _suspend_postgres_rls( + conn: sa.Connection, + table_names: list[str], +) -> dict[str, tuple[bool, bool]]: + if conn.dialect.name != 'postgresql': + return {} + + states: dict[str, tuple[bool, bool]] = {} + for table_name in table_names: + row = conn.execute( + sa.text( + 'SELECT relrowsecurity, relforcerowsecurity ' + 'FROM pg_class WHERE oid = to_regclass(:table_name)' + ), + {'table_name': table_name}, + ).one() + enabled, forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity) + states[table_name] = (enabled, forced) + table = _quote(conn, table_name) + if forced: + conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY')) + if enabled: + conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY')) + return states + + +def _restore_postgres_rls(conn: sa.Connection, states: dict[str, tuple[bool, bool]]) -> None: + for table_name, (enabled, forced) in states.items(): + table = _quote(conn, table_name) + if enabled: + conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + if forced: + conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + table_names = inspector.get_table_names() + if 'workspaces' not in table_names: + return + + metadata = sa.MetaData() + workspaces = sa.Table('workspaces', metadata, autoload_with=conn) + local_rows = conn.execute(sa.select(workspaces).where(workspaces.c.source == 'local')).mappings().all() + if not local_rows: + return + if len(local_rows) != 1: + raise RuntimeError('Cannot align OSS Workspace identity: expected exactly one local Workspace') + + old_row = dict(local_rows[0]) + old_uuid = old_row['uuid'] + canonical_uuid = _workspace_uuid_from_instance_id(old_row['instance_uuid']) + if old_uuid == canonical_uuid: + return + if conn.execute(sa.select(workspaces.c.uuid).where(workspaces.c.uuid == canonical_uuid)).scalar_one_or_none(): + raise RuntimeError(f'Cannot align OSS Workspace identity: target {canonical_uuid!r} already exists') + + tenant_tables = [ + table_name + for table_name in table_names + if table_name == 'workspaces' + or 'workspace_uuid' in {column['name'] for column in inspector.get_columns(table_name)} + ] + rls_states = _suspend_postgres_rls(conn, tenant_tables) + try: + _defer_foreign_keys(conn, inspector, table_names) + + # Release local source/slug uniqueness while the canonical parent exists + # alongside the old parent for the duration of this transaction. + temporary_slug = f'__workspace_rekey__{old_uuid}' + conn.execute( + workspaces.update() + .where(workspaces.c.uuid == old_uuid) + .values(source='cloud_projection', slug=temporary_slug) + ) + new_row = dict(old_row) + new_row['uuid'] = canonical_uuid + conn.execute(workspaces.insert().values(**new_row)) + + for table_name in tenant_tables: + if table_name == 'workspaces': + continue + table = sa.Table(table_name, metadata, autoload_with=conn, extend_existing=True) + conn.execute( + table.update() + .where(table.c.workspace_uuid == old_uuid) + .values(workspace_uuid=canonical_uuid) + ) + + if 'metadata' in table_names: + conn.execute( + sa.text( + 'UPDATE metadata SET value = :canonical_uuid ' + 'WHERE key = :key AND value = :old_uuid' + ), + { + 'canonical_uuid': canonical_uuid, + 'key': _OSS_WORKSPACE_METADATA_KEY, + 'old_uuid': old_uuid, + }, + ) + conn.execute(workspaces.delete().where(workspaces.c.uuid == old_uuid)) + if conn.dialect.name == 'postgresql': + # Fire deferred FK triggers before ALTER TABLE restores RLS; PostgreSQL + # rejects ALTER TABLE while a relation has pending trigger events. + conn.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE')) + except Exception: + # Alembic owns the transaction. Rollback restores the transactional RLS DDL. + raise + else: + _restore_postgres_rls(conn, rls_states) + + +def downgrade() -> None: + # The previous random UUID is intentionally not recoverable. Keeping the + # canonical identity preserves every FK and is safe for older application code. + pass diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index ef6dd874d..d3dd4e79d 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -15,6 +15,7 @@ from ....provider import runner as runner_module import langbot_plugin.api.entities.events as events from ....utils import importutil, constants, runner as runner_utils from ....telemetry import features as telemetry_features +from ....telemetry.identity import workspace_identity from ....provider import runners import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -265,7 +266,8 @@ class ChatMessageHandler(handler.MessageHandler): 'duration_ms': duration_ms, 'model_name': model_name, 'version': constants.semantic_version, - 'instance_id': constants.instance_id, + **workspace_identity(get_query_execution_context(query)), + 'runtime_instance_id': constants.instance_id, 'edition': constants.edition, 'pipeline_plugins': pipeline_plugins, 'features': features, diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py index c535120ca..1296d9145 100644 --- a/src/langbot/pkg/telemetry/heartbeat.py +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -27,6 +27,18 @@ if typing.TYPE_CHECKING: HEARTBEAT_INTERVAL_SECONDS = 24 * 3600 +class WorkspaceResourceSnapshot(typing.TypedDict): + workspace_uuid: str + bot_count: int + pipeline_count: int + knowledge_base_count: int + plugin_count: int + mcp_server_count: int + extension_count: int + skill_count: int + adapters: list[str] + + async def _count( ap: core_app.Application, table, @@ -52,14 +64,13 @@ async def _count( return -1 -async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dict]: +async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -> list[WorkspaceResourceSnapshot]: """Summarize already-loaded Cloud registries without per-tenant SQL.""" persistence_mgr = ap.persistence_mgr if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime': return [] - bindings = await ap.workspace_service.list_active_execution_bindings() - counts = { + counts: dict[str, WorkspaceResourceSnapshot] = { binding.workspace_uuid: { 'workspace_uuid': binding.workspace_uuid, 'bot_count': 0, @@ -68,13 +79,19 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dic 'plugin_count': 0, 'mcp_server_count': 0, 'extension_count': 0, + 'skill_count': 0, + 'adapters': [], } for binding in bindings } - for key in getattr(ap.platform_mgr, '_bots_by_key', {}): + adapter_sets: dict[str, set[str]] = {workspace_uuid: set() for workspace_uuid in counts} + for key, bot in getattr(ap.platform_mgr, '_bots_by_key', {}).items(): if len(key) >= 2 and key[1] in counts: counts[key[1]]['bot_count'] += 1 + adapter = getattr(bot, 'adapter', None) + if adapter is not None and getattr(bot, 'enable', False): + adapter_sets[key[1]].add(adapter.__class__.__name__) for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}): if len(key) >= 2 and key[1] in counts: counts[key[1]]['pipeline_count'] += 1 @@ -87,14 +104,23 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dic for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items(): if workspace_uuid in counts: counts[workspace_uuid]['plugin_count'] = len(installations) + for key, skills in getattr(ap.skill_mgr, '_skills_by_scope', {}).items(): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['skill_count'] += len(skills) - for resource in counts.values(): + for workspace_uuid, resource in counts.items(): resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count'] + resource['adapters'] = sorted(adapter_sets[workspace_uuid]) return list(counts.values()) -async def build_heartbeat_payload(ap: core_app.Application) -> dict: - """Collect the anonymous instance profile snapshot.""" +async def build_heartbeat_payload( + ap: core_app.Application, + *, + workspace_uuid: str, + workspace_resource: WorkspaceResourceSnapshot | None = None, +) -> dict: + """Collect one anonymous Workspace profile snapshot.""" from ..entity.persistence import bot as persistence_bot from ..entity.persistence import mcp as persistence_mcp from ..entity.persistence import pipeline as persistence_pipeline @@ -177,15 +203,14 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict: except Exception: pass - workspace_resources = await _cloud_workspace_resource_counts(ap) - if workspace_resources: - features['workspace_resources'] = workspace_resources + if workspace_resource is not None: + features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'}) return { 'event_type': 'instance_heartbeat', 'query_id': '', 'version': constants.semantic_version, - 'instance_id': constants.instance_id, + 'workspace_uuid': workspace_uuid, 'instance_create_ts': constants.instance_create_ts, 'edition': constants.edition, 'features': features, @@ -193,14 +218,34 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict: } +async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]: + """Build one heartbeat per active Workspace.""" + bindings = await ap.workspace_service.list_active_execution_bindings() + workspace_uuids = sorted({binding.workspace_uuid for binding in bindings}) + resources = { + resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings) + } + return [ + await build_heartbeat_payload( + ap, + workspace_uuid=workspace_uuid, + workspace_resource=resources.get(workspace_uuid), + ) + for workspace_uuid in workspace_uuids + ] + + async def heartbeat_loop(ap: core_app.Application) -> None: """Send one heartbeat shortly after startup, then daily.""" # Small delay so managers (platform, skills, plugins) finish loading first await asyncio.sleep(30) while True: try: - payload = await build_heartbeat_payload(ap) - await ap.telemetry.start_send_task(payload) + for payload in await build_heartbeat_payloads(ap): + # Heartbeats are a daily bounded batch, not best-effort query events. + # Await each send so the TelemetryManager's 8-task queue cannot drop + # Workspaces after the first batch. + await ap.telemetry.send(payload) except Exception as e: try: ap.logger.debug(f'Telemetry heartbeat failed: {e}') diff --git a/src/langbot/pkg/telemetry/identity.py b/src/langbot/pkg/telemetry/identity.py new file mode 100644 index 000000000..8385ce190 --- /dev/null +++ b/src/langbot/pkg/telemetry/identity.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import typing + + +class WorkspaceExecutionContext(typing.Protocol): + @property + def workspace_uuid(self) -> str: ... + + +def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]: + """Build the canonical telemetry identity for one Workspace execution.""" + workspace_uuid = execution_context.workspace_uuid.strip() + if not workspace_uuid: + raise ValueError("Telemetry execution Workspace UUID is empty") + return {"workspace_uuid": workspace_uuid} diff --git a/src/langbot/pkg/telemetry/telemetry.py b/src/langbot/pkg/telemetry/telemetry.py index 6c3b8ba29..28bcf1a0a 100644 --- a/src/langbot/pkg/telemetry/telemetry.py +++ b/src/langbot/pkg/telemetry/telemetry.py @@ -2,7 +2,11 @@ from __future__ import annotations import asyncio import contextlib +import os +import typing + import httpx + from ..core import app as core_app from ..utils import httpclient @@ -21,7 +25,7 @@ class TelemetryManager: def __init__(self, ap: core_app.Application): self.ap = ap - self.telemetry_config = {} + self.telemetry_config: dict[str, typing.Any] = {} self.send_tasks: list[asyncio.Task] = [] self._client: httpx.AsyncClient | None = None @@ -131,7 +135,16 @@ class TelemetryManager: async with self._client_context() as client: try: # Use asyncio.wait_for to ensure we always bound the total time - resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1) + telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip() + if telemetry_token: + request = client.post( + url, + json=sanitized, + headers={'X-LangBot-Telemetry-Token': telemetry_token}, + ) + else: + request = client.post(url, json=sanitized) + resp = await asyncio.wait_for(request, timeout=10 + 1) if resp.status_code >= 400: body = await httpclient.response_text(resp, max_chars=200) @@ -143,7 +156,8 @@ class TelemetryManager: app_err = False try: j = await httpclient.parse_json_response(resp) - if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400: + app_code = j.get('code') if isinstance(j, dict) else None + if app_code is not None and int(app_code) >= 400: app_err = True self.ap.logger.warning( f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}' diff --git a/src/langbot/pkg/workspace/identity.py b/src/langbot/pkg/workspace/identity.py new file mode 100644 index 000000000..8500be798 --- /dev/null +++ b/src/langbot/pkg/workspace/identity.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import uuid + + +_INSTANCE_PREFIX = "instance_" +_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID("8ea04f29-8528-4cc3-bb28-30a838c89d76") + + +def workspace_uuid_from_instance_id(instance_id: str) -> str: + """Return the stable OSS Workspace UUID for a persisted instance identity.""" + value = instance_id.strip() + if not value: + raise ValueError("LangBot instance identity is empty") + + candidate = value[len(_INSTANCE_PREFIX) :] if value.startswith(_INSTANCE_PREFIX) else value + try: + return str(uuid.UUID(candidate)) + except ValueError: + return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value)) diff --git a/src/langbot/pkg/workspace/service.py b/src/langbot/pkg/workspace/service.py index bab30d4f2..b4eb0a845 100644 --- a/src/langbot/pkg/workspace/service.py +++ b/src/langbot/pkg/workspace/service.py @@ -30,6 +30,7 @@ from .errors import ( WorkspaceOwnerAlreadyExistsError, ) from .entities import WorkspaceExecutionBinding +from .identity import workspace_uuid_from_instance_id from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy from .repository import WorkspaceRepository @@ -497,7 +498,7 @@ class WorkspaceService: created_by_account_uuid: str | None = None, ) -> Workspace: return Workspace( - uuid=str(uuid.uuid4()), + uuid=workspace_uuid_from_instance_id(self.instance_uuid), instance_uuid=self.instance_uuid, name=name, slug=slug, diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 4fc56eba5..70c46f89a 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -22,6 +22,7 @@ from langbot.pkg.persistence.alembic_runner import ( from langbot.pkg.utils import constants from langbot.pkg.utils import importutil from langbot.pkg.workspace.collaboration import normalize_email +from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id pytestmark = [pytest.mark.integration, pytest.mark.asyncio] @@ -109,6 +110,7 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy .mappings() .one() ) + assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test') assert workspace['instance_uuid'] == 'instance_migration_test' assert workspace['slug'] == 'default' assert workspace['status'] == 'active' @@ -149,6 +151,53 @@ async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_ assert workspace_uuid_after == workspace_uuid_before +async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') + instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f' + old_workspace_uuid = '11111111-1111-4111-8111-111111111111' + canonical_uuid = workspace_uuid_from_instance_id(instance_id) + schema = sa.MetaData() + sa.Table( + 'metadata', + schema, + sa.Column('key', sa.String(255), primary_key=True), + sa.Column('value', sa.String(255)), + ) + sa.Table( + 'workspaces', + schema, + sa.Column('uuid', sa.String(36), primary_key=True), + sa.Column('instance_uuid', sa.String(255), nullable=False), + sa.Column('slug', sa.String(255), nullable=False), + sa.Column('source', sa.String(32), nullable=False), + ) + sa.Table( + 'tenant_rows', + schema, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False), + ) + async with engine.begin() as conn: + await conn.run_sync(schema.create_all) + await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}) + await conn.execute( + sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"), + {'uuid': old_workspace_uuid, 'instance': instance_id}, + ) + await conn.execute( + sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"), + {'uuid': old_workspace_uuid}, + ) + await run_alembic_stamp(engine, '0016_support_admin_sessions') + + await run_alembic_upgrade(engine, 'head') + + async with engine.connect() as conn: + assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid + assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid + await engine.dispose() + + async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}') try: @@ -362,6 +411,47 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade await engine.dispose() +async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') + try: + await _create_legacy_schema(engine) + await run_alembic_stamp(engine, '0008_mcp_resource_prefs') + await run_alembic_upgrade(engine, '0016_support_admin_sessions') + + async with engine.begin() as conn: + old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) + instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'")) + assert old_uuid + assert instance_uuid + await conn.execute( + sa.text( + "INSERT INTO workspace_metadata (workspace_uuid, key, value) " + "VALUES (:workspace_uuid, 'migration_probe', 'present')" + ), + {'workspace_uuid': old_uuid}, + ) + await conn.execute( + sa.text( + "INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ), + {'workspace_uuid': old_uuid}, + ) + + await run_alembic_upgrade(engine, 'head') + expected_uuid = workspace_uuid_from_instance_id(instance_uuid) + async with engine.connect() as conn: + assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid + assert await conn.scalar( + sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") + ) == expected_uuid + assert await conn.scalar( + sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'") + ) == expected_uuid + finally: + await engine.dispose() + + async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}') try: diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py index 24c25b4ee..7a1b76484 100644 --- a/tests/unit_tests/telemetry/test_heartbeat.py +++ b/tests/unit_tests/telemetry/test_heartbeat.py @@ -60,10 +60,12 @@ class TestBuildHeartbeatPayload: async def test_payload_shape(self): heartbeat = get_heartbeat_module() ap = make_app() - payload = await heartbeat.build_heartbeat_payload(ap) + payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a') assert payload['event_type'] == 'instance_heartbeat' assert payload['query_id'] == '' + assert payload['workspace_uuid'] == 'workspace-a' + assert 'instance_id' not in payload assert 'instance_create_ts' in payload assert 'timestamp' in payload f = payload['features'] @@ -86,7 +88,7 @@ class TestBuildHeartbeatPayload: @pytest.mark.asyncio async def test_payload_is_json_serializable(self): heartbeat = get_heartbeat_module() - payload = await heartbeat.build_heartbeat_payload(make_app()) + payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a') json.dumps(payload) @pytest.mark.asyncio @@ -94,7 +96,7 @@ class TestBuildHeartbeatPayload: heartbeat = get_heartbeat_module() ap = make_app() ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down')) - payload = await heartbeat.build_heartbeat_payload(ap) + payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a') assert payload['features']['pipeline_count'] == -1 @pytest.mark.asyncio @@ -111,8 +113,10 @@ class TestBuildHeartbeatPayload: ('instance-a', 'workspace-a', 'pipeline-b'): object(), }, ) + adapter_a = type('WorkspaceAAdapter', (), {})() + adapter_b = type('WorkspaceBAdapter', (), {})() ap.platform_mgr._bots_by_key = { - ('instance-a', 'workspace-a', 'bot-a'): object(), + ('instance-a', 'workspace-a', 'bot-a'): SimpleNamespace(enable=True, adapter=adapter_a), } ap.tool_mgr = SimpleNamespace( mcp_tool_loader=SimpleNamespace( @@ -129,35 +133,46 @@ class TestBuildHeartbeatPayload: ap.plugin_connector._workspace_installations = { 'workspace-a': {'plugin-a', 'plugin-b'}, } + ap.skill_mgr._skills_by_scope = { + ('instance-a', 'workspace-a', 1): {'skill-a': {}, 'skill-b': {}}, + ('instance-a', 'workspace-b', 1): {'skill-c': {}}, + } ap.workspace_service.list_active_execution_bindings = AsyncMock( - return_value=[SimpleNamespace(workspace_uuid='workspace-a')], + return_value=[ + SimpleNamespace(workspace_uuid='workspace-a'), + SimpleNamespace(workspace_uuid='workspace-b'), + ], + ) + ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace( + enable=True, adapter=adapter_b ) - payload = await heartbeat.build_heartbeat_payload(ap) + payloads = await heartbeat.build_heartbeat_payloads(ap) - features = payload['features'] - assert features['pipeline_count'] == 2 - assert features['mcp_server_count'] == 3 - assert features['knowledge_base_count'] == 1 - assert features['bot_count'] == 1 - assert features['workspace_resources'] == [ - { - 'workspace_uuid': 'workspace-a', - 'bot_count': 1, - 'pipeline_count': 2, - 'knowledge_base_count': 1, - 'plugin_count': 2, - 'mcp_server_count': 3, - 'extension_count': 5, - } - ] + assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b'] + assert all('instance_id' not in payload for payload in payloads) + by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads} + assert by_workspace['workspace-a']['pipeline_count'] == 2 + assert by_workspace['workspace-a']['mcp_server_count'] == 3 + assert by_workspace['workspace-a']['knowledge_base_count'] == 1 + assert by_workspace['workspace-a']['bot_count'] == 1 + assert by_workspace['workspace-a']['plugin_count'] == 2 + assert by_workspace['workspace-a']['extension_count'] == 5 + assert by_workspace['workspace-a']['skill_count'] == 2 + assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter'] + assert by_workspace['workspace-b']['bot_count'] == 1 + assert by_workspace['workspace-b']['pipeline_count'] == 0 + assert by_workspace['workspace-b']['skill_count'] == 1 + assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter'] + assert 'workspace_resources' not in by_workspace['workspace-a'] ap.persistence_mgr.execute_async.assert_not_awaited() + ap.workspace_service.list_active_execution_bindings.assert_awaited_once() @pytest.mark.asyncio async def test_no_user_content_fields(self): """The heartbeat must never carry message content / credentials keys.""" heartbeat = get_heartbeat_module() - payload = await heartbeat.build_heartbeat_payload(make_app()) + payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a') flat = json.dumps(payload).lower() for forbidden in ('api_key', 'password', 'token', 'message_content'): assert forbidden not in flat diff --git a/tests/unit_tests/telemetry/test_telemetry.py b/tests/unit_tests/telemetry/test_telemetry.py index b15a989ee..2242a1aa4 100644 --- a/tests/unit_tests/telemetry/test_telemetry.py +++ b/tests/unit_tests/telemetry/test_telemetry.py @@ -569,6 +569,33 @@ class TestHTTPScenarios: await manager.send({'query_id': 'test'}) +class TestTelemetryManagedRuntimeAuthentication: + @pytest.mark.asyncio + async def test_send_includes_managed_runtime_token_header(self): + telemetry = get_telemetry_module() + mock_app = Mock() + mock_app.logger = Mock() + manager = telemetry.TelemetryManager(mock_app) + manager.telemetry_config = {'url': 'https://example.com'} + captured = {} + + async def mock_post(url, json, headers): + captured['headers'] = headers + return Mock(status_code=200, text='', json=Mock(return_value={'code': 0})) + + mock_client = Mock() + mock_client.post = mock_post + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + with ( + patch.dict('os.environ', {'LANGBOT_TELEMETRY_INGEST_TOKEN': 'managed-runtime-secret'}), + patch.object(httpx, 'AsyncClient', return_value=mock_client), + ): + await manager.send({'event_type': 'instance_heartbeat'}) + + assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'} + + class TestStartSendTask: """Tests for start_send_task() method.""" diff --git a/tests/unit_tests/telemetry/test_workspace_identity.py b/tests/unit_tests/telemetry/test_workspace_identity.py new file mode 100644 index 000000000..efbd68a11 --- /dev/null +++ b/tests/unit_tests/telemetry/test_workspace_identity.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace + + +def test_standard_oss_instance_id_aligns_to_embedded_uuid(): + from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id + + instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f" + + assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid + assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid + + +def test_custom_legacy_instance_id_maps_to_stable_valid_uuid(): + from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id + + first = workspace_uuid_from_instance_id("instance_migration_test") + second = workspace_uuid_from_instance_id("instance_migration_test") + + assert first == second + assert str(uuid.UUID(first)) == first + + +def test_query_telemetry_identity_uses_execution_workspace_only(): + from langbot.pkg.telemetry.identity import workspace_identity + + identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a")) + + assert identity == {"workspace_uuid": "workspace-a"} diff --git a/tests/unit_tests/workspace/test_workspace_service.py b/tests/unit_tests/workspace/test_workspace_service.py index 8657998a3..f24f2ca44 100644 --- a/tests/unit_tests/workspace/test_workspace_service.py +++ b/tests/unit_tests/workspace/test_workspace_service.py @@ -26,6 +26,7 @@ from langbot.pkg.workspace import ( WorkspaceOwnerAlreadyExistsError, WorkspaceService, ) +from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id from langbot.pkg.workspace.policy import CloudWorkspacePolicy @@ -113,6 +114,7 @@ async def test_ensure_singleton_workspace_is_idempotent(workspace_test_context): first = await service.ensure_singleton_workspace() second = await service.ensure_singleton_workspace() + assert first.uuid == workspace_uuid_from_instance_id('instance_service_test') assert second.uuid == first.uuid async with session_factory() as session: assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1 From e3832ca536af296d569cc6e67fffcaff6e7e8153 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 31 Jul 2026 23:46:03 +0800 Subject: [PATCH 5/6] style: format workspace identity modules (#2372) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../versions/0017_oss_workspace_identity.py | 16 +++------------- src/langbot/pkg/telemetry/identity.py | 4 ++-- src/langbot/pkg/workspace/identity.py | 6 +++--- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py b/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py index 5574c96c1..105f529be 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py +++ b/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py @@ -67,10 +67,7 @@ def _suspend_postgres_rls( states: dict[str, tuple[bool, bool]] = {} for table_name in table_names: row = conn.execute( - sa.text( - 'SELECT relrowsecurity, relforcerowsecurity ' - 'FROM pg_class WHERE oid = to_regclass(:table_name)' - ), + sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'), {'table_name': table_name}, ).one() enabled, forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity) @@ -141,18 +138,11 @@ def upgrade() -> None: if table_name == 'workspaces': continue table = sa.Table(table_name, metadata, autoload_with=conn, extend_existing=True) - conn.execute( - table.update() - .where(table.c.workspace_uuid == old_uuid) - .values(workspace_uuid=canonical_uuid) - ) + conn.execute(table.update().where(table.c.workspace_uuid == old_uuid).values(workspace_uuid=canonical_uuid)) if 'metadata' in table_names: conn.execute( - sa.text( - 'UPDATE metadata SET value = :canonical_uuid ' - 'WHERE key = :key AND value = :old_uuid' - ), + sa.text('UPDATE metadata SET value = :canonical_uuid WHERE key = :key AND value = :old_uuid'), { 'canonical_uuid': canonical_uuid, 'key': _OSS_WORKSPACE_METADATA_KEY, diff --git a/src/langbot/pkg/telemetry/identity.py b/src/langbot/pkg/telemetry/identity.py index 8385ce190..a1320f1d4 100644 --- a/src/langbot/pkg/telemetry/identity.py +++ b/src/langbot/pkg/telemetry/identity.py @@ -12,5 +12,5 @@ def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str """Build the canonical telemetry identity for one Workspace execution.""" workspace_uuid = execution_context.workspace_uuid.strip() if not workspace_uuid: - raise ValueError("Telemetry execution Workspace UUID is empty") - return {"workspace_uuid": workspace_uuid} + raise ValueError('Telemetry execution Workspace UUID is empty') + return {'workspace_uuid': workspace_uuid} diff --git a/src/langbot/pkg/workspace/identity.py b/src/langbot/pkg/workspace/identity.py index 8500be798..e307d4c06 100644 --- a/src/langbot/pkg/workspace/identity.py +++ b/src/langbot/pkg/workspace/identity.py @@ -3,15 +3,15 @@ from __future__ import annotations import uuid -_INSTANCE_PREFIX = "instance_" -_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID("8ea04f29-8528-4cc3-bb28-30a838c89d76") +_INSTANCE_PREFIX = 'instance_' +_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76') def workspace_uuid_from_instance_id(instance_id: str) -> str: """Return the stable OSS Workspace UUID for a persisted instance identity.""" value = instance_id.strip() if not value: - raise ValueError("LangBot instance identity is empty") + raise ValueError('LangBot instance identity is empty') candidate = value[len(_INSTANCE_PREFIX) :] if value.startswith(_INSTANCE_PREFIX) else value try: From d8ab0ba567f5e149275474289d9abd1c5e2818e7 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sat, 1 Aug 2026 02:10:59 +0800 Subject: [PATCH 6/6] feat(telemetry): report workspace execution generation (#2374) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/telemetry/heartbeat.py | 2 ++ tests/unit_tests/telemetry/test_heartbeat.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py index 1296d9145..2dd2b2afd 100644 --- a/src/langbot/pkg/telemetry/heartbeat.py +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -37,6 +37,7 @@ class WorkspaceResourceSnapshot(typing.TypedDict): extension_count: int skill_count: int adapters: list[str] + execution_generation: int async def _count( @@ -81,6 +82,7 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) - 'extension_count': 0, 'skill_count': 0, 'adapters': [], + 'execution_generation': binding.placement_generation, } for binding in bindings } diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py index 7a1b76484..3d7e60620 100644 --- a/tests/unit_tests/telemetry/test_heartbeat.py +++ b/tests/unit_tests/telemetry/test_heartbeat.py @@ -139,8 +139,8 @@ class TestBuildHeartbeatPayload: } ap.workspace_service.list_active_execution_bindings = AsyncMock( return_value=[ - SimpleNamespace(workspace_uuid='workspace-a'), - SimpleNamespace(workspace_uuid='workspace-b'), + SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7), + SimpleNamespace(workspace_uuid='workspace-b', placement_generation=9), ], ) ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace( @@ -159,10 +159,12 @@ class TestBuildHeartbeatPayload: assert by_workspace['workspace-a']['plugin_count'] == 2 assert by_workspace['workspace-a']['extension_count'] == 5 assert by_workspace['workspace-a']['skill_count'] == 2 + assert by_workspace['workspace-a']['execution_generation'] == 7 assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter'] assert by_workspace['workspace-b']['bot_count'] == 1 assert by_workspace['workspace-b']['pipeline_count'] == 0 assert by_workspace['workspace-b']['skill_count'] == 1 + assert by_workspace['workspace-b']['execution_generation'] == 9 assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter'] assert 'workspace_resources' not in by_workspace['workspace-a'] ap.persistence_mgr.execute_async.assert_not_awaited()