Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	src/langbot/pkg/pipeline/process/handlers/chat.py
#	tests/integration/persistence/test_workspace_migration.py
This commit is contained in:
huanghuoguoguo
2026-08-01 09:31:23 +08:00
57 changed files with 2755 additions and 134 deletions
+5
View File
@@ -74,6 +74,11 @@ class AuthorizationError(Exception):
error_code = 'forbidden' error_code = 'forbidden'
class AuthenticationDeniedError(AuthorizationError):
status_code = 401
error_code = 'invalid_authentication'
class WorkspaceRequiredError(AuthorizationError): class WorkspaceRequiredError(AuthorizationError):
status_code = 400 status_code = 400
error_code = 'workspace_required' error_code = 'workspace_required'
+3
View File
@@ -9,6 +9,7 @@ class PrincipalType(enum.StrEnum):
ACCOUNT = 'account' ACCOUNT = 'account'
API_KEY = 'api_key' API_KEY = 'api_key'
SUPPORT_ADMIN = 'support_admin'
SYSTEM = 'system' SYSTEM = 'system'
PUBLIC_BOT = 'public_bot' PUBLIC_BOT = 'public_bot'
@@ -19,7 +20,9 @@ class PrincipalContext:
principal_type: PrincipalType principal_type: PrincipalType
account_uuid: str | None = None account_uuid: str | None = None
actor_account_uuid: str | None = None
api_key_uuid: str | None = None api_key_uuid: str | None = None
support_session_id: str | None = None
@dataclasses.dataclass(frozen=True, slots=True) @dataclasses.dataclass(frozen=True, slots=True)
+118 -8
View File
@@ -15,8 +15,17 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
from ....workspace.errors import WorkspaceNotFoundError from ....workspace.errors import WorkspaceNotFoundError
from ....cloud.entitlements import EntitlementUnavailableError from ....cloud.entitlements import EntitlementUnavailableError
from ....core.errors import TaskCapacityError 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 ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from ....cloud.support_admin import SupportAdminSessionError
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from ....core.app import Application from ....core.app import Application
@@ -51,6 +60,17 @@ class AuthType(enum.Enum):
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' 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): class RouterGroup(abc.ABC):
name: str name: str
@@ -95,6 +115,10 @@ class RouterGroup(abc.ABC):
return self.http_status(401, -1, 'No valid user token provided') return self.http_status(401, -1, 'No valid user token provided')
try: 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, user_email = await self._authenticate_account(token)
# Account-token routes deliberately stop before Workspace # Account-token routes deliberately stop before Workspace
# selection. They may bootstrap a selector, but cannot # 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') return self.http_status(401, -1, 'No valid user token provided')
try: try:
account, user_email = await self._authenticate_account(token) request_context = await self._authenticate_support_admin(token, auth_type)
request_context = await self._resolve_account_context(account, 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 permission is not None:
if request_context is None: if request_context is None:
raise AuthorizationError('Workspace authorization is unavailable') raise AuthorizationError('Workspace authorization is unavailable')
@@ -141,10 +170,20 @@ class RouterGroup(abc.ABC):
return self._auth_error_response(e) return self._auth_error_response(e)
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: 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) # Try API key first (check X-API-Key header)
api_key = quart.request.headers.get('X-API-Key', '') elif api_key := quart.request.headers.get('X-API-Key', ''):
if api_key:
# API key authentication # API key authentication
try: try:
request_context = await self._authenticate_api_key(api_key, auth_type) 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) return self._auth_error_response(e)
else: else:
# Try user token authentication (Authorization header) # Try user token authentication (Authorization header)
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
if not token: if not token:
return self.http_status( return self.http_status(
401, -1, 'No valid authentication provided (user token or API key required)' 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') raise ValueError('User not found')
return account, account.user 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( async def _resolve_account_context(
self, self,
account: typing.Any, account: typing.Any,
auth_type: AuthType, auth_type: AuthType,
*,
token: str | None = None,
) -> RequestContext | None: ) -> RequestContext | None:
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None) collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
account_uuid = getattr(account, 'uuid', None) account_uuid = getattr(account, 'uuid', None)
@@ -97,6 +97,16 @@ class WebSocketChatRouterGroup(group.RouterGroup):
if not token or not workspace_uuid: if not token or not workspace_uuid:
raise ValueError('Authentication is required') 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, _ = await self._authenticate_account(token)
account_uuid = getattr(account, 'uuid', None) account_uuid = getattr(account, 'uuid', None)
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None) collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
@@ -131,6 +141,23 @@ class WebSocketChatRouterGroup(group.RouterGroup):
) -> RequestContext: ) -> RequestContext:
"""Recheck revocable account, membership, permission, and placement state.""" """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, _ = await self._authenticate_account(token)
account_uuid = getattr(account, 'uuid', None) account_uuid = getattr(account, 'uuid', None)
if account_uuid != request_context.account_uuid: if account_uuid != request_context.account_uuid:
@@ -211,6 +238,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
scope=WebSocketScope.from_context(request_context), scope=WebSocketScope.from_context(request_context),
pipeline_uuid=pipeline_uuid, pipeline_uuid=pipeline_uuid,
session_type=session_type, session_type=session_type,
trigger_principal=request_context.principal,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
send_queue_size=( send_queue_size=(
self.ap.instance_config.data.get('system', {}) self.ap.instance_config.data.get('system', {})
@@ -391,7 +419,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
) )
elif message_type == 'message': elif message_type == 'message':
try: try:
await self._revalidate_websocket_authorization(request_context, token) request_context = await self._revalidate_websocket_authorization(request_context, token)
except Exception: except Exception:
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'}) await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
break break
@@ -22,6 +22,7 @@ class _AdapterSessionScope:
principal_type: str principal_type: str
account_uuid: str | None account_uuid: str | None
api_key_uuid: str | None api_key_uuid: str | None
support_session_id: str | None
@classmethod @classmethod
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope': def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
@@ -33,6 +34,7 @@ class _AdapterSessionScope:
principal_type=principal.principal_type.value, principal_type=principal.principal_type.value,
account_uuid=principal.account_uuid, account_uuid=principal.account_uuid,
api_key_uuid=principal.api_key_uuid, api_key_uuid=principal.api_key_uuid,
support_session_id=principal.support_session_id,
) )
def matches(self, request_context: RequestContext) -> bool: def matches(self, request_context: RequestContext) -> bool:
@@ -318,6 +318,13 @@ class PluginsRouterGroup(group.RouterGroup):
) )
return await operation() 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: async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
"""Resolve public assets only for the OSS singleton Workspace. """Resolve public assets only for the OSS singleton Workspace.
@@ -372,7 +379,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW, permission=Permission.RESOURCE_VIEW,
) )
async def _(request_context: RequestContext) -> str: 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() plugins = await self.ap.plugin_connector.list_plugins()
return self.success(data={'plugins': redact_plugin_secrets(plugins)}) return self.success(data={'plugins': redact_plugin_secrets(plugins)})
@@ -385,7 +392,7 @@ class PluginsRouterGroup(group.RouterGroup):
) )
async def _(request_context: RequestContext) -> str: async def _(request_context: RequestContext) -> str:
"""Get plugin debug information including debug URL and key""" """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() debug_info = await self.ap.plugin_connector.get_debug_info()
# Get debug URL from config # Get debug URL from config
@@ -428,7 +435,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW, permission=Permission.RESOURCE_VIEW,
) )
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: 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) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None: if plugin is None:
return self.http_status(404, -1, 'plugin not found') return self.http_status(404, -1, 'plugin not found')
@@ -469,7 +476,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW, permission=Permission.RESOURCE_VIEW,
) )
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: 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) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None: if plugin is None:
return self.http_status(404, -1, 'plugin not found') return self.http_status(404, -1, 'plugin not found')
@@ -489,7 +496,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE, permission=Permission.RESOURCE_MANAGE,
) )
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: 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) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None: if plugin is None:
return self.http_status(404, -1, 'plugin not found') return self.http_status(404, -1, 'plugin not found')
@@ -506,7 +513,7 @@ class PluginsRouterGroup(group.RouterGroup):
) )
except ValueError as exc: except ValueError as exc:
return self.http_status(400, -1, str(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) await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
return self.success(data={}) return self.success(data={})
@@ -517,7 +524,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW, permission=Permission.RESOURCE_VIEW,
) )
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: 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') language = quart.request.args.get('language', 'en')
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
return self.success(data={'readme': readme}) return self.success(data={'readme': readme})
@@ -529,7 +536,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.AUDIT_VIEW, permission=Permission.AUDIT_VIEW,
) )
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: 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: try:
limit = int(quart.request.args.get('limit', 200)) limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError): 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) logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
return self.success(data={'logs': logs}) return self.success(data={'logs': logs})
@self.route(
'/<author>/<plugin_name>/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(
'/<author>/<plugin_name>/authenticated-assets/<path:filepath>',
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( @self.route(
'/<author>/<plugin_name>/icon', '/<author>/<plugin_name>/icon',
methods=['GET'], methods=['GET'],
@@ -596,7 +641,7 @@ class PluginsRouterGroup(group.RouterGroup):
) )
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
"""Forward a page API request to the plugin.""" """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 data = await quart.request.json
if not isinstance(data, dict): if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body') return self.http_status(400, -1, 'invalid request body')
@@ -625,7 +670,7 @@ class PluginsRouterGroup(group.RouterGroup):
) )
async def _(request_context: RequestContext) -> str: async def _(request_context: RequestContext) -> str:
"""Get releases from a GitHub repository URL""" """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 data = await quart.request.json
repo_url = data.get('repo_url', '') repo_url = data.get('repo_url', '')
@@ -705,7 +750,7 @@ class PluginsRouterGroup(group.RouterGroup):
) )
async def _(request_context: RequestContext) -> str: async def _(request_context: RequestContext) -> str:
"""Get assets from a specific GitHub release""" """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 data = await quart.request.json
owner = data.get('owner', '') owner = data.get('owner', '')
repo = data.get('repo', '') repo = data.get('repo', '')
@@ -901,7 +946,7 @@ class PluginsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE, permission=Permission.RESOURCE_MANAGE,
) )
async def _(request_context: RequestContext) -> str: 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') file = (await quart.request.files).get('file')
if file is None: if file is None:
return self.http_status(400, -1, 'file is required') return self.http_status(400, -1, 'file is required')
@@ -942,7 +987,7 @@ class PluginsRouterGroup(group.RouterGroup):
) )
async def _(request_context: RequestContext) -> str: async def _(request_context: RequestContext) -> str:
"""Upload a file for plugin configuration""" """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') file = (await quart.request.files).get('file')
if file is None: if file is None:
return self.http_status(400, -1, 'file is required') 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: async def _(file_key: str, request_context: RequestContext) -> str:
"""Delete a plugin configuration file""" """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'): 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') return self.http_status(400, -1, 'invalid file key')
@@ -396,6 +396,19 @@ class UserRouterGroup(group.RouterGroup):
launch_assertion, launch_assertion,
expected_workspace_uuid=workspace_uuid, 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']) account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is None: if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core') raise SpaceLaunchError('Launch Account is not projected into Core')
@@ -5,7 +5,7 @@ import typing
import quart import quart
from ...authz import Permission, permissions_for_role from ...authz import Permission, permissions_for_role
from ...context import RequestContext from ...context import PrincipalType, RequestContext
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
from .....entity.persistence.workspace import WorkspaceSource from .....entity.persistence.workspace import WorkspaceSource
@@ -120,9 +120,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW) @self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
async def _(request_context: RequestContext) -> typing.Any: async def _(request_context: RequestContext) -> typing.Any:
membership = quart.g.workspace_membership 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) workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
plan_name: str | None = None plan_name: str | None = None
resolver = getattr(self.ap, 'entitlement_resolver', None) resolver = getattr(self.ap, 'entitlement_resolver', None)
@@ -132,6 +129,28 @@ class WorkspacesRouterGroup(group.RouterGroup):
minimum_revision=request_context.entitlement_revision, minimum_revision=request_context.entitlement_revision,
) )
plan_name = entitlement.plan_name 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( return self.success(
data={ data={
'workspace': _workspace_payload(workspace), 'workspace': _workspace_payload(workspace),
+5 -2
View File
@@ -400,7 +400,10 @@ class UserService:
return await self.generate_jwt_token(user_obj) 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_secret = self.ap.instance_config.data['system']['jwt']['secret']
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire'] 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. # Lightweight unit-test and bootstrap callers may not have persistence wired.
account_obj = None account_obj = None
payload = { payload: dict[str, typing.Any] = {
'user': user_email, 'user': user_email,
'iss': self._jwt_identity()[0], 'iss': self._jwt_identity()[0],
'aud': self._jwt_identity()[1], 'aud': self._jwt_identity()[1],
+54 -5
View File
@@ -15,12 +15,15 @@ from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from .support_admin import SupportAdminReplayError, SupportAdminSessionError, hash_grant_jti
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from ..core.app import Application from ..core.app import Application
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt' CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
LAUNCH_KIND = 'workspace.launch' LAUNCH_KIND = 'workspace.launch'
SUPPORT_ADMIN_LAUNCH_KIND = 'workspace.support_admin_launch'
EXPECTED_ISSUER = 'langbot-space' EXPECTED_ISSUER = 'langbot-space'
EXPECTED_AUDIENCE = 'langbot-cloud-runtime' EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
_CONSUMED_JTI_MAX_ENTRIES = 4096 _CONSUMED_JTI_MAX_ENTRIES = 4096
@@ -123,15 +126,60 @@ class SpaceLaunchService:
payload = claims.get('payload') payload = claims.get('payload')
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise SpaceLaunchError('Launch assertion payload must be a JSON object') 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') workspace_uuid = _required_string(payload, 'workspace_uuid')
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid: if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
raise SpaceLaunchError('Launch assertion targets another Workspace') raise SpaceLaunchError('Launch assertion targets another Workspace')
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1)) if kind == SUPPORT_ADMIN_LAUNCH_KIND:
return { 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, 'account_uuid': account_uuid,
'workspace_uuid': workspace_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]: def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): 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') raise SpaceLaunchError('Launch assertion subject targets another instance')
if _required_string(claims, 'instance_uuid') != instance_uuid: if _required_string(claims, 'instance_uuid') != instance_uuid:
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core') raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
if _required_string(claims, 'kind') != LAUNCH_KIND: kind = _required_string(claims, 'kind')
raise SpaceLaunchError('Launch assertion kind is not workspace.launch') if kind not in {LAUNCH_KIND, SUPPORT_ADMIN_LAUNCH_KIND}:
raise SpaceLaunchError('Launch assertion kind is not supported')
issued_at = _required_int(claims, 'iat') issued_at = _required_int(claims, 'iat')
not_before = _required_int(claims, 'nbf') not_before = _required_int(claims, 'nbf')
+248
View File
@@ -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)
+3
View File
@@ -53,6 +53,7 @@ from ..workspace import collaboration as workspace_collaboration_module
from ..workspace import invitation_delivery as invitation_delivery_module from ..workspace import invitation_delivery as invitation_delivery_module
from ..cloud import bootstrap as cloud_bootstrap_module from ..cloud import bootstrap as cloud_bootstrap_module
from ..cloud import launch as cloud_launch_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 directory_projection as cloud_directory_projection_module
from ..cloud import entitlements as cloud_entitlements_module from ..cloud import entitlements as cloud_entitlements_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -141,6 +142,8 @@ class Application:
space_launch_service: cloud_launch_module.SpaceLaunchService = None 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: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
+2
View File
@@ -44,6 +44,7 @@ from ...workspace import collaboration as workspace_collaboration_module
from ...workspace import invitation_delivery as invitation_delivery_module from ...workspace import invitation_delivery as invitation_delivery_module
from ...cloud import bootstrap as cloud_bootstrap from ...cloud import bootstrap as cloud_bootstrap
from ...cloud import launch as cloud_launch_module 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 import directory_projection_limits_from_config
from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.directory_projection import DirectoryProjectionService
from ...cloud.entitlements import EntitlementResolver from ...cloud.entitlements import EntitlementResolver
@@ -185,6 +186,7 @@ class BuildAppStage(stage.BootingStage):
workspace_service_inst, workspace_service_inst,
) )
ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap) 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) ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap)
user_service_inst = user_service.UserService(ap) user_service_inst = user_service.UserService(ap)
@@ -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',
),
)
@@ -18,6 +18,17 @@ down_revision = '0008_mcp_resource_prefs'
branch_labels = None branch_labels = None
depends_on = 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]: def _table_names(conn: sa.Connection) -> set[str]:
return set(sa.inspect(conn).get_table_names()) 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) .values(created_by_account_uuid=owner_account_uuid)
) )
else: else:
workspace_uuid = str(uuid.uuid4()) workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid)
conn.execute( conn.execute(
workspaces.insert().values( workspaces.insert().values(
uuid=workspace_uuid, uuid=workspace_uuid,
@@ -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)
@@ -0,0 +1,167 @@
"""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
@@ -1,7 +1,7 @@
"""repair an ownerless local Workspace after tenancy migration """repair an ownerless local Workspace after tenancy migration
Revision ID: 0017_local_owner_repair Revision ID: 0018_local_owner_repair
Revises: 0016_agent_workspace Revises: 0016_agent_workspace, 0017_oss_workspace_identity
Create Date: 2026-07-31 Create Date: 2026-07-31
""" """
@@ -14,8 +14,8 @@ import sqlalchemy as sa
from alembic import op from alembic import op
revision = '0017_local_owner_repair' revision = '0018_local_owner_repair'
down_revision = '0016_agent_workspace' down_revision = ('0016_agent_workspace', '0017_oss_workspace_identity')
branch_labels = None branch_labels = None
depends_on = None depends_on = None
+1
View File
@@ -53,6 +53,7 @@ _ALEMBIC_TENANT_TABLES = {
'workspace_memberships', 'workspace_memberships',
'workspace_invitations', 'workspace_invitations',
'workspace_execution_states', 'workspace_execution_states',
'support_admin_temporary_sessions',
'workspace_metadata', 'workspace_metadata',
'api_keys', 'api_keys',
'agents', 'agents',
@@ -43,6 +43,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
'workspace_memberships': 'workspace_uuid', 'workspace_memberships': 'workspace_uuid',
'workspace_invitations': 'workspace_uuid', 'workspace_invitations': 'workspace_uuid',
'workspace_execution_states': 'workspace_uuid', 'workspace_execution_states': 'workspace_uuid',
'support_admin_temporary_sessions': 'workspace_uuid',
'workspace_metadata': 'workspace_uuid', 'workspace_metadata': 'workspace_uuid',
'api_keys': 'workspace_uuid', 'api_keys': 'workspace_uuid',
'agents': 'workspace_uuid', 'agents': 'workspace_uuid',
@@ -16,6 +16,7 @@ from ....agent.runner.config_resolver import RunnerConfigResolver
from ....agent.runner import config_schema from ....agent.runner import config_schema
from ....utils import constants, runner as runner_utils from ....utils import constants, runner as runner_utils
from ....telemetry import features as telemetry_features from ....telemetry import features as telemetry_features
from ....telemetry.identity import workspace_identity
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
@@ -303,7 +304,8 @@ class ChatMessageHandler(handler.MessageHandler):
'duration_ms': duration_ms, 'duration_ms': duration_ms,
'model_name': model_name, 'model_name': model_name,
'version': constants.semantic_version, '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, 'edition': constants.edition,
'pipeline_plugins': pipeline_plugins, 'pipeline_plugins': pipeline_plugins,
'features': features, 'features': features,
+4 -1
View File
@@ -179,10 +179,13 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
) )
file_format = 'image/jpeg' file_format = 'image/jpeg'
# NOTE: Telegram's file.file_path is a full URL of the form
# https://api.telegram.org/file/bot<TOKEN>/<path> 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) encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append( message_components.append(
platform_message.Image( platform_message.Image(
url=file.file_path,
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}', base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
) )
) )
@@ -9,7 +9,7 @@ from datetime import datetime
import pydantic import pydantic
from ...api.http.context import ExecutionContext from ...api.http.context import ExecutionContext, PrincipalContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SESSION_FILTER_UNSET = object() _SESSION_FILTER_UNSET = object()
@@ -95,6 +95,9 @@ class WebSocketConnection(pydantic.BaseModel):
metadata: dict = pydantic.Field(default_factory=dict) metadata: dict = pydantic.Field(default_factory=dict)
"""连接元数据(可存储额外信息)""" """连接元数据(可存储额外信息)"""
trigger_principal: PrincipalContext | None = None
"""Authenticated principal that opened this dashboard connection."""
@property @property
def scope(self) -> WebSocketScope: def scope(self) -> WebSocketScope:
return WebSocketScope( return WebSocketScope(
@@ -112,6 +115,7 @@ class WebSocketConnection(pydantic.BaseModel):
workspace_uuid=self.workspace_uuid, workspace_uuid=self.workspace_uuid,
placement_generation=self.placement_generation, placement_generation=self.placement_generation,
pipeline_uuid=self.pipeline_uuid, pipeline_uuid=self.pipeline_uuid,
trigger_principal=self.trigger_principal,
) )
@@ -138,6 +142,7 @@ class WebSocketConnectionManager:
pipeline_uuid: str, pipeline_uuid: str,
session_type: str, session_type: str,
metadata: dict | None = None, metadata: dict | None = None,
trigger_principal: PrincipalContext | None = None,
session_id: str | None = None, session_id: str | None = None,
send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE, send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE,
max_connections: int = 1024, max_connections: int = 1024,
@@ -174,6 +179,7 @@ class WebSocketConnectionManager:
session_id=session_id, session_id=session_id,
websocket=websocket, websocket=websocket,
metadata=metadata or {}, metadata=metadata or {},
trigger_principal=trigger_principal,
send_queue=asyncio.Queue(maxsize=send_queue_size), send_queue=asyncio.Queue(maxsize=send_queue_size),
) )
+97 -5
View File
@@ -27,6 +27,19 @@ if typing.TYPE_CHECKING:
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600 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]
execution_generation: int
async def _count( async def _count(
ap: core_app.Application, ap: core_app.Application,
table, table,
@@ -52,8 +65,64 @@ async def _count(
return -1 return -1
async def build_heartbeat_payload(ap: core_app.Application) -> dict: async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -> list[WorkspaceResourceSnapshot]:
"""Collect the anonymous instance profile snapshot.""" """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 []
counts: dict[str, WorkspaceResourceSnapshot] = {
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,
'skill_count': 0,
'adapters': [],
'execution_generation': binding.placement_generation,
}
for binding in bindings
}
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
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 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 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,
*,
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 bot as persistence_bot
from ..entity.persistence import mcp as persistence_mcp from ..entity.persistence import mcp as persistence_mcp
from ..entity.persistence import pipeline as persistence_pipeline from ..entity.persistence import pipeline as persistence_pipeline
@@ -136,11 +205,14 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
except Exception: except Exception:
pass pass
if workspace_resource is not None:
features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'})
return { return {
'event_type': 'instance_heartbeat', 'event_type': 'instance_heartbeat',
'query_id': '', 'query_id': '',
'version': constants.semantic_version, 'version': constants.semantic_version,
'instance_id': constants.instance_id, 'workspace_uuid': workspace_uuid,
'instance_create_ts': constants.instance_create_ts, 'instance_create_ts': constants.instance_create_ts,
'edition': constants.edition, 'edition': constants.edition,
'features': features, 'features': features,
@@ -148,14 +220,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: async def heartbeat_loop(ap: core_app.Application) -> None:
"""Send one heartbeat shortly after startup, then daily.""" """Send one heartbeat shortly after startup, then daily."""
# Small delay so managers (platform, skills, plugins) finish loading first # Small delay so managers (platform, skills, plugins) finish loading first
await asyncio.sleep(30) await asyncio.sleep(30)
while True: while True:
try: try:
payload = await build_heartbeat_payload(ap) for payload in await build_heartbeat_payloads(ap):
await ap.telemetry.start_send_task(payload) # 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: except Exception as e:
try: try:
ap.logger.debug(f'Telemetry heartbeat failed: {e}') ap.logger.debug(f'Telemetry heartbeat failed: {e}')
+16
View File
@@ -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}
+17 -3
View File
@@ -2,7 +2,11 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import os
import typing
import httpx import httpx
from ..core import app as core_app from ..core import app as core_app
from ..utils import httpclient from ..utils import httpclient
@@ -21,7 +25,7 @@ class TelemetryManager:
def __init__(self, ap: core_app.Application): def __init__(self, ap: core_app.Application):
self.ap = ap self.ap = ap
self.telemetry_config = {} self.telemetry_config: dict[str, typing.Any] = {}
self.send_tasks: list[asyncio.Task] = [] self.send_tasks: list[asyncio.Task] = []
self._client: httpx.AsyncClient | None = None self._client: httpx.AsyncClient | None = None
@@ -131,7 +135,16 @@ class TelemetryManager:
async with self._client_context() as client: async with self._client_context() as client:
try: try:
# Use asyncio.wait_for to ensure we always bound the total time # 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: if resp.status_code >= 400:
body = await httpclient.response_text(resp, max_chars=200) body = await httpclient.response_text(resp, max_chars=200)
@@ -143,7 +156,8 @@ class TelemetryManager:
app_err = False app_err = False
try: try:
j = await httpclient.parse_json_response(resp) 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 app_err = True
self.ap.logger.warning( self.ap.logger.warning(
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}' f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
+20
View File
@@ -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))
+2 -1
View File
@@ -30,6 +30,7 @@ from .errors import (
WorkspaceOwnerAlreadyExistsError, WorkspaceOwnerAlreadyExistsError,
) )
from .entities import WorkspaceExecutionBinding from .entities import WorkspaceExecutionBinding
from .identity import workspace_uuid_from_instance_id
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from .repository import WorkspaceRepository from .repository import WorkspaceRepository
@@ -497,7 +498,7 @@ class WorkspaceService:
created_by_account_uuid: str | None = None, created_by_account_uuid: str | None = None,
) -> Workspace: ) -> Workspace:
return Workspace( return Workspace(
uuid=str(uuid.uuid4()), uuid=workspace_uuid_from_instance_id(self.instance_uuid),
instance_uuid=self.instance_uuid, instance_uuid=self.instance_uuid,
name=name, name=name,
slug=slug, slug=slug,
@@ -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
)
@@ -270,7 +270,7 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
response = await client.get( response = await client.get(
'/api/v1/user/space-credits', '/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 assert response.status_code == 200
@@ -22,6 +22,7 @@ from langbot.pkg.persistence.alembic_runner import (
from langbot.pkg.utils import constants from langbot.pkg.utils import constants
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
from langbot.pkg.workspace.collaboration import normalize_email 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] pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
@@ -109,6 +110,7 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy
.mappings() .mappings()
.one() .one()
) )
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
assert workspace['instance_uuid'] == 'instance_migration_test' assert workspace['instance_uuid'] == 'instance_migration_test'
assert workspace['slug'] == 'default' assert workspace['slug'] == 'default'
assert workspace['status'] == 'active' assert workspace['status'] == 'active'
@@ -153,11 +155,11 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega
await run_alembic_upgrade(legacy_engine, '0016_agent_workspace') await run_alembic_upgrade(legacy_engine, '0016_agent_workspace')
async with legacy_engine.begin() as conn: async with legacy_engine.begin() as conn:
owner_account_uuid = await conn.scalar(sa.text('SELECT uuid FROM users ORDER BY id LIMIT 1')) owner_account_uuid = await conn.scalar(sa.text('SELECT uuid FROM users ORDER BY id LIMIT 1'))
workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) old_workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
await conn.execute(sa.text('DELETE FROM workspace_memberships')) await conn.execute(sa.text('DELETE FROM workspace_memberships'))
await conn.execute( await conn.execute(
sa.text('UPDATE workspaces SET created_by_account_uuid = NULL WHERE uuid = :workspace_uuid'), sa.text('UPDATE workspaces SET created_by_account_uuid = NULL WHERE uuid = :workspace_uuid'),
{'workspace_uuid': workspace_uuid}, {'workspace_uuid': old_workspace_uuid},
) )
await run_alembic_upgrade(legacy_engine, 'head') await run_alembic_upgrade(legacy_engine, 'head')
@@ -165,19 +167,66 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega
async with legacy_engine.connect() as conn: async with legacy_engine.connect() as conn:
workspace = ( workspace = (
await conn.execute( await conn.execute(
sa.text('SELECT created_by_account_uuid FROM workspaces WHERE uuid = :workspace_uuid'), sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"),
{'workspace_uuid': workspace_uuid},
) )
).mappings().one() ).mappings().one()
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one() membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
assert workspace['created_by_account_uuid'] == owner_account_uuid assert workspace['created_by_account_uuid'] == owner_account_uuid
assert membership['workspace_uuid'] == workspace_uuid assert membership['workspace_uuid'] == workspace['uuid']
assert membership['account_uuid'] == owner_account_uuid assert membership['account_uuid'] == owner_account_uuid
assert membership['role'] == 'owner' assert membership['role'] == 'owner'
assert membership['status'] == 'active' assert membership['status'] == 'active'
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): 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"}') engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
try: try:
@@ -391,6 +440,47 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
await engine.dispose() 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): async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}') engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
try: try:
@@ -418,6 +418,7 @@ class TestUserServiceGenerateJwtToken:
assert token is not None assert token is not None
class TestUserServiceVerifyJwtToken: class TestUserServiceVerifyJwtToken:
"""Tests for verify_jwt_token method.""" """Tests for verify_jwt_token method."""
@@ -149,6 +149,36 @@ async def test_session_scope_matches_exact_tenant_placement_and_principal():
assert sessions == {} 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(): async def test_session_capacity_evicts_oldest_session_in_same_workspace():
owner_context = _request_context() owner_context = _request_context()
sessions: dict[str, dict] = {} sessions: dict[str, dict] = {}
@@ -46,6 +46,20 @@ def plugin_router_cls():
yield PluginsRouterGroup 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 @pytest.mark.asyncio
async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls): async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
connector = SimpleNamespace( connector = SimpleNamespace(
@@ -11,6 +11,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
from langbot.pkg.cloud.support_admin import SupportAdminReplayError
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@@ -57,9 +58,21 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
encoding=serialization.Encoding.Raw, encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.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( app = SimpleNamespace(
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID), deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID), workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
logger=SimpleNamespace(info=lambda *args, **kwargs: None),
support_admin_session_service=DurableSupportAdminService(),
instance_config=SimpleNamespace( instance_config=SimpleNamespace(
data={ data={
'space': { 'space': {
@@ -86,6 +99,81 @@ async def test_consumes_valid_workspace_launch_assertion_once():
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) 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): async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
private_key = Ed25519PrivateKey.generate() private_key = Ed25519PrivateKey.generate()
now = int(time.time()) now = int(time.time())
@@ -1,7 +1,8 @@
"""Tests for Telegram Dify form callback helpers.""" """Tests for Telegram Dify form callback helpers."""
import json import json
from unittest.mock import AsyncMock, MagicMock from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from telegram import ForceReply 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.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.telegram import ( from langbot.pkg.platform.sources.telegram import (
TelegramAdapter, TelegramAdapter,
TelegramMessageConverter,
_decode_telegram_base64_limited, _decode_telegram_base64_limited,
_telegram_form_action_from_callback, _telegram_form_action_from_callback,
_telegram_select_field_options, _telegram_select_field_options,
@@ -26,6 +29,70 @@ def test_telegram_base64_decode_is_bounded(monkeypatch):
_decode_telegram_base64_limited('A' * 12) _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<TOKEN>/<path>, 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: def _select_form_data() -> dict:
return { return {
'_current_input_field': 'choice', '_current_input_field': 'choice',
+57 -13
View File
@@ -60,10 +60,12 @@ class TestBuildHeartbeatPayload:
async def test_payload_shape(self): async def test_payload_shape(self):
heartbeat = get_heartbeat_module() heartbeat = get_heartbeat_module()
ap = make_app() 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['event_type'] == 'instance_heartbeat'
assert payload['query_id'] == '' assert payload['query_id'] == ''
assert payload['workspace_uuid'] == 'workspace-a'
assert 'instance_id' not in payload
assert 'instance_create_ts' in payload assert 'instance_create_ts' in payload
assert 'timestamp' in payload assert 'timestamp' in payload
f = payload['features'] f = payload['features']
@@ -86,7 +88,7 @@ class TestBuildHeartbeatPayload:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_payload_is_json_serializable(self): async def test_payload_is_json_serializable(self):
heartbeat = get_heartbeat_module() 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) json.dumps(payload)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -94,7 +96,7 @@ class TestBuildHeartbeatPayload:
heartbeat = get_heartbeat_module() heartbeat = get_heartbeat_module()
ap = make_app() ap = make_app()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down')) 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 assert payload['features']['pipeline_count'] == -1
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -106,31 +108,73 @@ class TestBuildHeartbeatPayload:
side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs') side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs')
) )
ap.pipeline_mgr = SimpleNamespace( 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(),
},
) )
adapter_a = type('WorkspaceAAdapter', (), {})()
adapter_b = type('WorkspaceBAdapter', (), {})()
ap.platform_mgr._bots_by_key = {
('instance-a', 'workspace-a', 'bot-a'): SimpleNamespace(enable=True, adapter=adapter_a),
}
ap.tool_mgr = SimpleNamespace( ap.tool_mgr = SimpleNamespace(
mcp_tool_loader=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( 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.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', placement_generation=7),
SimpleNamespace(workspace_uuid='workspace-b', placement_generation=9),
],
)
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 [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
assert features['pipeline_count'] == 2 assert all('instance_id' not in payload for payload in payloads)
assert features['mcp_server_count'] == 3 by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
assert features['knowledge_base_count'] == 1 assert by_workspace['workspace-a']['pipeline_count'] == 2
assert features['bot_count'] == 1 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']['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() ap.persistence_mgr.execute_async.assert_not_awaited()
ap.workspace_service.list_active_execution_bindings.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_user_content_fields(self): async def test_no_user_content_fields(self):
"""The heartbeat must never carry message content / credentials keys.""" """The heartbeat must never carry message content / credentials keys."""
heartbeat = get_heartbeat_module() 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() flat = json.dumps(payload).lower()
for forbidden in ('api_key', 'password', 'token', 'message_content'): for forbidden in ('api_key', 'password', 'token', 'message_content'):
assert forbidden not in flat assert forbidden not in flat
@@ -569,6 +569,33 @@ class TestHTTPScenarios:
await manager.send({'query_id': 'test'}) 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: class TestStartSendTask:
"""Tests for start_send_task() method.""" """Tests for start_send_task() method."""
@@ -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"}
@@ -26,6 +26,7 @@ from langbot.pkg.workspace import (
WorkspaceOwnerAlreadyExistsError, WorkspaceOwnerAlreadyExistsError,
WorkspaceService, WorkspaceService,
) )
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
from langbot.pkg.workspace.policy import CloudWorkspacePolicy 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() first = await service.ensure_singleton_workspace()
second = 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 assert second.uuid == first.uuid
async with session_factory() as session: async with session_factory() as session:
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1 assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
+14 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { import {
beginAuthenticatedSession, beginAuthenticatedSession,
beginSupportAdminSession,
bootstrapWorkspaceSession, bootstrapWorkspaceSession,
getPendingInvitationToken, getPendingInvitationToken,
} from '@/app/infra/http'; } from '@/app/infra/http';
@@ -27,8 +28,10 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
type SpaceOAuthLoginResult = { type SpaceOAuthLoginResult = {
token: string; token: string;
user: string; user?: string;
workspace_uuid?: string; workspace_uuid?: string;
principal_type?: 'account' | 'support_admin';
actor_account_uuid?: string;
}; };
const pendingSpaceOAuthLogins = new Map< const pendingSpaceOAuthLogins = new Map<
@@ -94,6 +97,16 @@ function SpaceOAuthCallbackContent() {
return; 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); beginAuthenticatedSession(response.token, response.user);
if (getPendingInvitationToken()) { if (getPendingInvitationToken()) {
navigate('/invitations/accept', { replace: true }); navigate('/invitations/accept', { replace: true });
@@ -197,6 +197,19 @@ export function SidebarDataProvider({
// Deduplicate plugins by composite key (prefer debug over installed) // Deduplicate plugins by composite key (prefer debug over installed)
const pluginMap = new Map<string, SidebarEntityItem>(); const pluginMap = new Map<string, SidebarEntityItem>();
const pluginIconURLs = new Map<string, string>(
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) { for (const plugin of pluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata; const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? ''; const author = meta.author ?? '';
@@ -215,7 +228,7 @@ export function SidebarDataProvider({
const item: SidebarEntityItem = { const item: SidebarEntityItem = {
id: compositeKey, id: compositeKey,
name: extractI18nObject(meta.label), name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name), iconURL: pluginIconURLs.get(compositeKey) || '',
installSource: plugin.install_source, installSource: plugin.install_source,
installInfo: plugin.install_info, installInfo: plugin.install_info,
hasUpdate, hasUpdate,
@@ -249,7 +262,7 @@ export function SidebarDataProvider({
pluginAuthor: author, pluginAuthor: author,
pluginName: name, pluginName: name,
pluginLabel: label, pluginLabel: label,
pluginIconURL: httpClient.getPluginIconURL(author, name), pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '',
pageId: page.id, pageId: page.id,
path: page.path, path: page.path,
}); });
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker'; import EmojiPicker from '@/components/ui/emoji-picker';
import { import {
@@ -428,12 +429,9 @@ export default function KBForm({
); );
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <AuthenticatedPluginIcon
src={httpClient.getPluginIconURL( author={author}
author, name={name}
name,
)}
alt=""
className="h-5 w-5 rounded" className="h-5 w-5 rounded"
/> />
<span> <span>
@@ -459,12 +457,9 @@ export default function KBForm({
value={engine.plugin_id} value={engine.plugin_id}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <AuthenticatedPluginIcon
src={httpClient.getPluginIconURL( author={author}
author, name={name}
name,
)}
alt=""
className="h-5 w-5 rounded" className="h-5 w-5 rounded"
/> />
<span>{extractI18nObject(engine.name)}</span> <span>{extractI18nObject(engine.name)}</span>
+2 -1
View File
@@ -17,6 +17,7 @@ import {
bootstrapWorkspaceSession, bootstrapWorkspaceSession,
systemInfo, systemInfo,
initializeSystemInfo, initializeSystemInfo,
isSupportAdminSession,
useCurrentWorkspace, useCurrentWorkspace,
} from '@/app/infra/http'; } from '@/app/infra/http';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
@@ -157,7 +158,7 @@ export default function HomeLayout({
// selected Workspace's wizard state. // selected Workspace's wizard state.
useEffect(() => { useEffect(() => {
if (!identityReady) return; if (!identityReady) return;
if (systemInfo.wizard_status === 'none') { if (systemInfo?.wizard_status === 'none' && !isSupportAdminSession()) {
navigate('/wizard', { replace: true }); navigate('/wizard', { replace: true });
} }
}, [identityReady, navigate]); }, [identityReady, navigate]);
@@ -13,7 +13,7 @@ import {
Puzzle, Puzzle,
} from 'lucide-react'; } from 'lucide-react';
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http'; 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 { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { import {
@@ -39,6 +39,11 @@ export default function ExtensionCardComponent({
const { t } = useTranslation(); const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [iconFailed, setIconFailed] = useState(false); const [iconFailed, setIconFailed] = useState(false);
const authenticatedIcon = useAuthenticatedPluginIcon(
cardVO.author,
cardVO.name,
cardVO.type === 'plugin',
);
const FallbackIcon = const FallbackIcon =
cardVO.type === 'mcp' cardVO.type === 'mcp'
@@ -47,8 +52,8 @@ export default function ExtensionCardComponent({
? Sparkles ? Sparkles
: Puzzle; : Puzzle;
const iconSrc = const iconSrc =
cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name); cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL;
const showFallback = iconFailed || !iconSrc; const showFallback = iconFailed || authenticatedIcon.error || !iconSrc;
const getTypeLabel = (type: ExtensionType) => { const getTypeLabel = (type: ExtensionType) => {
switch (type) { switch (type) {
@@ -10,6 +10,74 @@ import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { getAPILanguageCode } from '@/i18n/I18nProvider'; import { getAPILanguageCode } from '@/i18n/I18nProvider';
import '@/styles/github-markdown.css'; 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<HTMLImageElement>) {
const { url, error } = useAuthenticatedPluginAsset(author, name, filepath);
if (error)
return (
<span className="text-sm text-muted-foreground">{alt || filepath}</span>
);
if (!url)
return (
<span className="inline-block h-6 w-24 animate-pulse rounded bg-muted" />
);
return (
<img
src={url}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
}
function PluginReadmeImage({
author,
name,
src,
alt,
...props
}: {
author: string;
name: string;
src?: string;
alt?: string;
} & React.ImgHTMLAttributes<HTMLImageElement>) {
const imageSrc = typeof src === 'string' ? src : '';
if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) {
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
}
let filepath = imageSrc.replace(/^(\.\/|\/)+/, '');
filepath = filepath.replace(/^assets\//, '');
return (
<AuthenticatedReadmeImage
author={author}
name={name}
filepath={filepath}
alt={alt}
{...props}
/>
);
}
export default function PluginReadme({ export default function PluginReadme({
pluginAuthor, pluginAuthor,
@@ -71,49 +139,15 @@ export default function PluginReadme({
<ol className="list-decimal">{children}</ol> <ol className="list-decimal">{children}</ol>
), ),
li: ({ children }) => <li className="ml-4">{children}</li>, li: ({ children }) => <li className="ml-4">{children}</li>,
img: ({ src, alt, ...props }) => { img: ({ src, alt, ...props }) => (
let imageSrc = src || ''; <PluginReadmeImage
author={pluginAuthor}
if (typeof imageSrc !== 'string') { name={pluginName}
return ( src={typeof src === 'string' ? src : undefined}
<img alt={alt}
src={src} {...props}
alt={alt || ''} />
className="max-w-full h-auto rounded-lg my-4" ),
{...props}
/>
);
}
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 (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}} }}
> >
{readme} {readme}
+29 -1
View File
@@ -776,6 +776,32 @@ export class BackendClient extends BaseHttpClient {
); );
} }
private async getAuthenticatedObjectURL(path: string): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
}
public getAuthenticatedPluginAssetURL(
author: string,
name: string,
filepath: string,
): Promise<string> {
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
);
}
public getAuthenticatedPluginIconURL(
author: string,
name: string,
): Promise<string> {
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-icon`,
);
}
public async pluginPageApi( public async pluginPageApi(
author: string, author: string,
name: string, name: string,
@@ -1400,8 +1426,10 @@ export class BackendClient extends BaseHttpClient {
launchAssertion?: string, launchAssertion?: string,
): Promise<{ ): Promise<{
token: string; token: string;
user: string; user?: string;
workspace_uuid?: string; workspace_uuid?: string;
principal_type?: 'account' | 'support_admin';
actor_account_uuid?: string;
}> { }> {
const response = await this.instance.post( const response = await this.instance.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
+48
View File
@@ -217,10 +217,34 @@ export function beginAuthenticatedSession(
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('userEmail'); localStorage.removeItem('userEmail');
localStorage.removeItem('authPrincipalType');
localStorage.setItem('token', token); localStorage.setItem('token', token);
if (userEmail) localStorage.setItem('userEmail', userEmail); 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( async function initializeSelectedWorkspace(
workspaceUuid: string, workspaceUuid: string,
workspaces: WorkspaceBootstrapEntry[], workspaces: WorkspaceBootstrapEntry[],
@@ -252,6 +276,27 @@ async function initializeSelectedWorkspace(
export async function bootstrapWorkspaceSession( export async function bootstrapWorkspaceSession(
options: WorkspaceBootstrapOptions = {}, options: WorkspaceBootstrapOptions = {},
): Promise<WorkspaceBootstrapResult> { ): Promise<WorkspaceBootstrapResult> {
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) { if (options.resetSelection) {
clearWorkspaceSelection(); clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot(); clearWorkspaceBootstrapSnapshot();
@@ -339,6 +384,9 @@ export const clearUserInfo = (): void => {
userInfo = null; userInfo = null;
clearWorkspaceSelection(); clearWorkspaceSelection();
clearWorkspaceBootstrapSnapshot(); clearWorkspaceBootstrapSnapshot();
if (typeof window !== 'undefined') {
localStorage.removeItem('authPrincipalType');
}
}; };
export { export {
@@ -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 (
<span
aria-hidden={alt ? undefined : true}
aria-label={alt || undefined}
className={cn('inline-block bg-muted', className)}
/>
);
}
return <img src={icon.url} alt={alt} className={className} />;
}
@@ -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 };
}
+100
View File
@@ -175,6 +175,8 @@ const esES = {
more: 'Más ({{count}})', more: 'Más ({{count}})',
less: 'Menos', less: 'Menos',
noItems: 'Sin elementos', noItems: 'Sin elementos',
apiKeyStoredSecurely: 'Secret shown only when created',
}, },
notFound: { notFound: {
title: 'Página no encontrada', title: 'Página no encontrada',
@@ -324,6 +326,11 @@ const esES = {
fallbackList: 'Modelos de respaldo', fallbackList: 'Modelos de respaldo',
addFallback: 'Añadir modelo 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: { bots: {
title: 'Bots', title: 'Bots',
@@ -1374,6 +1381,13 @@ const esES = {
'Establece una contraseña para iniciar sesión con correo y contraseña', 'Establece una contraseña para iniciar sesión con correo y contraseña',
spaceEmailMismatch: spaceEmailMismatch:
'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local', '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: { monitoring: {
title: 'Panel de control', title: 'Panel de control',
@@ -1632,6 +1646,8 @@ const esES = {
api: 'API', api: 'API',
storage: 'Almacenamiento', storage: 'Almacenamiento',
account: 'Cuenta', account: 'Cuenta',
workspace: 'Workspace',
}, },
}, },
storageAnalysis: { storageAnalysis: {
@@ -1948,6 +1964,90 @@ const esES = {
unsupportedFileType: unsupportedFileType:
'Tipo de archivo no admitido. Solo se admiten archivos .zip y .lbpkg', '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; export default esES;
+5
View File
@@ -1636,6 +1636,11 @@ const jaJP = {
operator: 'オペレーター', operator: 'オペレーター',
viewer: '閲覧者', viewer: '閲覧者',
}, },
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
}, },
monitoring: { monitoring: {
title: 'ダッシュボード', title: 'ダッシュボード',
+100
View File
@@ -172,6 +172,8 @@ const ruRU = {
less: 'Свернуть', less: 'Свернуть',
noItems: 'Нет элементов', noItems: 'Нет элементов',
termsOfService: 'Условия обслуживания', termsOfService: 'Условия обслуживания',
apiKeyStoredSecurely: 'Secret shown only when created',
}, },
notFound: { notFound: {
title: 'Страница не найдена', title: 'Страница не найдена',
@@ -323,6 +325,11 @@ const ruRU = {
fallbackList: 'Резервные модели', fallbackList: 'Резервные модели',
addFallback: 'Добавить резервную модель', addFallback: 'Добавить резервную модель',
}, },
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
}, },
bots: { bots: {
title: 'Боты', title: 'Боты',
@@ -1349,6 +1356,13 @@ const ruRU = {
setPasswordHint: 'Установите пароль для входа с email и паролем', setPasswordHint: 'Установите пароль для входа с email и паролем',
spaceEmailMismatch: spaceEmailMismatch:
'Email входа через Space не совпадает с email локальной учётной записи', '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: { monitoring: {
title: 'Мониторинг', title: 'Мониторинг',
@@ -1606,6 +1620,8 @@ const ruRU = {
api: 'API', api: 'API',
storage: 'Хранилище', storage: 'Хранилище',
account: 'Аккаунт', account: 'Аккаунт',
workspace: 'Workspace',
}, },
}, },
storageAnalysis: { storageAnalysis: {
@@ -1914,6 +1930,90 @@ const ruRU = {
unsupportedFileType: unsupportedFileType:
'Неподдерживаемый тип файла. Поддерживаются только файлы .zip и .lbpkg', 'Неподдерживаемый тип файла. Поддерживаются только файлы .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; export default ruRU;
+100
View File
@@ -169,6 +169,8 @@ const thTH = {
more: 'เพิ่มเติม ({{count}})', more: 'เพิ่มเติม ({{count}})',
less: 'น้อยลง', less: 'น้อยลง',
noItems: 'ไม่มีรายการ', noItems: 'ไม่มีรายการ',
apiKeyStoredSecurely: 'Secret shown only when created',
}, },
notFound: { notFound: {
title: 'ไม่พบหน้า', title: 'ไม่พบหน้า',
@@ -310,6 +312,11 @@ const thTH = {
fallbackList: 'โมเดลสำรอง', fallbackList: 'โมเดลสำรอง',
addFallback: 'เพิ่มโมเดลสำรอง', addFallback: 'เพิ่มโมเดลสำรอง',
}, },
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
}, },
bots: { bots: {
title: 'บอท', title: 'บอท',
@@ -1317,6 +1324,13 @@ const thTH = {
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี', bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น', 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: { monitoring: {
title: 'แดชบอร์ด', title: 'แดชบอร์ด',
@@ -1573,6 +1587,8 @@ const thTH = {
api: 'API', api: 'API',
storage: 'พื้นที่จัดเก็บ', storage: 'พื้นที่จัดเก็บ',
account: 'บัญชี', account: 'บัญชี',
workspace: 'Workspace',
}, },
}, },
storageAnalysis: { storageAnalysis: {
@@ -1871,6 +1887,90 @@ const thTH = {
createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง', createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง',
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .zip และ .lbpkg', 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; export default thTH;
+100
View File
@@ -172,6 +172,8 @@ const viVN = {
more: 'Thêm ({{count}})', more: 'Thêm ({{count}})',
less: 'Thu gọn', less: 'Thu gọn',
noItems: 'Không có mục nào', noItems: 'Không có mục nào',
apiKeyStoredSecurely: 'Secret shown only when created',
}, },
notFound: { notFound: {
title: 'Không tìm thấy trang', title: 'Không tìm thấy trang',
@@ -319,6 +321,11 @@ const viVN = {
fallbackList: 'Mô hình dự phòng', fallbackList: 'Mô hình dự phòng',
addFallback: 'Thêm 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: { bots: {
title: 'Bot', title: 'Bot',
@@ -1343,6 +1350,13 @@ const viVN = {
setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu', setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu',
spaceEmailMismatch: spaceEmailMismatch:
'Email đăng nhập Space không khớp với email tài khoản cục bộ', '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: { monitoring: {
title: 'Bảng điều khiển', title: 'Bảng điều khiển',
@@ -1599,6 +1613,8 @@ const viVN = {
api: 'API', api: 'API',
storage: 'Lưu trữ', storage: 'Lưu trữ',
account: 'Tài khoản', account: 'Tài khoản',
workspace: 'Workspace',
}, },
}, },
storageAnalysis: { storageAnalysis: {
@@ -1905,6 +1921,90 @@ const viVN = {
unsupportedFileType: unsupportedFileType:
'Loại tệp không được hỗ trợ. Chỉ hỗ trợ tệp .zip và .lbpkg', '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; export default viVN;
+100
View File
@@ -160,6 +160,8 @@ const zhHant = {
more: '更多 ({{count}})', more: '更多 ({{count}})',
less: '收起', less: '收起',
noItems: '暫無內容', noItems: '暫無內容',
apiKeyStoredSecurely: 'Secret shown only when created',
}, },
notFound: { notFound: {
title: '頁面不存在', title: '頁面不存在',
@@ -300,6 +302,11 @@ const zhHant = {
fallbackList: '備用模型', fallbackList: '備用模型',
addFallback: '新增備用模型', addFallback: '新增備用模型',
}, },
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
}, },
bots: { bots: {
title: '機器人', title: '機器人',
@@ -1270,6 +1277,13 @@ const zhHant = {
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起', bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後可使用電子郵件密碼登入', setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配', 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: { monitoring: {
title: '儀表盤', title: '儀表盤',
@@ -1525,6 +1539,8 @@ const zhHant = {
api: 'API', api: 'API',
storage: '儲存', storage: '儲存',
account: '帳戶', account: '帳戶',
workspace: 'Workspace',
}, },
}, },
storageAnalysis: { storageAnalysis: {
@@ -1812,6 +1828,90 @@ const zhHant = {
saveFileSuccess: '檔案儲存成功', saveFileSuccess: '檔案儲存成功',
saveFileError: '檔案儲存失敗:', 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; export default zhHant;
@@ -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\(\)/);
});