diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 000000000..acf33d0e5 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,59 @@ +name: Build and deploy production + +on: + push: + branches: [deploy/prod] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: langbot-production + cancel-in-progress: false + +env: + CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot + CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core + SPACE_REF: 178b1634c104e635c706e1fb4ca37cb3de073cf9 + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build exact Core image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.CORE_IMAGE }}:prod-${{ github.sha }} + ${{ env.CORE_IMAGE }}:deploy-prod + cache-from: type=gha,scope=core-prod + cache-to: type=gha,mode=max,scope=core-prod + - name: Checkout production Cloud adapter + uses: actions/checkout@v4 + with: + repository: langbot-app/langbot-space + ref: ${{ env.SPACE_REF }} + token: ${{ secrets.CLA_PAT }} + path: .space + - name: Build exact Cloud Core image + uses: docker/build-push-action@v6 + with: + context: .space + file: .space/Dockerfile.cloud + push: true + build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }} + tags: | + ${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }} + ${{ env.CLOUD_IMAGE }}:deploy-prod + cache-from: type=gha,scope=cloud-core-prod + cache-to: type=gha,mode=max,scope=cloud-core-prod diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh new file mode 100755 index 000000000..dc51f89a5 --- /dev/null +++ b/deploy/prod/deploy.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd /opt/langbot-cloud-prod +TAG=${1:?usage: deploy.sh prod-<40-char-sha>} +[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; } +[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; } + +rendered_compose=$(docker compose config) +grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || { + echo 'Cloud control-plane URL must be https://space.langbot.app' >&2 + exit 4 +} +grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || { + echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2 + exit 5 +} +grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || { + echo 'Cloud telemetry ingest token must be configured' >&2 + exit 6 +} + +update_env() { + local key=$1 value=$2 + python3 - "$key" "$value" <<'PY' +from pathlib import Path +import os +import sys + +path = Path('.env') +key, value = sys.argv[1:] +lines = path.read_text().splitlines() +updated = False +for index, line in enumerate(lines): + if line.startswith(f'{key}='): + lines[index] = f'{key}={value}' + updated = True + break +if not updated: + lines.append(f'{key}={value}') +temporary = Path('.env.tmp') +temporary.write_text('\n'.join(lines) + '\n') +os.chmod(temporary, 0o600) +temporary.replace(path) +PY +} +update_env LANGBOT_IMAGE_TAG "$TAG" +set -a +. ./.env +set +a +: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}" + +for attempt in 1 2 3 4 5; do + if docker compose pull postgres redis migrate plugin-runtime core; then + break + fi + if [ "$attempt" -eq 5 ]; then + echo "docker compose pull failed after $attempt attempts" >&2 + exit 1 + fi + delay=$((attempt * 10)) + echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2 + sleep "$delay" +done +docker compose up -d postgres redis +for _ in $(seq 1 60); do + if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi + sleep 2 +done +docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null + +docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \ + -v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL' +SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec +ALTER ROLE langbot_runtime PASSWORD :'runtime_password'; +GRANT CONNECT ON DATABASE langbot TO langbot_runtime; +REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime; +REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime; +REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime; +GRANT USAGE ON SCHEMA public TO langbot_runtime; +SQL + +docker compose --profile tools run --rm migrate + +docker compose up -d --remove-orphans plugin-runtime core +for _ in $(seq 1 90); do + if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then + docker compose ps + exit 0 + fi + sleep 2 +done +docker compose logs --tail=200 core plugin-runtime >&2 +exit 1 diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml new file mode 100644 index 000000000..db224b4f0 --- /dev/null +++ b/deploy/prod/docker-compose.yml @@ -0,0 +1,162 @@ +services: + postgres: + image: pgvector/pgvector:pg17 + container_name: langbot-cloud-postgres + restart: unless-stopped + environment: + POSTGRES_DB: langbot + POSTGRES_USER: langbot_operator + POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"] + interval: 5s + timeout: 5s + retries: 30 + networks: [internal] + + redis: + image: redis:7.4-alpine + container_name: langbot-cloud-redis + restart: unless-stopped + command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"] + volumes: + - redis-data:/data + healthcheck: + test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] + interval: 5s + timeout: 5s + retries: 20 + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + networks: [internal] + + migrate: + image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} + profiles: [tools] + command: [uv, run, langbot, migrate, --cloud] + environment: &core-env + TZ: Asia/Shanghai + SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID} + SYSTEM__EDITION: cloud + SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY} + SYSTEM__JWT__SECRET: ${JWT_SECRET} + SYSTEM__LIMITATION__MAX_BOTS: "2" + SYSTEM__LIMITATION__MAX_PIPELINES: "3" + SYSTEM__LIMITATION__MAX_EXTENSIONS: "3" + SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2" + API__WEBHOOK_PREFIX: https://cloud.langbot.app + API__WEBUI_URL: https://cloud.langbot.app + WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app + DATABASE__USE: postgresql + DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot + DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN + LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot + VDB__USE: pgvector + VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true" + VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536" + PLUGIN__ENABLE: "true" + PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws + PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws + PLUGIN__WORKER__MAX_CPUS: "0.25" + PLUGIN__WORKER__MAX_MEMORY_MB: "256" + PLUGIN__WORKER__MAX_PIDS: "128" + PLUGIN__WORKER__MAX_WORKERS: "16" + PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0" + PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096" + PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true" + LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} + # Cloud v2 currently grants no managed Box capability. Keep the shared + # runtime deployed but disable Core integration until a hard-quota-capable + # backend can satisfy the fail-closed Cloud readiness contract. + BOX__ENABLED: "false" + BOX__BACKEND: nsjail + BOX__RUNTIME__ENDPOINT: ws://box:5410 + BOX__ADMISSION__REQUIRED: "true" + BOX__ADMISSION__LOGICAL_SESSION_ID: global + BOX__ADMISSION__REQUIRED_BACKEND: nsjail + BOX__ADMISSION__MAX_SESSIONS: "1" + BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0" + BOX__ADMISSION__CPUS: "0.25" + BOX__ADMISSION__MEMORY_MB: "256" + BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256" + BOX__LOCAL__HOST_ROOT: /app/data/box + BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box + BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box + LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} + MCP__STDIO__ENABLED: "false" + LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app + LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN} + LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN} + LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY} + LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID} + SPACE__URL: https://space.langbot.app + depends_on: + postgres: {condition: service_healthy} + networks: [internal] + + plugin-runtime: + image: rockchin/langbot:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-plugin-runtime + restart: unless-stopped + command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt] + environment: + LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} + volumes: + - plugin-data:/app/data + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroup: host + privileged: true + expose: ["5400"] + networks: [internal] + + box: + image: rockchin/langbot:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-box + restart: unless-stopped + command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"] + environment: + LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} + LANGBOT_BOX_ROOT: /app/data/box + volumes: + - box-data:/app/data/box + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroup: host + privileged: true + expose: ["5410"] + networks: [internal] + + core: + image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-core + restart: unless-stopped + environment: *core-env + volumes: + - core-data:/app/data + - box-data:/app/data/box + depends_on: + postgres: {condition: service_healthy} + redis: {condition: service_healthy} + plugin-runtime: {condition: service_started} + box: {condition: service_started} + expose: ["5300"] + healthcheck: + test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + networks: [internal, shared-network] + +networks: + internal: + shared-network: + external: true + +volumes: + postgres-data: + redis-data: + plugin-data: + box-data: + core-data: diff --git a/src/langbot/pkg/api/http/authz.py b/src/langbot/pkg/api/http/authz.py index 225e30aa8..7bfc8dbe3 100644 --- a/src/langbot/pkg/api/http/authz.py +++ b/src/langbot/pkg/api/http/authz.py @@ -19,7 +19,6 @@ class Permission(enum.StrEnum): WORKSPACE_VIEW = 'workspace.view' WORKSPACE_UPDATE = 'workspace.update' WORKSPACE_DELETE = 'workspace.delete' - OWNER_TRANSFER = 'owner.transfer' MEMBER_VIEW = 'member.view' MEMBER_INVITE = 'member.invite' MEMBER_UPDATE_ROLE = 'member.update_role' @@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType( if permission not in { Permission.WORKSPACE_DELETE, - Permission.OWNER_TRANSFER, Permission.BILLING_LINK_MANAGE, } ), diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 0917c6c6d..6459ecec7 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -62,7 +62,6 @@ class AuthType(enum.Enum): _SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset( { - Permission.OWNER_TRANSFER.value, Permission.MEMBER_VIEW.value, Permission.MEMBER_INVITE.value, Permission.MEMBER_UPDATE_ROLE.value, diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 8b86ba361..e8e450911 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -285,8 +285,25 @@ class UserRouterGroup(group.RouterGroup): request_context.workspace_uuid, ) owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid) - owner_space_bound = bool(owner and owner.space_account_uuid) - credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None + cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + owner_has_local_space_credentials = bool(owner and owner.space_account_uuid) + # Cloud Accounts authenticate through LangBot Account, so every projected + # Workspace owner is already bound even when this Core has no local OAuth + # token row (model billing uses the owner's control-plane API key). + owner_space_bound = cloud_mode or owner_has_local_space_credentials + if cloud_mode: + catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None) + credits = ( + catalog_service.get_workspace_credits(access.workspace.uuid) + if catalog_service is not None + else None + ) + else: + credits = ( + await self.ap.space_service.get_credits(owner.user) + if owner is not None and owner.space_account_uuid + else None + ) return self.success( data={ 'credits': credits, @@ -302,8 +319,10 @@ class UserRouterGroup(group.RouterGroup): return self.success(data={'initialized': False}) capabilities = await self.ap.user_service.get_login_capabilities() - if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': + cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + if cloud_mode: capabilities['password_login_enabled'] = False + capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 88801c6b8..898a791e2 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -5,6 +5,7 @@ import uuid import sqlalchemy from langbot_plugin.api.entities.builtin.provider import message as provider_message +from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER from ....core import app from ....entity.persistence import model as persistence_model from ....entity.persistence import pipeline as persistence_pipeline @@ -113,6 +114,23 @@ async def _require_workspace_provider( return provider +def _is_cloud_runtime(ap: app.Application) -> bool: + mode = getattr(ap.persistence_mgr, 'mode', None) + return getattr(mode, 'value', None) == 'cloud_runtime' + + +async def _assert_cloud_managed_provider_mutable( + ap: app.Application, + context: TenantContext, + provider_uuid: str, +) -> None: + if not _is_cloud_runtime(ap): + return + provider = await _require_workspace_provider(ap, context, provider_uuid) + if provider.get('requester') == LANGBOT_MODELS_PROVIDER_REQUESTER: + raise ValueError('LangBot Models is managed by Cloud and cannot be modified') + + async def _require_runtime_provider( ap: app.Application, context: TenantContext, @@ -213,6 +231,7 @@ class LLMModelsService: model_data['provider_uuid'] = provider_uuid await _require_workspace_provider(self.ap, context, model_data['provider_uuid']) + await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid']) await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm') await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)) @@ -291,11 +310,17 @@ class LLMModelsService: return model_dict - async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None: + async def update_llm_model( + self, + context: TenantContext, + model_uuid: str, + model_data: dict, + ) -> None: """Update an existing LLM model""" existing_model = await self.get_llm_model(context, model_uuid, include_secret=True) if existing_model is None: raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) model_data = model_data.copy() model_data.pop('uuid', None) model_data.pop('workspace_uuid', None) @@ -321,6 +346,7 @@ class LLMModelsService: provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) await _require_workspace_provider(self.ap, context, provider_uuid) + await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid) await _validate_provider_supports(self.ap, context, provider_uuid, 'llm') result = await self.ap.persistence_mgr.execute_async( @@ -355,6 +381,11 @@ class LLMModelsService: async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None: """Delete an LLM model""" + if _is_cloud_runtime(self.ap): + existing_model = await self.get_llm_model(context, model_uuid, include_secret=True) + if existing_model is None: + raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid), @@ -448,7 +479,10 @@ class EmbeddingModelsService: return serialized if include_secret else [_redact_model_secrets(model) for model in serialized] async def create_embedding_model( - self, context: TenantContext, model_data: dict, preserve_uuid: bool = False + self, + context: TenantContext, + model_data: dict, + preserve_uuid: bool = False, ) -> str: """Create a new embedding model""" model_data = model_data.copy() @@ -472,6 +506,7 @@ class EmbeddingModelsService: model_data['provider_uuid'] = provider_uuid await _require_workspace_provider(self.ap, context, model_data['provider_uuid']) + await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid']) await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding') await self.ap.persistence_mgr.execute_async( @@ -530,11 +565,17 @@ class EmbeddingModelsService: return model_dict - async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None: + async def update_embedding_model( + self, + context: TenantContext, + model_uuid: str, + model_data: dict, + ) -> None: """Update an existing embedding model""" existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True) if existing_model is None: raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) model_data = model_data.copy() model_data.pop('uuid', None) model_data.pop('workspace_uuid', None) @@ -559,6 +600,7 @@ class EmbeddingModelsService: provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) await _require_workspace_provider(self.ap, context, provider_uuid) + await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid) await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding') result = await self.ap.persistence_mgr.execute_async( @@ -593,6 +635,11 @@ class EmbeddingModelsService: async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None: """Delete an embedding model""" + if _is_cloud_runtime(self.ap): + existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True) + if existing_model is None: + raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.delete(persistence_model.EmbeddingModel).where( @@ -685,7 +732,12 @@ class RerankModelsService: serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models] return serialized if include_secret else [_redact_model_secrets(model) for model in serialized] - async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str: + async def create_rerank_model( + self, + context: TenantContext, + model_data: dict, + preserve_uuid: bool = False, + ) -> str: """Create a new rerank model""" model_data = model_data.copy() if not preserve_uuid: @@ -708,6 +760,7 @@ class RerankModelsService: model_data['provider_uuid'] = provider_uuid await _require_workspace_provider(self.ap, context, model_data['provider_uuid']) + await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid']) await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank') await self.ap.persistence_mgr.execute_async( @@ -766,11 +819,17 @@ class RerankModelsService: return model_dict - async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None: + async def update_rerank_model( + self, + context: TenantContext, + model_uuid: str, + model_data: dict, + ) -> None: """Update an existing rerank model""" existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True) if existing_model is None: raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) model_data = model_data.copy() model_data.pop('uuid', None) model_data.pop('workspace_uuid', None) @@ -795,6 +854,7 @@ class RerankModelsService: provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) await _require_workspace_provider(self.ap, context, provider_uuid) + await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid) await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank') result = await self.ap.persistence_mgr.execute_async( @@ -829,6 +889,11 @@ class RerankModelsService: async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None: """Delete a rerank model""" + if _is_cloud_runtime(self.ap): + existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True) + if existing_model is None: + raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.delete(persistence_model.RerankModel).where( diff --git a/src/langbot/pkg/api/http/service/provider.py b/src/langbot/pkg/api/http/service/provider.py index 74647d7ab..dc29d3858 100644 --- a/src/langbot/pkg/api/http/service/provider.py +++ b/src/langbot/pkg/api/http/service/provider.py @@ -5,6 +5,7 @@ import traceback import sqlalchemy +from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER from ....core import app from ....entity.persistence import model as persistence_model from ....workspace.errors import WorkspaceNotFoundError @@ -20,6 +21,20 @@ class ModelProviderService: def __init__(self, ap: app.Application) -> None: self.ap = ap + def _is_cloud_runtime(self) -> bool: + mode = getattr(self.ap.persistence_mgr, 'mode', None) + return getattr(mode, 'value', None) == 'cloud_runtime' + + def _system_requester_is_reserved(self, requester: object) -> bool: + return self._is_cloud_runtime() and requester == LANGBOT_MODELS_PROVIDER_REQUESTER + + async def _assert_provider_mutable(self, context: TenantContext, provider_uuid: str) -> None: + if not self._is_cloud_runtime(): + return + provider = await self.get_provider(context, provider_uuid) + if provider is not None and self._system_requester_is_reserved(provider.get('requester')): + raise ValueError('LangBot Models is managed by Cloud and cannot be modified') + @staticmethod def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]: if api_keys is None: @@ -99,6 +114,8 @@ class ModelProviderService: async def create_provider(self, context: TenantContext, provider_data: dict) -> str: """Create a new provider""" provider_data = provider_data.copy() + if self._system_requester_is_reserved(provider_data.get('requester')): + raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') provider_data['uuid'] = str(uuid.uuid4()) provider_data['workspace_uuid'] = require_workspace_uuid(context) provider_data['api_keys'] = self._normalize_api_keys( @@ -115,7 +132,10 @@ class ModelProviderService: async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None: """Update an existing provider""" + await self._assert_provider_mutable(context, provider_uuid) provider_data = provider_data.copy() + if self._system_requester_is_reserved(provider_data.get('requester')): + raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') provider_data.pop('uuid', None) provider_data.pop('workspace_uuid', None) if 'api_keys' in provider_data: @@ -145,6 +165,7 @@ class ModelProviderService: async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None: """Delete a provider (only if no models reference it)""" + await self._assert_provider_mutable(context, provider_uuid) workspace_uuid = require_workspace_uuid(context) # Check if any models use this provider llm_result = await self.ap.persistence_mgr.execute_async( @@ -245,6 +266,8 @@ class ModelProviderService: api_keys: list, ) -> str: """Find existing provider or create new one""" + if self._system_requester_is_reserved(requester): + raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') workspace_uuid = require_workspace_uuid(context) api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True)) diff --git a/src/langbot/pkg/cloud/bootstrap.py b/src/langbot/pkg/cloud/bootstrap.py index 8e9ee1886..2c7ddafd2 100644 --- a/src/langbot/pkg/cloud/bootstrap.py +++ b/src/langbot/pkg/cloud/bootstrap.py @@ -13,6 +13,7 @@ from typing import Any, Protocol, runtime_checkable from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider +from .model_catalog import CloudModelCatalogProvider CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap' @@ -50,6 +51,7 @@ class OpenSourceDeployment: ) directory_provider: None = None manifest_provider: None = None + model_catalog_provider: None = None persistence_mode: str = 'oss_compat' required_vector_backend: str | None = None @@ -80,6 +82,7 @@ class VerifiedCloudDeployment: entitlement_provider: EntitlementProvider directory_provider: DirectoryProjectionProvider manifest_provider: CloudManifestProvider + model_catalog_provider: CloudModelCatalogProvider verification_key_id: str mode: str = dataclasses.field(default='cloud', init=False) workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False) @@ -110,6 +113,8 @@ class VerifiedCloudDeployment: raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter') if not isinstance(self.manifest_provider, CloudManifestProvider): raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter') + if not isinstance(self.model_catalog_provider, CloudModelCatalogProvider): + raise CloudBootstrapError('Verified Cloud bootstrap did not provide a model catalog adapter') def validate_instance_config(self, config: dict[str, Any]) -> None: try: @@ -138,8 +143,14 @@ class VerifiedCloudDeployment: if plugin_worker.get('require_hard_limits') is not True: raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true') box_config = config.get('box', {}) - if box_config.get('enabled') is not True: - raise CloudBootstrapError('Cloud runtime requires box.enabled=true') + box_enabled = box_config.get('enabled') + if box_enabled is False: + # Explicitly disabling Box removes the sandbox surface entirely and + # therefore does not weaken tenant isolation. Validate the strict + # runtime/admission contract only when the surface is enabled. + return + if box_enabled is not True: + raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean') if box_config.get('backend') != 'nsjail': raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail') runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip() diff --git a/src/langbot/pkg/cloud/model_catalog.py b/src/langbot/pkg/cloud/model_catalog.py new file mode 100644 index 000000000..056159e0a --- /dev/null +++ b/src/langbot/pkg/cloud/model_catalog.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime +from typing import Any, Literal, Protocol, runtime_checkable + +import sqlalchemy +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator + +from ..entity.persistence import model as persistence_model + + +LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions' +LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models' +_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921') +_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'} +_MODEL_TABLES = ( + persistence_model.LLMModel, + persistence_model.EmbeddingModel, + persistence_model.RerankModel, +) + + +class CloudModelCatalogItem(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + uuid: str = Field(min_length=1, max_length=255) + model_id: str = Field(min_length=1, max_length=255) + category: Literal['chat', 'embedding', 'rerank'] + llm_abilities: tuple[str, ...] = () + is_featured: bool = False + featured_order: int = 0 + + @field_validator('llm_abilities', mode='before') + @classmethod + def normalize_missing_abilities(cls, value: Any) -> Any: + return () if value is None else value + + @field_validator('llm_abilities') + @classmethod + def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if any(not item.strip() or len(item) > 64 for item in value): + raise ValueError('Model abilities must be non-empty strings of at most 64 characters') + if len(set(value)) != len(value): + raise ValueError('Model abilities must be unique') + return value + + +class CloudWorkspaceModelBilling(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + workspace_uuid: str = Field(min_length=36, max_length=36) + owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36) + api_key: SecretStr | None = None + credits: int | None = None + + @field_validator('workspace_uuid') + @classmethod + def validate_uuid(cls, value: str) -> str: + return str(uuid.UUID(value)) + + @field_validator('owner_account_uuid') + @classmethod + def validate_optional_uuid(cls, value: str | None) -> str | None: + return None if value is None else str(uuid.UUID(value)) + + +class CloudModelCatalogSnapshot(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + instance_uuid: str = Field(min_length=1, max_length=255) + generated_at: datetime + base_url: str = Field(min_length=1, max_length=512) + models: tuple[CloudModelCatalogItem, ...] + workspaces: tuple[CloudWorkspaceModelBilling, ...] + + @field_validator('base_url') + @classmethod + def validate_base_url(cls, value: str) -> str: + normalized = value.rstrip('/') + if not normalized.startswith('https://'): + raise ValueError('Cloud model gateway base URL must use HTTPS') + return normalized + + @field_validator('models') + @classmethod + def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]: + if len(value) > 500: + raise ValueError('Cloud model catalog exceeds 500 models') + identities = {(item.category, item.uuid) for item in value} + if len(identities) != len(value): + raise ValueError('Cloud model catalog contains duplicate model identities') + return value + + @field_validator('workspaces') + @classmethod + def validate_workspaces( + cls, value: tuple[CloudWorkspaceModelBilling, ...] + ) -> tuple[CloudWorkspaceModelBilling, ...]: + if len(value) > 10_000: + raise ValueError('Cloud model catalog exceeds 10000 Workspaces') + identities = {item.workspace_uuid for item in value} + if len(identities) != len(value): + raise ValueError('Cloud model catalog contains duplicate Workspaces') + return value + + +@runtime_checkable +class CloudModelCatalogProvider(Protocol): + async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot: + """Fetch and verify the complete model catalog and Workspace billing projection.""" + ... + + +def system_provider_uuid(workspace_uuid: str) -> str: + workspace = str(uuid.UUID(workspace_uuid)) + return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}')) + + +def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str: + workspace = str(uuid.UUID(workspace_uuid)) + if category not in _SUPPORTED_CATEGORIES: + raise ValueError(f'Unsupported model category: {category}') + if not upstream_uuid: + raise ValueError('Upstream model UUID is required') + return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}')) + + +class CloudModelCatalogSyncService: + """Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace.""" + + def __init__( + self, + ap: Any, + provider: CloudModelCatalogProvider, + instance_uuid: str, + *, + sync_interval_seconds: float = 3600.0, + ) -> None: + if not isinstance(provider, CloudModelCatalogProvider): + raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider') + if sync_interval_seconds < 10: + raise ValueError('Cloud model catalog sync interval must be at least 10 seconds') + self.ap = ap + self.provider = provider + self.instance_uuid = instance_uuid + self.sync_interval_seconds = float(sync_interval_seconds) + # A tenant UoW commits one Workspace at a time. Keep a durable in-memory + # convergence marker so a failed runtime reload is retried even when the + # following database reconciliation is a no-op. + self._runtime_reload_pending = False + self._workspace_credits: dict[str, int | None] = {} + + def get_workspace_credits(self, workspace_uuid: str) -> int | None: + """Return the latest signed owner-credit projection for a Workspace.""" + return self._workspace_credits.get(str(uuid.UUID(workspace_uuid))) + + async def initialize(self) -> None: + await self.sync_once(reload_runtime=False) + + async def run(self) -> None: + while True: + await asyncio.sleep(self.sync_interval_seconds) + try: + await self.sync_once(reload_runtime=True) + except asyncio.CancelledError: + raise + except Exception as exc: + # Exception messages can contain rendered SQL bound values, + # including provider API keys. Log only the exception class. + self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})') + + async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]: + summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0} + snapshot: CloudModelCatalogSnapshot | None = None + sync_error: Exception | None = None + reload_error: Exception | None = None + try: + snapshot = await self.provider.fetch_model_catalog(self.instance_uuid) + if snapshot.instance_uuid != self.instance_uuid: + raise ValueError('Cloud model catalog targets another LangBot instance') + + bindings = await self.ap.workspace_service.list_active_execution_bindings() + billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces} + missing = sorted( + binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace + ) + if missing: + raise ValueError( + f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces' + ) + + for binding in bindings: + counts = await self._sync_workspace( + binding.workspace_uuid, + snapshot, + billing_by_workspace[binding.workspace_uuid], + ) + summary['workspaces'] += 1 + workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted')) + if workspace_changed: + # _sync_workspace returns only after its tenant UoW commits. + self._runtime_reload_pending = True + for key in ('created', 'updated', 'deleted'): + summary[key] += counts[key] + self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits + except Exception as exc: + sync_error = exc + finally: + model_mgr = getattr(self.ap, 'model_mgr', None) + if reload_runtime and self._runtime_reload_pending and model_mgr is not None: + try: + await model_mgr.load_models_from_db() + except Exception as exc: + reload_error = exc + else: + self._runtime_reload_pending = False + + if sync_error is not None: + if reload_error is not None: + raise sync_error from reload_error + raise sync_error + if reload_error is not None: + raise reload_error + + changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted')) + if changed and snapshot is not None: + self.ap.logger.info( + 'Cloud model catalog synchronized ' + f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, ' + f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})' + ) + return summary + + async def _sync_workspace( + self, + workspace_uuid: str, + snapshot: CloudModelCatalogSnapshot, + billing: CloudWorkspaceModelBilling, + ) -> dict[str, int]: + counts = {'created': 0, 'updated': 0, 'deleted': 0} + provider_uuid = system_provider_uuid(workspace_uuid) + desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else [] + + async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow: + provider = await uow.session.scalar( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == provider_uuid + ) + ) + provider_values = { + 'workspace_uuid': workspace_uuid, + 'name': LANGBOT_MODELS_PROVIDER_NAME, + 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER, + 'base_url': snapshot.base_url, + 'api_keys': desired_keys, + } + if provider is None: + provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values) + uow.session.add(provider) + await uow.session.flush() + counts['created'] += 1 + elif self._update_entity(provider, provider_values): + counts['updated'] += 1 + + existing_by_table: dict[type, dict[str, Any]] = {} + for table in _MODEL_TABLES: + rows = ( + await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid)) + ).all() + existing_by_table[table] = {row.uuid: row for row in rows} + + desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES} + for item in snapshot.models: + table, values = self._model_values(workspace_uuid, provider_uuid, item) + model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid) + desired_ids[table].add(model_uuid) + existing = existing_by_table[table].get(model_uuid) + if existing is None: + uow.session.add(table(uuid=model_uuid, **values)) + counts['created'] += 1 + elif self._update_entity(existing, values): + counts['updated'] += 1 + + for table, entities in existing_by_table.items(): + for model_uuid, entity in entities.items(): + if model_uuid not in desired_ids[table]: + await uow.session.delete(entity) + counts['deleted'] += 1 + + return counts + + @staticmethod + def _update_entity(entity: Any, values: dict[str, Any]) -> bool: + changed = False + for key, value in values.items(): + if getattr(entity, key) != value: + setattr(entity, key, value) + changed = True + return changed + + @staticmethod + def _model_values( + workspace_uuid: str, + provider_uuid: str, + item: CloudModelCatalogItem, + ) -> tuple[type, dict[str, Any]]: + ranking = 100 - item.featured_order if item.is_featured else 0 + common = { + 'workspace_uuid': workspace_uuid, + 'name': item.model_id, + 'provider_uuid': provider_uuid, + 'extra_args': {}, + 'prefered_ranking': ranking, + } + if item.category == 'chat': + return persistence_model.LLMModel, { + **common, + 'abilities': list(item.llm_abilities), + 'context_length': None, + } + if item.category == 'embedding': + return persistence_model.EmbeddingModel, common + if item.category == 'rerank': + return persistence_model.RerankModel, common + raise ValueError(f'Unsupported model category: {item.category}') diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index a7187159a..e49bb3d67 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -56,6 +56,7 @@ from ..cloud import launch as cloud_launch_module from ..cloud import support_admin as cloud_support_admin_module from ..cloud import directory_projection as cloud_directory_projection_module from ..cloud import entitlements as cloud_entitlements_module +from ..cloud import model_catalog as cloud_model_catalog_module from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType if TYPE_CHECKING: @@ -147,13 +148,12 @@ class Application: deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None - + directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None + cloud_model_catalog_service: cloud_model_catalog_module.CloudModelCatalogSyncService | None = None manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None - directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None - vector_db_mgr: vectordb_mgr.VectorDBManager = None http_ctrl: http_controller.HTTPController = None @@ -320,6 +320,12 @@ class Application: name='cloud-directory-projection', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + if self.cloud_model_catalog_service is not None: + self.task_mgr.create_task( + self.cloud_model_catalog_service.run(), + name='cloud-model-catalog-sync', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) if self.manifest_refresh_service is not None: self.task_mgr.create_task( self.manifest_refresh_service.run(), diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 0233d04ca..8f0f21842 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -48,6 +48,7 @@ from ...cloud import support_admin as cloud_support_admin_module from ...cloud.directory import directory_projection_limits_from_config from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.entitlements import EntitlementResolver +from ...cloud.model_catalog import CloudModelCatalogSyncService from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ...api.http.authz import WorkspaceRequiredError @@ -181,6 +182,16 @@ class BuildAppStage(stage.BootingStage): # of repeating tenant validation for every manager. await workspace_service_inst.prime_startup_execution_bindings() + if not isinstance(deployment, cloud_bootstrap.VerifiedCloudDeployment): + raise RuntimeError('Multi-Workspace runtime requires a verified Cloud deployment') + cloud_model_catalog_service = CloudModelCatalogSyncService( + ap, + deployment.model_catalog_provider, + constants.instance_id, + ) + await cloud_model_catalog_service.initialize() + ap.cloud_model_catalog_service = cloud_model_catalog_service + ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService( ap, workspace_service_inst, diff --git a/src/langbot/pkg/entity/persistence/workspace.py b/src/langbot/pkg/entity/persistence/workspace.py index ca3743d4b..822c8e9d4 100644 --- a/src/langbot/pkg/entity/persistence/workspace.py +++ b/src/langbot/pkg/entity/persistence/workspace.py @@ -163,6 +163,13 @@ class WorkspaceMembership(Base): __table_args__ = ( sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'), sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'), + sqlalchemy.Index( + 'uq_workspace_memberships_one_active_owner', + 'workspace_uuid', + unique=True, + sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"), + postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"), + ), sqlalchemy.CheckConstraint( "role IN ('owner', 'admin', 'developer', 'operator', 'viewer')", name='ck_workspace_memberships_role', diff --git a/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py new file mode 100644 index 000000000..33f44c19c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py @@ -0,0 +1,57 @@ +"""add durable replay protection for signed Space launch assertions + +Revision ID: 0016_space_launch_replay +Revises: 0015_cloud_core_collab +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0016_space_launch_replay' +down_revision = '0015_cloud_core_collab' +branch_labels = None +depends_on = None + +_TABLE = 'space_launch_assertion_consumptions' +_POLICY = 'langbot_directory_projection' +_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')" + + +def upgrade() -> None: + conn = op.get_bind() + if _TABLE not in set(sa.inspect(conn).get_table_names()): + op.create_table( + _TABLE, + sa.Column('instance_uuid', sa.String(255), nullable=False), + sa.Column('jti', sa.String(255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('instance_uuid', 'jti'), + ) + op.create_index( + 'ix_space_launch_assertion_consumptions_expiry', + _TABLE, + ['instance_uuid', 'expires_at'], + unique=False, + ) + if conn.dialect.name == 'postgresql': + table = conn.dialect.identifier_preparer.quote(_TABLE) + policy = conn.dialect.identifier_preparer.quote(_POLICY) + expression = f'instance_uuid::text = {_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: + if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()): + op.drop_table(_TABLE) diff --git a/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py new file mode 100644 index 000000000..68f4e1007 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py @@ -0,0 +1,21 @@ +"""merge the published Space launch replay and main migration branches + +Revision ID: 0018_merge_launch_replay +Revises: 0016_space_launch_replay, 0017_oss_workspace_identity +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +revision = '0018_merge_launch_replay' +down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py new file mode 100644 index 000000000..55e4e08f2 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py @@ -0,0 +1,80 @@ +"""enforce one active owner per Workspace + +Revision ID: 0019_single_workspace_owner +Revises: 0018_merge_launch_replay +Create Date: 2026-08-02 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0019_single_workspace_owner' +down_revision = '0018_merge_launch_replay' +branch_labels = None +depends_on = None + +_INDEX_NAME = 'uq_workspace_memberships_one_active_owner' + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + + # Ownership transfer used to promote a second member without demoting the + # original owner. Preserve the Workspace creator where possible and demote + # every historical extra owner before installing the database invariant. + op.execute( + sa.text( + """ + WITH ranked_owners AS ( + SELECT membership.uuid, + ROW_NUMBER() OVER ( + PARTITION BY membership.workspace_uuid + ORDER BY + CASE + WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0 + ELSE 1 + END, + COALESCE(membership.joined_at, membership.created_at), + membership.uuid + ) AS owner_rank + FROM workspace_memberships AS membership + JOIN workspaces AS workspace + ON workspace.uuid = membership.workspace_uuid + WHERE membership.role = 'owner' + AND membership.status = 'active' + ) + UPDATE workspace_memberships + SET role = 'admin' + WHERE uuid IN ( + SELECT uuid + FROM ranked_owners + WHERE owner_rank > 1 + ) + """ + ) + ) + # Fresh installations may already have this index because SQLAlchemy + # metadata is created before Alembic advances the revision marker. + op.execute( + sa.text( + 'CREATE UNIQUE INDEX IF NOT EXISTS ' + 'uq_workspace_memberships_one_active_owner ' + 'ON workspace_memberships (workspace_uuid) ' + "WHERE role = 'owner' AND status = 'active'" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')} + if _INDEX_NAME in index_names: + op.drop_index(_INDEX_NAME, table_name='workspace_memberships') diff --git a/src/langbot/pkg/persistence/alembic/versions/0020_merge_agent_cloud_heads.py b/src/langbot/pkg/persistence/alembic/versions/0020_merge_agent_cloud_heads.py new file mode 100644 index 000000000..fe4378ecb --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0020_merge_agent_cloud_heads.py @@ -0,0 +1,21 @@ +"""merge AgentRunner and Cloud Workspace migration heads + +Revision ID: 0020_merge_agent_cloud_heads +Revises: 0018_merge_workspace_heads, 0019_single_workspace_owner +Create Date: 2026-08-04 +""" + +from __future__ import annotations + +revision = '0020_merge_agent_cloud_heads' +down_revision = ('0018_merge_workspace_heads', '0019_single_workspace_owner') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 1c5ccdf5d..018ddb9c9 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -210,7 +210,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = { 'now': sqlalchemy.sql.functions.now, 'sum': sqlalchemy.sql.functions.sum, } -_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'}) +_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'}) _ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'}) _ALLOWED_SCOPED_STATEMENT_TYPES = ( sqlalchemy.sql.dml.UpdateBase, diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 73740787d..f7d38860a 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -709,28 +709,37 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) if len(listener_tasks) >= 100: await self.logger.warning('WebSocket inbound listener capacity reached; dropping message') return - token = _current_pipeline_uuid.set(pipeline_uuid) - try: - task_manager = getattr(self.ap, 'task_mgr', None) - if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list): - listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter)) - else: - listener_task = task_manager.create_task( - listeners[event.__class__](event, callback_adapter), - kind='websocket-message', - name=f'websocket-message-{connection.connection_id}', - scopes=[ - core_entities.LifecycleControlScope.APPLICATION, - core_entities.LifecycleControlScope.PLATFORM, - ], - instance_uuid=connection.instance_uuid, - workspace_uuid=connection.workspace_uuid, - placement_generation=connection.placement_generation, - ).task - listener_tasks.add(listener_task) - listener_task.add_done_callback(self._listener_task_done) - finally: - _current_pipeline_uuid.reset(token) + listener = typing.cast( + typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]], + listeners[event.__class__], + ) + + async def run_listener(): + token = _current_pipeline_uuid.set(pipeline_uuid) + try: + await listener(event, callback_adapter) + finally: + _current_pipeline_uuid.reset(token) + + listener_coro = run_listener() + task_manager = getattr(self.ap, 'task_mgr', None) + if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list): + listener_task = asyncio.create_task(listener_coro) + else: + listener_task = task_manager.create_task( + listener_coro, + kind='websocket-message', + name=f'websocket-message-{connection.connection_id}', + scopes=[ + core_entities.LifecycleControlScope.APPLICATION, + core_entities.LifecycleControlScope.PLATFORM, + ], + instance_uuid=connection.instance_uuid, + workspace_uuid=connection.workspace_uuid, + placement_generation=connection.placement_generation, + ).task + listener_tasks.add(listener_task) + listener_task.add_done_callback(self._listener_task_done) def get_websocket_messages( self, diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py index bcebe47c4..3ffbb6999 100644 --- a/src/langbot/pkg/workspace/collaboration.py +++ b/src/langbot/pkg/workspace/collaboration.py @@ -606,6 +606,8 @@ class WorkspaceCollaborationService: ) -> WorkspaceMembership: if role not in {item.value for item in MembershipRole}: raise MembershipPermissionError('Unknown Workspace role') + if role == MembershipRole.OWNER.value: + raise MembershipPermissionError('Workspace ownership cannot be transferred') async def operation(active_session: AsyncSession) -> WorkspaceMembership: await self._require_active_workspace(active_session, workspace_uuid) @@ -617,8 +619,8 @@ class WorkspaceCollaborationService: target_account_uuid, ) self._require_can_manage_target(persisted_actor, target, new_role=role) - if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value: - await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) + if target.role == MembershipRole.OWNER.value: + raise LastOwnerError('The Workspace owner cannot be removed or demoted') target.role = role await active_session.flush() return target @@ -644,7 +646,7 @@ class WorkspaceCollaborationService: ) self._require_can_manage_target(persisted_actor, target) if target.role == MembershipRole.OWNER.value: - await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) + raise LastOwnerError('The Workspace owner cannot be removed or demoted') target.status = MembershipStatus.REMOVED.value await active_session.flush() return target @@ -751,26 +753,6 @@ class WorkspaceCollaborationService: raise WorkspaceNotFoundError('Workspace not found') return persisted_actor - async def _require_another_owner( - self, - session: AsyncSession, - workspace_uuid: str, - excluded_account_uuid: str, - ) -> None: - owners = ( - await session.scalars( - sqlalchemy.select(WorkspaceMembership) - .where( - WorkspaceMembership.workspace_uuid == workspace_uuid, - WorkspaceMembership.status == MembershipStatus.ACTIVE.value, - WorkspaceMembership.role == MembershipRole.OWNER.value, - ) - .with_for_update() - ) - ).all() - if not any(owner.account_uuid != excluded_account_uuid for owner in owners): - raise LastOwnerError('The last Workspace owner cannot be removed or demoted') - def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None: if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value: raise WorkspaceNotFoundError('Workspace not found') diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 9c2927503..dfd5054f2 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -9,6 +9,8 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q from __future__ import annotations +from types import SimpleNamespace + import pytest from unittest.mock import MagicMock, AsyncMock, Mock @@ -304,12 +306,34 @@ class TestUserInitEndpoint: data = await response.get_json() assert data['data'] == { 'initialized': True, + 'authenticated_invitation_acceptance_enabled': False, 'password_login_enabled': True, 'space_login_enabled': False, } fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with() fake_api_app.user_service.get_first_user.assert_not_awaited() + @pytest.mark.asyncio + async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud( + self, quart_test_client, fake_api_app + ): + fake_api_app.deployment = SimpleNamespace(mode='cloud') + fake_api_app.user_service.is_initialized.return_value = True + fake_api_app.user_service.get_login_capabilities = AsyncMock( + return_value={'password_login_enabled': True, 'space_login_enabled': True} + ) + + response = await quart_test_client.get('/api/v1/user/account-info') + + assert response.status_code == 200 + data = await response.get_json() + assert data['data'] == { + 'initialized': True, + 'authenticated_invitation_acceptance_enabled': True, + 'password_login_enabled': False, + 'space_login_enabled': True, + } + @pytest.mark.asyncio async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch): fake_api_app.user_service.is_initialized.return_value = True diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py index 94647a7f1..e71cb4374 100644 --- a/tests/integration/api/test_support_admin_launch.py +++ b/tests/integration/api/test_support_admin_launch.py @@ -333,7 +333,6 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s 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, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index deb1e93e2..16c86156d 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -272,9 +272,10 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): '/api/v1/user/space-credits', headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, ) + payload = await response.get_json() assert response.status_code == 200 - assert (await response.get_json())['data'] == { + assert payload['data'] == { 'credits': 25000, 'owner_space_bound': True, 'is_workspace_owner': True, @@ -282,6 +283,31 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): application.space_service.get_credits.assert_awaited_once_with('owner@example.com') +@pytest.mark.asyncio +async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + application.user_service.get_workspace_owner = AsyncMock(return_value=None) + application.space_service.get_credits = AsyncMock() + application.cloud_model_catalog_service = SimpleNamespace( + get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None + ) + + response = await client.get( + '/api/v1/user/space-credits', + headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, + ) + payload = await response.get_json() + + assert response.status_code == 200 + assert payload['data'] == { + 'credits': 25000, + 'owner_space_bound': True, + 'is_workspace_owner': True, + } + application.space_service.get_credits.assert_not_awaited() + + @pytest.mark.asyncio async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api): application, client = space_oauth_api diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 7547f4800..d3a70a56d 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac workspace_uuid = current['workspace']['uuid'] assert current['membership']['role'] == 'owner' assert 'member.invite' in current['permissions'] + assert 'owner.transfer' not in current['permissions'] invite_response = await client.post( f'/api/v1/workspaces/{workspace_uuid}/invitations', @@ -263,6 +264,14 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac assert member_current['membership']['role'] == 'viewer' assert 'member.invite' not in member_current['permissions'] + transfer_response = await client.patch( + f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}', + headers=_auth(owner_token, workspace_uuid), + json={'role': 'owner'}, + ) + assert transfer_response.status_code == 403 + assert (await transfer_response.get_json())['code'] == 'permission_denied' + forbidden_invite = await client.post( f'/api/v1/workspaces/{workspace_uuid}/invitations', headers=_auth(member_token, workspace_uuid), diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index c1a455fb1..26ce9133d 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -95,6 +95,30 @@ class TestSQLiteMigrationBaseline: class TestSQLiteMigrationUpgrade: """Tests for upgrade to head workflow.""" + @pytest.mark.asyncio + async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine): + """A database released at the production-only 0016 head must remain upgradable.""" + async with sqlite_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay') + await run_alembic_upgrade(sqlite_engine, 'head') + + assert await get_alembic_current(sqlite_engine) == _get_script_head() + assert _get_script_head() == '0020_merge_agent_cloud_heads' + + @pytest.mark.asyncio + async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine): + """A database at the 4.11 development head must absorb later master migrations.""" + async with sqlite_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await run_alembic_stamp(sqlite_engine, '0018_merge_workspace_heads') + await run_alembic_upgrade(sqlite_engine, 'head') + + assert await get_alembic_current(sqlite_engine) == _get_script_head() + assert _get_script_head() == '0020_merge_agent_cloud_heads' + @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): """ diff --git a/tests/integration/persistence/test_single_owner_migration.py b/tests/integration/persistence/test_single_owner_migration.py new file mode 100644 index 000000000..249759d72 --- /dev/null +++ b/tests/integration/persistence/test_single_owner_migration.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership +from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade + + +@pytest.mark.asyncio +async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}') + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner')) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + workspace_uuid = '00000000-0000-4000-8000-000000000001' + creator_uuid = '00000000-0000-4000-8000-000000000010' + promoted_uuid = '00000000-0000-4000-8000-000000000020' + async with session_factory() as session: + session.add_all( + [ + User( + uuid=creator_uuid, + user='creator@example.test', + normalized_email='creator@example.test', + password='hash', + account_type='local', + ), + User( + uuid=promoted_uuid, + user='promoted@example.test', + normalized_email='promoted@example.test', + password='hash', + account_type='local', + ), + Workspace( + uuid=workspace_uuid, + instance_uuid='instance-test', + name='Workspace', + slug='workspace', + type='team', + status='active', + source='local', + created_by_account_uuid=creator_uuid, + ), + WorkspaceMembership( + uuid='00000000-0000-4000-8000-000000000100', + workspace_uuid=workspace_uuid, + account_uuid=creator_uuid, + role='owner', + status='active', + ), + WorkspaceMembership( + uuid='00000000-0000-4000-8000-000000000200', + workspace_uuid=workspace_uuid, + account_uuid=promoted_uuid, + role='owner', + status='active', + ), + ] + ) + await session.commit() + + await run_alembic_stamp(engine, '0018_merge_launch_replay') + await run_alembic_upgrade(engine, 'head') + + async with engine.connect() as connection: + roles = dict( + ( + await connection.execute( + sa.text( + 'SELECT account_uuid, role FROM workspace_memberships ' + 'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid' + ), + {'workspace_uuid': workspace_uuid}, + ) + ).all() + ) + assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'} + indexes = await connection.run_sync( + lambda sync_connection: { + index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships') + } + ) + assert 'uq_workspace_memberships_one_active_owner' in indexes + + with pytest.raises(sa.exc.IntegrityError): + async with engine.begin() as connection: + await connection.execute( + sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"), + {'account_uuid': promoted_uuid}, + ) + finally: + await engine.dispose() diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 9959be9b3..a686f59e8 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -180,7 +180,7 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega assert membership['status'] == 'active' -async def test_published_owner_repair_revision_upgrades_to_merged_head(legacy_engine): +async def test_development_owner_repair_revision_upgrades_to_merged_head(legacy_engine): await run_alembic_upgrade(legacy_engine, '0017_local_owner_repair') assert await get_alembic_current(legacy_engine) == '0017_local_owner_repair' diff --git a/tests/unit_tests/api/http/test_authz.py b/tests/unit_tests/api/http/test_authz.py index 705891a16..620700bc5 100644 --- a/tests/unit_tests/api/http/test_authz.py +++ b/tests/unit_tests/api/http/test_authz.py @@ -27,10 +27,9 @@ def test_owner_has_every_fixed_permission(): assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission) -def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing(): +def test_admin_cannot_delete_workspace_or_link_billing(): ctx = _context(authz.WorkspaceRole.ADMIN) - assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER) assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE) assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE) assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE) diff --git a/tests/unit_tests/api/service/test_cloud_managed_model_protection.py b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py new file mode 100644 index 000000000..62ed06049 --- /dev/null +++ b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py @@ -0,0 +1,112 @@ +"""Cloud Runtime write protection for the managed LangBot Models catalog.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.api.http.service import model as model_service_module +from langbot.pkg.api.http.service.model import ( + EmbeddingModelsService, + LLMModelsService, + RerankModelsService, + _assert_cloud_managed_provider_mutable, +) +from langbot.pkg.cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER + + +WORKSPACE = 'workspace-a' +PROVIDER = 'managed-provider' +MODEL = 'managed-model' + + +@pytest.mark.asyncio +async def test_managed_provider_guard_is_cloud_only(monkeypatch) -> None: + async def managed_provider(_ap, _context, provider_uuid): + assert provider_uuid == PROVIDER + return {'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER} + + monkeypatch.setattr(model_service_module, '_require_workspace_provider', managed_provider) + application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime'))) + + with pytest.raises(ValueError, match='managed by Cloud'): + await _assert_cloud_managed_provider_mutable( + application, + WORKSPACE, + PROVIDER, + ) + + application.persistence_mgr.mode.value = 'normal' + await _assert_cloud_managed_provider_mutable( + application, + WORKSPACE, + PROVIDER, + ) + + +@pytest.mark.parametrize( + ('service_type', 'create_method', 'model_data'), + [ + (LLMModelsService, 'create_llm_model', {'provider_uuid': PROVIDER, 'name': 'chat', 'abilities': []}), + (EmbeddingModelsService, 'create_embedding_model', {'provider_uuid': PROVIDER, 'name': 'embedding'}), + (RerankModelsService, 'create_rerank_model', {'provider_uuid': PROVIDER, 'name': 'rerank'}), + ], +) +@pytest.mark.asyncio +async def test_all_model_types_reject_creation_under_managed_provider( + monkeypatch, + service_type, + create_method: str, + model_data: dict, +) -> None: + guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified')) + monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard) + application = SimpleNamespace( + persistence_mgr=SimpleNamespace(), + provider_service=SimpleNamespace( + get_provider=AsyncMock(return_value={'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER}) + ), + model_mgr=None, + ) + service = service_type(application) + + with pytest.raises(ValueError, match='managed by Cloud'): + await getattr(service, create_method)(WORKSPACE, model_data) + + guard.assert_awaited_once() + + +@pytest.mark.parametrize( + ('service_type', 'get_method', 'write_method', 'payload'), + [ + (LLMModelsService, 'get_llm_model', 'update_llm_model', {'name': 'changed'}), + (LLMModelsService, 'get_llm_model', 'delete_llm_model', None), + (EmbeddingModelsService, 'get_embedding_model', 'update_embedding_model', {'name': 'changed'}), + (EmbeddingModelsService, 'get_embedding_model', 'delete_embedding_model', None), + (RerankModelsService, 'get_rerank_model', 'update_rerank_model', {'name': 'changed'}), + (RerankModelsService, 'get_rerank_model', 'delete_rerank_model', None), + ], +) +@pytest.mark.asyncio +async def test_all_model_types_reject_update_and_delete_for_managed_provider( + monkeypatch, + service_type, + get_method: str, + write_method: str, + payload: dict | None, +) -> None: + guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified')) + monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard) + application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime'))) + service = service_type(application) + monkeypatch.setattr( + service, + get_method, + AsyncMock(return_value={'uuid': MODEL, 'provider_uuid': PROVIDER, 'extra_args': {}}), + ) + + args = (WORKSPACE, MODEL) if payload is None else (WORKSPACE, MODEL, payload) + with pytest.raises(ValueError, match='managed by Cloud'): + await getattr(service, write_method)(*args) + + guard.assert_awaited_once() diff --git a/tests/unit_tests/api/service/test_provider_service.py b/tests/unit_tests/api/service/test_provider_service.py index 15b995895..fdf8b01bc 100644 --- a/tests/unit_tests/api/service/test_provider_service.py +++ b/tests/unit_tests/api/service/test_provider_service.py @@ -25,6 +25,7 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError pytestmark = pytest.mark.asyncio WORKSPACE_UUID = 'workspace-a' +SYSTEM_REQUESTER = 'space-chat-completions' def _create_mock_provider( @@ -1005,3 +1006,56 @@ class TestProviderSecretRoundtrip: ) ap.persistence_mgr.execute_async.assert_not_awaited() + + +class TestCloudManagedProviderProtection: + @staticmethod + def _service() -> ModelProviderService: + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace( + mode=SimpleNamespace(value='cloud_runtime'), + execute_async=AsyncMock(), + ), + model_mgr=SimpleNamespace(), + ) + return ModelProviderService(ap) + + async def test_cloud_rejects_user_created_system_requester(self): + service = self._service() + + with pytest.raises(ValueError, match='reserved'): + await service.create_provider( + WORKSPACE_UUID, + { + 'name': 'Fake LangBot Models', + 'requester': SYSTEM_REQUESTER, + 'base_url': 'https://example.invalid/v1', + 'api_keys': ['fake'], + }, + ) + + with pytest.raises(ValueError, match='reserved'): + await service.find_or_create_provider( + WORKSPACE_UUID, + SYSTEM_REQUESTER, + 'https://api.langbot.cloud/v1', + ['fake'], + ) + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_cloud_rejects_update_and_delete_of_managed_provider(self): + service = self._service() + service.get_provider = AsyncMock( + return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER} + ) + + with pytest.raises(ValueError, match='managed by Cloud'): + await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'}) + with pytest.raises(ValueError, match='managed by Cloud'): + await service.delete_provider(WORKSPACE_UUID, 'system-provider') + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_oss_does_not_reserve_space_requester(self): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat'))) + service = ModelProviderService(ap) + assert service._system_requester_is_reserved(SYSTEM_REQUESTER) is False diff --git a/tests/unit_tests/cloud/test_bootstrap.py b/tests/unit_tests/cloud/test_bootstrap.py index 77ccefa5d..6225ba7b7 100644 --- a/tests/unit_tests/cloud/test_bootstrap.py +++ b/tests/unit_tests/cloud/test_bootstrap.py @@ -66,6 +66,10 @@ class _Provider: def __init__(self): self.manifest_provider = _Manifest() + async def fetch_model_catalog(self, instance_uuid: str): + del instance_uuid + raise AssertionError('not used by bootstrap contract tests') + def bootstrap(self, *, instance_uuid: str, instance_config: dict): del instance_config return VerifiedCloudDeployment( @@ -79,6 +83,7 @@ class _Provider: entitlement_provider=_Entitlements(), directory_provider=_Directory(), manifest_provider=self.manifest_provider, + model_catalog_provider=self, verification_key_id='root-2026', ) @@ -228,10 +233,23 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message): ) +async def test_cloud_runtime_allows_explicitly_disabled_box(): + config = _cloud_config() + config['box']['enabled'] = False + + deployment = await resolve_deployment( + instance_uuid='instance-a', + instance_config=config, + entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]), + now=1_000, + ) + + assert isinstance(deployment, VerifiedCloudDeployment) + + @pytest.mark.parametrize( ('mutate', 'message'), [ - (lambda config: config['box'].update(enabled=False), 'box.enabled=true'), (lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'), (lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'), ( diff --git a/tests/unit_tests/cloud/test_model_catalog.py b/tests/unit_tests/cloud/test_model_catalog.py new file mode 100644 index 000000000..5d1de001f --- /dev/null +++ b/tests/unit_tests/cloud/test_model_catalog.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.cloud.model_catalog import ( + CloudModelCatalogSnapshot, + CloudModelCatalogSyncService, + system_model_uuid, + system_provider_uuid, +) +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider +from langbot.pkg.entity.persistence.workspace import Workspace +from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode + + +pytestmark = pytest.mark.asyncio +INSTANCE_UUID = 'instance-model-catalog' +WORKSPACE_A = '00000000-0000-4000-8000-000000000001' +WORKSPACE_B = '00000000-0000-4000-8000-000000000002' +OWNER_A = '10000000-0000-4000-8000-000000000001' +OWNER_B = '10000000-0000-4000-8000-000000000002' + + +class _CatalogProvider: + def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None: + self.snapshot = snapshot + + async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot: + assert instance_uuid == INSTANCE_UUID + return self.snapshot + + +def _snapshot( + *, + key_a: str | None = 'owner-a-key', + model_id: str = 'gpt-test', + include_embedding: bool = True, +) -> CloudModelCatalogSnapshot: + models = [ + { + 'uuid': 'upstream-chat', + 'model_id': model_id, + 'category': 'chat', + 'llm_abilities': ['chat', 'vision'], + 'is_featured': True, + 'featured_order': 7, + } + ] + if include_embedding: + models.append( + { + 'uuid': 'upstream-embedding', + 'model_id': 'embedding-test', + 'category': 'embedding', + } + ) + return CloudModelCatalogSnapshot.model_validate( + { + 'instance_uuid': INSTANCE_UUID, + 'generated_at': datetime.now(UTC), + 'base_url': 'https://api.langbot.cloud/v1/', + 'models': models, + 'workspaces': [ + { + 'workspace_uuid': WORKSPACE_A, + 'owner_account_uuid': OWNER_A, + 'api_key': key_a, + 'credits': 25000, + }, + { + 'workspace_uuid': WORKSPACE_B, + 'owner_account_uuid': OWNER_B, + 'api_key': 'owner-b-key', + 'credits': 5000, + }, + ], + } + ) + + +async def test_catalog_snapshot_treats_null_model_abilities_as_empty() -> None: + payload = _snapshot().model_dump(mode='json') + payload['models'][0]['llm_abilities'] = None + + snapshot = CloudModelCatalogSnapshot.model_validate(payload) + + assert snapshot.models[0].llm_abilities == () + + +async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None: + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}') + manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME) + manager.db = SimpleNamespace(get_engine=lambda: engine) + bindings = [ + SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1), + SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1), + ] + workspace_service = SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)) + reload_counter = _AsyncCounter() + runtime_reload = SimpleNamespace(load_models_from_db=reload_counter) + app = SimpleNamespace( + persistence_mgr=manager, + workspace_service=workspace_service, + model_mgr=runtime_reload, + logger=logging.getLogger(__name__), + ) + provider = _CatalogProvider(_snapshot()) + service = CloudModelCatalogSyncService(app, provider, INSTANCE_UUID) + + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute( + sqlalchemy.insert(Workspace), + [ + { + 'uuid': WORKSPACE_A, + 'instance_uuid': INSTANCE_UUID, + 'name': 'A', + 'slug': 'a', + 'source': 'cloud_projection', + }, + { + 'uuid': WORKSPACE_B, + 'instance_uuid': INSTANCE_UUID, + 'name': 'B', + 'slug': 'b', + 'source': 'cloud_projection', + }, + ], + ) + await connection.execute( + sqlalchemy.insert(ModelProvider).values( + uuid='custom-provider', + workspace_uuid=WORKSPACE_A, + name='Custom', + requester='openai-chat-completions', + base_url='https://custom.example/v1', + api_keys=['custom-key'], + ) + ) + await connection.execute( + sqlalchemy.insert(LLMModel).values( + uuid='custom-model', + workspace_uuid=WORKSPACE_A, + name='custom-model', + provider_uuid='custom-provider', + abilities=['chat'], + extra_args={}, + prefered_ranking=0, + ) + ) + + first = await service.sync_once() + assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0} + assert reload_counter.calls == 1 + assert service.get_workspace_credits(WORKSPACE_A) == 25000 + assert service.get_workspace_credits(WORKSPACE_B) == 5000 + + async with engine.connect() as connection: + providers = ( + await connection.execute( + sqlalchemy.select( + ModelProvider.uuid, + ModelProvider.workspace_uuid, + ModelProvider.api_keys, + ).where(ModelProvider.requester == 'space-chat-completions') + ) + ).all() + assert {item.workspace_uuid for item in providers} == {WORKSPACE_A, WORKSPACE_B} + assert {item.uuid for item in providers} == { + system_provider_uuid(WORKSPACE_A), + system_provider_uuid(WORKSPACE_B), + } + assert {item.workspace_uuid: item.api_keys for item in providers} == { + WORKSPACE_A: ['owner-a-key'], + WORKSPACE_B: ['owner-b-key'], + } + assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(LLMModel)) == 3 + assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 2 + + second = await service.sync_once() + assert second == {'workspaces': 2, 'created': 0, 'updated': 0, 'deleted': 0} + assert reload_counter.calls == 1 + + provider.snapshot = _snapshot( + key_a='new-owner-key', + model_id='gpt-renamed', + include_embedding=False, + ) + third = await service.sync_once() + assert third == {'workspaces': 2, 'created': 0, 'updated': 3, 'deleted': 2} + assert reload_counter.calls == 2 + + async with engine.connect() as connection: + provider_a_keys = await connection.scalar( + sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A)) + ) + assert provider_a_keys == ['new-owner-key'] + system_model_names = ( + ( + await connection.execute( + sqlalchemy.select(LLMModel.name).where( + LLMModel.provider_uuid.in_( + [system_provider_uuid(WORKSPACE_A), system_provider_uuid(WORKSPACE_B)] + ) + ) + ) + ) + .scalars() + .all() + ) + assert set(system_model_names) == {'gpt-renamed'} + assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 0 + assert ( + await connection.scalar( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(ModelProvider) + .where(ModelProvider.uuid == 'custom-provider') + ) + == 1 + ) + assert ( + await connection.scalar( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(LLMModel) + .where(LLMModel.uuid == 'custom-model') + ) + == 1 + ) + + provider.snapshot = _snapshot(key_a=None, model_id='gpt-renamed', include_embedding=False) + fourth = await service.sync_once() + assert fourth == {'workspaces': 2, 'created': 0, 'updated': 1, 'deleted': 0} + assert reload_counter.calls == 3 + async with engine.connect() as connection: + provider_a_keys = await connection.scalar( + sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A)) + ) + assert provider_a_keys == [] + finally: + await engine.dispose() + + +def test_workspace_scoped_ids_are_stable_and_secrets_are_redacted() -> None: + assert system_provider_uuid(WORKSPACE_A) == system_provider_uuid(WORKSPACE_A) + assert system_provider_uuid(WORKSPACE_A) != system_provider_uuid(WORKSPACE_B) + assert system_model_uuid(WORKSPACE_A, 'chat', 'upstream') != system_model_uuid(WORKSPACE_B, 'chat', 'upstream') + snapshot = _snapshot() + assert 'owner-a-key' not in repr(snapshot) + + +async def test_snapshot_must_cover_every_active_workspace() -> None: + snapshot = _snapshot().model_copy(update={'workspaces': _snapshot().workspaces[:1]}) + app = SimpleNamespace( + workspace_service=SimpleNamespace( + list_active_execution_bindings=lambda: _async_value( + [SimpleNamespace(workspace_uuid=WORKSPACE_A), SimpleNamespace(workspace_uuid=WORKSPACE_B)] + ) + ), + logger=logging.getLogger(__name__), + ) + service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID) + with pytest.raises(ValueError, match='missing billing projections for 1 active Workspaces'): + await service.sync_once() + + +async def _async_value(value): + return value + + +class _AsyncCounter: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self) -> None: + self.calls += 1 + + +async def test_partial_workspace_failure_reloads_already_committed_changes() -> None: + bindings = [ + SimpleNamespace(workspace_uuid=WORKSPACE_A), + SimpleNamespace(workspace_uuid=WORKSPACE_B), + ] + reload_counter = _AsyncCounter() + app = SimpleNamespace( + workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)), + model_mgr=SimpleNamespace(load_models_from_db=reload_counter), + logger=logging.getLogger(__name__), + ) + service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID) + calls = 0 + + async def sync_workspace(*_args): + nonlocal calls + calls += 1 + if calls == 1: + return {'created': 1, 'updated': 0, 'deleted': 0} + raise RuntimeError('second Workspace failed') + + service._sync_workspace = sync_workspace # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match='second Workspace failed'): + await service.sync_once() + assert service.get_workspace_credits(WORKSPACE_A) == 25000 + assert service.get_workspace_credits(WORKSPACE_B) is None + assert reload_counter.calls == 1 + + +async def test_failed_runtime_reload_is_retried_after_noop_sync() -> None: + bindings = [SimpleNamespace(workspace_uuid=WORKSPACE_A)] + + class _FlakyReload: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self) -> None: + self.calls += 1 + if self.calls == 1: + raise RuntimeError('reload failed') + + runtime_reload = _FlakyReload() + app = SimpleNamespace( + workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)), + model_mgr=SimpleNamespace(load_models_from_db=runtime_reload), + logger=logging.getLogger(__name__), + ) + service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID) + calls = 0 + + async def sync_workspace(*_args): + nonlocal calls + calls += 1 + if calls == 1: + return {'created': 1, 'updated': 0, 'deleted': 0} + return {'created': 0, 'updated': 0, 'deleted': 0} + + service._sync_workspace = sync_workspace # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match='reload failed'): + await service.sync_once() + summary = await service.sync_once() + assert summary == {'workspaces': 1, 'created': 0, 'updated': 0, 'deleted': 0} + assert runtime_reload.calls == 2 + + +async def test_background_sync_log_redacts_exception_message(caplog) -> None: + secret = 'owner-secret-api-key' + attempted = asyncio.Event() + + class _FailingProvider: + async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot: + del instance_uuid + attempted.set() + raise RuntimeError(f'database parameters include {secret}') + + app = SimpleNamespace(logger=logging.getLogger(__name__)) + service = CloudModelCatalogSyncService(app, _FailingProvider(), INSTANCE_UUID) + service.sync_interval_seconds = 0.001 + task = asyncio.create_task(service.run()) + try: + await asyncio.wait_for(attempted.wait(), timeout=1) + await asyncio.sleep(0.01) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert secret not in caplog.text + assert 'Cloud model catalog synchronization failed (RuntimeError)' in caplog.text diff --git a/tests/unit_tests/persistence/test_tenant_uow.py b/tests/unit_tests/persistence/test_tenant_uow.py index 7be62bf2e..bc8369f08 100644 --- a/tests/unit_tests/persistence/test_tenant_uow.py +++ b/tests/unit_tests/persistence/test_tenant_uow.py @@ -961,6 +961,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql( sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))), sa.select( sa.func.now(), + sa.func.date_trunc('hour', sa.column('timestamp')), sa.func.length(sa.literal('value')), sa.func.nullif(sa.literal('value'), sa.literal('')), ), diff --git a/tests/unit_tests/pipeline/test_controller_tenancy.py b/tests/unit_tests/pipeline/test_controller_tenancy.py index b99e54bb2..ac999e56b 100644 --- a/tests/unit_tests/pipeline/test_controller_tenancy.py +++ b/tests/unit_tests/pipeline/test_controller_tenancy.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind from langbot.pkg.pipeline.controller import Controller +from langbot.pkg.pipeline.pool import QueryPool from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError @@ -143,3 +144,31 @@ async def test_controller_revalidates_generation_before_running_pipeline( runtime_pipeline.run.assert_awaited_once_with(sample_query) query_pool.remove_query.assert_awaited_once_with(sample_query) session._semaphore.release.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_controller_schedules_query_without_removing_it_twice(mock_app, sample_query): + query_pool = QueryPool() + query_pool.queries.append(sample_query) + mock_app.query_pool = query_pool + mock_app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace(_semaphore=asyncio.Semaphore(1))) + + scheduler_errors: list[str] = [] + + def stop_on_scheduler_error(message): + scheduler_errors.append(str(message)) + raise asyncio.CancelledError + + def stop_after_scheduling(process_coro, **_kwargs): + process_coro.close() + raise asyncio.CancelledError + + mock_app.logger.error.side_effect = stop_on_scheduler_error + mock_app.task_mgr.create_task.side_effect = stop_after_scheduling + controller = Controller(mock_app) + + with pytest.raises(asyncio.CancelledError): + await controller.consumer() + + assert scheduler_errors == [] + assert query_pool.queries == [] diff --git a/tests/unit_tests/platform/test_websocket_session_isolation.py b/tests/unit_tests/platform/test_websocket_session_isolation.py index 31d1750e2..111359d53 100644 --- a/tests/unit_tests/platform/test_websocket_session_isolation.py +++ b/tests/unit_tests/platform/test_websocket_session_isolation.py @@ -1,6 +1,7 @@ """Regression tests for isolated embed-widget conversations.""" import asyncio +import contextvars from pathlib import Path from unittest.mock import AsyncMock, Mock @@ -207,6 +208,48 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch): assert received[0].sender.id == f'websocket_pipeline-1:{session_id}' +@pytest.mark.asyncio +async def test_pipeline_override_survives_detached_listener_task(monkeypatch): + manager = WebSocketConnectionManager() + connection = await manager.add_connection( + websocket=Mock(), + scope=SCOPE_A, + pipeline_uuid='pipeline-1', + session_type='person', + ) + monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager) + + class DetachedTaskManager: + def __init__(self): + self.tasks = [] + + def create_task(self, coro, **_kwargs): + task = asyncio.create_task(coro, context=contextvars.Context()) + self.tasks.append(task) + return Mock(task=task) + + task_manager = DetachedTaskManager() + adapter = WebSocketAdapter.model_construct( + ap=Mock(task_mgr=task_manager), + logger=_adapter_logger(), + ) + adapter.websocket_person_session = WebSocketSession(id='person') + adapter.websocket_group_session = WebSocketSession(id='group') + pipeline_overrides = [] + + async def listener(_event, callback_adapter): + pipeline_overrides.append(callback_adapter.get_pipeline_uuid_override()) + + adapter.listeners = {platform_events.FriendMessage: listener} + await adapter.handle_websocket_message( + connection, + {'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False}, + ) + await asyncio.gather(*task_manager.tasks) + + assert pipeline_overrides == ['pipeline-1'] + + @pytest.mark.asyncio async def test_embed_group_event_uses_stable_session_launcher(monkeypatch): manager = WebSocketConnectionManager() diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py index a9a79c42b..77974f4ff 100644 --- a/tests/unit_tests/workspace/test_workspace_collaboration.py +++ b/tests/unit_tests/workspace/test_workspace_collaboration.py @@ -203,20 +203,21 @@ async def test_last_owner_cannot_be_demoted(collaboration_context): second_membership, ) - promoted = await service.update_member_role( - workspace.uuid, - second.uuid, - 'owner', - owner_membership, - ) - assert promoted.role == 'owner' - demoted = await service.update_member_role( - workspace.uuid, - owner_membership.account_uuid, - 'admin', - owner_membership, - ) - assert demoted.role == 'admin' + with pytest.raises(MembershipPermissionError, match='cannot be transferred'): + await service.update_member_role( + workspace.uuid, + second.uuid, + 'owner', + owner_membership, + ) + + with pytest.raises(LastOwnerError): + await service.update_member_role( + workspace.uuid, + owner_membership.account_uuid, + 'admin', + owner_membership, + ) async def test_workspace_selector_requires_membership(collaboration_context): diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 1028de6ac..c2c142589 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -5,6 +5,7 @@ import { beginAuthenticatedSession, beginSupportAdminSession, bootstrapWorkspaceSession, + clearPendingInvitationToken, getPendingInvitationToken, } from '@/app/infra/http'; import { toast } from 'sonner'; @@ -66,6 +67,10 @@ function SpaceOAuthCallbackContent() { const [searchParams] = useSearchParams(); const { t } = useTranslation(); const isMountedRef = useRef(true); + const directLaunchFragmentRef = useRef<{ + workspaceUuid: string | null; + launchAssertion: string | null; + } | null>(null); const [status, setStatus] = useState< 'loading' | 'confirm' | 'success' | 'error' @@ -108,8 +113,31 @@ function SpaceOAuthCallbackContent() { } beginAuthenticatedSession(response.token, response.user); - if (getPendingInvitationToken()) { - navigate('/invitations/accept', { replace: true }); + const invitationToken = getPendingInvitationToken(); + if (invitationToken) { + let invitation; + try { + invitation = + await httpClient.acceptWorkspaceInvitation(invitationToken); + } catch (error) { + const code = (error as { code?: string }).code; + const path = code + ? `/invitations/accept?error=${encodeURIComponent(code)}` + : '/invitations/accept'; + navigate(path, { replace: true }); + return; + } + + beginAuthenticatedSession(invitation.token, response.user); + clearPendingInvitationToken(); + const workspaceResult = await bootstrapWorkspaceSession({ + preferredWorkspaceUuid: invitation.workspace_uuid, + }); + if (workspaceResult.status === 'unavailable') { + navigate('/workspace-unavailable', { replace: true }); + return; + } + navigate('/home', { replace: true }); return; } const workspaceResult = await bootstrapWorkspaceSession({ @@ -220,8 +248,28 @@ function SpaceOAuthCallbackContent() { const errorDescription = searchParams.get('error_description'); const mode = searchParams.get('mode'); const state = searchParams.get('state'); - const workspaceUuid = searchParams.get('workspace_uuid'); - const launchAssertion = searchParams.get('launch_assertion'); + if (directLaunchFragmentRef.current === null) { + const fragmentParams = new URLSearchParams( + window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash, + ); + directLaunchFragmentRef.current = { + workspaceUuid: fragmentParams.get('workspace_uuid'), + launchAssertion: fragmentParams.get('launch_assertion'), + }; + if (window.location.hash) { + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}`, + ); + } + } + const workspaceUuid = + directLaunchFragmentRef.current.workspaceUuid ?? + searchParams.get('workspace_uuid'); + const launchAssertion = directLaunchFragmentRef.current.launchAssertion; if (error) { setStatus('error'); diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index 1dfcb74e0..c3dd8b9a0 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -49,6 +49,8 @@ import type { } from '@/app/home/mcp/components/mcp-form/MCPForm'; import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel'; import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel'; +import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus'; +import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip'; type PopoverView = 'menu' | 'mcp' | 'github'; @@ -154,6 +156,12 @@ function AddExtensionContent() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); + const { extensions: extensionQuota, extensionsReached } = + useWorkspaceQuotaStatus(); + const extensionQuotaTooltip = t('limitation.createDisabledTooltip', { + resource: t('sidebar.extensions'), + max: extensionQuota.max, + }); // Localized label for an extension type, used in the install dialog. const extensionTypeLabel = (type: string) => @@ -344,23 +352,28 @@ function AddExtensionContent() { t, ]); - const handleInstallPlugin = useCallback(async (plugin: PluginV4) => { - setInstallInfo({ - plugin_author: plugin.author, - plugin_name: plugin.name, - plugin_version: plugin.latest_version, - plugin_label: extractI18nObject(plugin.label) || plugin.name, - plugin_description: extractI18nObject(plugin.description) || '', - plugin_icon: plugin.icon || '', - }); - setInstallExtensionType(plugin.type || 'plugin'); - setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); - setInstallError(null); - setInstallIconFailed(false); - setModalOpen(true); - }, []); + const handleInstallPlugin = useCallback( + async (plugin: PluginV4) => { + if (extensionsReached) return; + setInstallInfo({ + plugin_author: plugin.author, + plugin_name: plugin.name, + plugin_version: plugin.latest_version, + plugin_label: extractI18nObject(plugin.label) || plugin.name, + plugin_description: extractI18nObject(plugin.description) || '', + plugin_icon: plugin.icon || '', + }); + setInstallExtensionType(plugin.type || 'plugin'); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); + setInstallError(null); + setInstallIconFailed(false); + setModalOpen(true); + }, + [extensionsReached], + ); function handleModalConfirm() { + if (extensionsReached) return; setPluginInstallStatus(PluginInstallStatus.INSTALLING); const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`; httpClient @@ -402,6 +415,7 @@ function AddExtensionContent() { const uploadFile = useCallback( async (file: File) => { + if (extensionsReached) return; if (!validateFileType(file)) { toast.error(t('addExtension.unsupportedFileType')); return; @@ -421,14 +435,15 @@ function AddExtensionContent() { setSkillUploadPreviewOpen(true); } }, - [t, setSelectedTaskId], + [extensionsReached, t, setSelectedTaskId], ); const handleFileSelect = useCallback(() => { + if (extensionsReached) return; if (fileInputRef.current) { fileInputRef.current.click(); } - }, []); + }, [extensionsReached]); const handleFileChange = useCallback( (event: React.ChangeEvent) => { @@ -455,12 +470,13 @@ function AddExtensionContent() { (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); + if (extensionsReached) return; const files = Array.from(event.dataTransfer.files); if (files.length > 0) { uploadFile(files[0]); } }, - [uploadFile], + [extensionsReached, uploadFile], ); function handleMCPCreated(_serverName: string) { @@ -490,7 +506,8 @@ function AddExtensionContent() { return false; } } catch { - // If we can't check, let backend handle it + toast.error(t('limitation.quotaCheckFailed')); + return false; } return true; } @@ -630,9 +647,11 @@ function AddExtensionContent() { async function handleGithubConfirm() { if (!selectedAsset || !selectedRelease) return; - if (!(await checkExtensionsLimit())) return; - setGithubInstallStatus(GithubInstallStatus.INSTALLING); + if (!(await checkExtensionsLimit())) { + setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM); + return; + } const pluginDisplayName = `${githubOwner}/${githubRepo}`; httpClient .installPluginFromGithub( @@ -664,9 +683,11 @@ function AddExtensionContent() { async function handleGithubSkillConfirm() { if (!githubSkillInfo) return; - if (!(await checkExtensionsLimit())) return; - setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING); + if (!(await checkExtensionsLimit())) { + setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW); + return; + } try { await httpClient.installSkillFromGithub( githubURL.trim(), @@ -726,17 +747,24 @@ function AddExtensionContent() { setPopoverOpen(open); }} > - - - + + + + + + {extensionsReached && ( +
+ {extensionQuotaTooltip} +
+ )} {/* File upload area */}
)} - @@ -1184,6 +1231,7 @@ function AddExtensionContent() { @@ -1240,6 +1288,8 @@ function AddExtensionContent() { @@ -1325,9 +1375,17 @@ function AddExtensionContent() { - + + + )} {pluginInstallStatus === PluginInstallStatus.ERROR && ( @@ -1359,6 +1417,8 @@ function AddExtensionContent() { {pluginUploadPreviewFile && ( { setPluginUploadPreviewOpen(false); setPluginUploadPreviewFile(null); @@ -1392,6 +1452,8 @@ function AddExtensionContent() { {skillUploadPreviewFile && ( { setSkillUploadPreviewOpen(false); setSkillUploadPreviewFile(null); diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 009c62f28..9a02a0062 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -130,7 +130,7 @@ export default function BotForm({ const [dynamicFormConfigList, setDynamicFormConfigList] = useState< IDynamicFormItemSchema[] >([]); - const [, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [webhookUrl, setWebhookUrl] = useState(''); const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); @@ -409,271 +409,278 @@ export default function BotForm({
- {/* Card 1: Basic Information */} - - - {t('bots.basicInfo')} - {t('bots.basicInfoDescription')} - - - ( - - - {t('bots.botName')} - * - - - - - - - )} - /> - ( - - {t('bots.botDescription')} - - - - - - )} - /> - - - - {/* Card 2: Adapter Configuration */} - - - {t('bots.adapterConfig')} - - {t('bots.adapterConfigDescription')} - - - - ( - - - {t('bots.platformAdapter')} - * - - -
- - {currentAdapter && - (() => { - const docUrl = getAdapterDocUrl( - adapterHelpLinks[currentAdapter], - i18n.language, - ); - return docUrl ? ( - - {t('bots.viewAdapterDocs')} - - - ) : null; - })()} -
-
- {currentAdapter && adapterDescriptionList[currentAdapter] && ( - - {adapterDescriptionList[currentAdapter]} - - )} - -
- )} - /> - - {showDynamicForm && dynamicFormConfigList.length > 0 && ( - { - form.setValue('adapter_config', values, { - shouldDirty: !isInitializing.current, - }); - }} - systemContext={{ - webhook_url: webhookUrl, - extra_webhook_url: extraWebhookUrl, - bot_uuid: initBotId || '', - adapter_config: form.getValues('adapter_config') || {}, - outbound_ips: systemInfo.outbound_ips, - }} - /> - )} -
-
- - {/* Card 3: Event Routing */} - {currentAdapter && ( +
+ {/* Card 1: Basic Information */} - {t('bots.eventRouting')} + {t('bots.basicInfo')} - {t('bots.eventRoutingDescription')} + {t('bots.basicInfoDescription')} - - + ( + + + {t('bots.botName')} + * + + + + + + + )} + /> + ( + + {t('bots.botDescription')} + + + + + + )} /> - )} + + {/* Card 2: Adapter Configuration */} + + + {t('bots.adapterConfig')} + + {t('bots.adapterConfigDescription')} + + + + ( + + + {t('bots.platformAdapter')} + * + + +
+ + {currentAdapter && + (() => { + const docUrl = getAdapterDocUrl( + adapterHelpLinks[currentAdapter], + i18n.language, + ); + return docUrl ? ( + + {t('bots.viewAdapterDocs')} + + + ) : null; + })()} +
+
+ {currentAdapter && + adapterDescriptionList[currentAdapter] && ( + + {adapterDescriptionList[currentAdapter]} + + )} + +
+ )} + /> + + {showDynamicForm && dynamicFormConfigList.length > 0 && ( + { + form.setValue('adapter_config', values, { + shouldDirty: !isInitializing.current, + }); + }} + systemContext={{ + webhook_url: webhookUrl, + extra_webhook_url: extraWebhookUrl, + bot_uuid: initBotId || '', + adapter_config: form.getValues('adapter_config') || {}, + outbound_ips: systemInfo.outbound_ips, + }} + /> + )} +
+
+ + {/* Card 3: Event Routing */} + {currentAdapter && ( + + + {t('bots.eventRouting')} + + {t('bots.eventRoutingDescription')} + + + + + + + )} +
); diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 96112c1d7..d8bc3734e 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -8,6 +8,7 @@ import { clearUserInfo, getCloudServiceClientSync, useCurrentWorkspace, + useWorkspaceBootstrap, } from '@/app/infra/http'; import { useTranslation } from 'react-i18next'; import { @@ -32,7 +33,6 @@ import { Zap, FilePlus2, Sparkles, - HardDrive, Server, Puzzle, RefreshCcw, @@ -113,6 +113,11 @@ import { import { cn } from '@/lib/utils'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { FeedbackPopoverContent } from './FeedbackPopover'; +import { + type WorkspaceQuotaItem, + useWorkspaceQuotaStatus, +} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus'; +import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip'; // Compare two version strings, returns true if v1 > v2 function compareVersions(v1: string, v2: string): boolean { @@ -283,6 +288,14 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +const UNLIMITED_QUOTA: WorkspaceQuotaItem = { + count: 0, + max: -1, + reached: false, + loading: false, + disabled: false, +}; + async function waitForMCPRefreshTask(taskId: number) { const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS; @@ -390,6 +403,7 @@ function NavItems({ const pathname = location.pathname; const [searchParams] = useSearchParams(); const sidebarData = useSidebarData(); + const quotaStatus = useWorkspaceQuotaStatus(); const { state: sidebarState, isMobile } = useSidebar(); const { t } = useTranslation(); const currentWorkspace = useCurrentWorkspace(); @@ -533,7 +547,7 @@ function NavItems({ if (config.id === 'add-extension' && !canManageResources) { return null; } - // Non-entity entries (e.g. monitoring, market, mcp) render as plain links + // Non-entity entries (e.g. monitoring and the extension market) render as plain links. return ( { if (item.extensionType === 'mcp') { @@ -970,128 +996,144 @@ function NavItems({ >
{config.name} - {canCreate && - (isPlugin ? ( - - - - - - {systemInfo.enable_marketplace && ( + {canCreate && ( + + {isPlugin ? ( + + + + + + {systemInfo.enable_marketplace && ( + { + e.stopPropagation(); + navigate('/home/add-extension'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('plugins.goToMarketplace')} + + )} { e.stopPropagation(); - navigate('/home/add-extension'); + navigate('/home/add-extension?manual=1'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > - - {t('plugins.goToMarketplace')} + + {t('plugins.uploadLocal')} - )} - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('plugins.uploadLocal')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('plugins.installFromGithub')} - - - - ) : isSkill ? ( - - - - - - { - e.stopPropagation(); - navigate('/home/skills?action=create'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('skills.createManually')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('skills.uploadZip')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('skills.importFromGithub')} - - - - ) : ( - - ))} + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('plugins.installFromGithub')} + + + + ) : isSkill ? ( + + + + + + { + e.stopPropagation(); + navigate('/home/skills?action=create'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.createManually')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.uploadZip')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.importFromGithub')} + + + + ) : ( + + )} + + )}
{renderEntityList(true)} @@ -1206,103 +1248,119 @@ function NavItems({
)}
- {canCreate && - (isPlugin ? ( - - - - - - {systemInfo.enable_marketplace && ( + {canCreate && ( + + {isPlugin ? ( + + + + + + {systemInfo.enable_marketplace && ( + { + e.stopPropagation(); + navigate('/home/add-extension'); + }} + > + + {t('plugins.goToMarketplace')} + + )} { e.stopPropagation(); - navigate('/home/add-extension'); + navigate('/home/add-extension?manual=1'); }} > - - {t('plugins.goToMarketplace')} + + {t('plugins.uploadLocal')} - )} - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('plugins.uploadLocal')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('plugins.installFromGithub')} - - - - ) : isSkill ? ( - - - - - - { - e.stopPropagation(); - navigate('/home/skills?action=create'); - }} - > - - {t('skills.createManually')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('skills.uploadZip')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('skills.importFromGithub')} - - - - ) : ( - - ))} + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + }} + > + + {t('plugins.installFromGithub')} + + + + ) : isSkill ? ( + + + + + + { + e.stopPropagation(); + navigate('/home/skills?action=create'); + }} + > + + {t('skills.createManually')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + }} + > + + {t('skills.uploadZip')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + }} + > + + {t('skills.importFromGithub')} + + + + ) : ( + + )} + + )} + {isWorkspaceOwner && ( + + )}
)} {isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && ( diff --git a/web/src/app/home/components/settings-dialog/SettingsDialog.tsx b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx index 22cdd90e7..63453a3f5 100644 --- a/web/src/app/home/components/settings-dialog/SettingsDialog.tsx +++ b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx @@ -133,12 +133,14 @@ export default function SettingsDialog({ const permissions = currentWorkspace?.permissions ?? []; const canManageApiKeys = permissions.includes('api_key.manage'); const canViewAudit = permissions.includes('audit.view'); + const canViewStorageAnalysis = + currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit; const navItems = allNavItems.filter((item) => { if (item.id === 'apiIntegration') { return canManageApiKeys; } if (item.id === 'storageAnalysis') { - return canViewAudit; + return canViewStorageAnalysis; } return true; }); @@ -146,11 +148,17 @@ export default function SettingsDialog({ useEffect(() => { const forbiddenSection = (section === 'apiIntegration' && !canManageApiKeys) || - (section === 'storageAnalysis' && !canViewAudit); + (section === 'storageAnalysis' && !canViewStorageAnalysis); if (open && forbiddenSection) { onSectionChange('workspace'); } - }, [canManageApiKeys, canViewAudit, open, section, onSectionChange]); + }, [ + canManageApiKeys, + canViewStorageAnalysis, + open, + section, + onSectionChange, + ]); const activeItem = navItems.find((item) => item.id === section); const activeLabel = activeItem?.title ?? t('settingsDialog.title'); @@ -256,7 +264,7 @@ export default function SettingsDialog({ active={open && section === 'apiIntegration'} /> )} - {section === 'storageAnalysis' && ( + {section === 'storageAnalysis' && canViewStorageAnalysis && ( diff --git a/web/src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx b/web/src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx new file mode 100644 index 000000000..e12a95b7b --- /dev/null +++ b/web/src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus'; + +export function WorkspaceQuotaTooltip({ + quota, + resource, + children, + side = 'top', +}: { + quota: WorkspaceQuotaItem; + resource: string; + children: ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; +}) { + const { t } = useTranslation(); + if (!quota.disabled) return children; + const message = quota.loading + ? t('limitation.quotaLoadingTooltip') + : t('limitation.createDisabledTooltip', { + resource, + max: quota.max, + }); + + return ( + + + + {children} + + + + {message} + + + ); +} diff --git a/web/src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts b/web/src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts new file mode 100644 index 000000000..4788b7477 --- /dev/null +++ b/web/src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts @@ -0,0 +1,82 @@ +import { systemInfo } from '@/app/infra/http/HttpClient'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; + +export interface WorkspaceQuotaItem { + count: number; + max: number; + reached: boolean; + loading: boolean; + disabled: boolean; +} + +export interface WorkspaceQuotaStatus { + bots: WorkspaceQuotaItem; + pipelines: WorkspaceQuotaItem; + knowledgeBases: WorkspaceQuotaItem; + extensions: WorkspaceQuotaItem; + botsReached: boolean; + pipelinesReached: boolean; + knowledgeBasesReached: boolean; + extensionsReached: boolean; +} + +function quotaItem( + count: number, + max: number | undefined, + loaded: boolean, +): WorkspaceQuotaItem { + const normalizedMax = typeof max === 'number' ? max : -1; + const reached = loaded && normalizedMax >= 0 && count >= normalizedMax; + return { + count, + max: normalizedMax, + reached, + loading: !loaded, + disabled: !loaded || reached, + }; +} + +export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus { + const { + bots, + pipelines, + knowledgeBases, + pluginCount, + mcpServers, + skills, + quotaDataLoaded, + } = useSidebarData(); + const limitation = systemInfo.limitation; + + const botQuota = quotaItem( + bots.length, + limitation?.max_bots, + quotaDataLoaded, + ); + const pipelineQuota = quotaItem( + pipelines.length, + limitation?.max_pipelines, + quotaDataLoaded, + ); + const knowledgeBaseQuota = quotaItem( + knowledgeBases.length, + limitation?.max_knowledge_bases, + quotaDataLoaded, + ); + const extensionQuota = quotaItem( + pluginCount + mcpServers.length + skills.length, + limitation?.max_extensions, + quotaDataLoaded, + ); + + return { + bots: botQuota, + pipelines: pipelineQuota, + knowledgeBases: knowledgeBaseQuota, + extensions: extensionQuota, + botsReached: botQuota.disabled, + pipelinesReached: pipelineQuota.disabled, + knowledgeBasesReached: knowledgeBaseQuota.disabled, + extensionsReached: extensionQuota.disabled, + }; +} diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx index ebe184040..2469c5194 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx @@ -79,7 +79,6 @@ export default function WorkspaceSettingsPanel({ const canInvite = permissions.has('member.invite'); const canUpdateMembers = permissions.has('member.update_role'); const canRemoveMembers = permissions.has('member.remove'); - const canTransferOwner = permissions.has('owner.transfer'); const cloudPortalURL = workspaceInfo ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan` : ''; @@ -342,11 +341,6 @@ export default function WorkspaceSettingsPanel({ {t(`workspace.roles.${role}`)} ))} - {canTransferOwner && ( - - {t('workspace.transferOwnership')} - - )} )} diff --git a/web/src/app/home/plugin-pages/page.tsx b/web/src/app/home/plugin-pages/page.tsx index c86443302..a51df38ec 100644 --- a/web/src/app/home/plugin-pages/page.tsx +++ b/web/src/app/home/plugin-pages/page.tsx @@ -1,9 +1,10 @@ import { useSearchParams } from 'react-router-dom'; import { httpClient } from '@/app/infra/http/HttpClient'; -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@/components/providers/theme-provider'; +import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource'; /** * Plugin page that renders a plugin-provided HTML page in an iframe. @@ -109,15 +110,15 @@ function PluginPageIframe({ pageId: string; }) { const iframeRef = useRef(null); - const [loading, setLoading] = useState(true); + const [loadedAssetUrl, setLoadedAssetUrl] = useState(''); const { resolvedTheme } = useTheme(); - const { i18n } = useTranslation(); - - const assetUrl = useMemo(() => { - const url = httpClient.getPluginAssetURL(author, pluginName, pagePath); - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}_lb_page_v=${Date.now()}`; - }, [author, pluginName, pagePath]); + const { t, i18n } = useTranslation(); + const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset( + author, + pluginName, + pagePath, + ); + const loading = !assetUrl || loadedAssetUrl !== assetUrl; // Send context (theme + language) to iframe // Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin @@ -203,23 +204,29 @@ function PluginPageIframe({ return (
- {loading && ( + {assetError ? ( +
+ {t('plugins.loadFailed')} +
+ ) : loading || !assetUrl ? (
Loading...
+ ) : null} + {!assetError && assetUrl && ( +