diff --git a/src/langbot/pkg/api/http/context.py b/src/langbot/pkg/api/http/context.py index 8236c8dc7..7880a1e15 100644 --- a/src/langbot/pkg/api/http/context.py +++ b/src/langbot/pkg/api/http/context.py @@ -69,6 +69,7 @@ class ExecutionContext: pipeline_uuid: str | None = None query_uuid: str | None = None trigger_principal: PrincipalContext | None = None + entitlement_revision: int = 0 @classmethod def from_request( @@ -89,4 +90,5 @@ class ExecutionContext: pipeline_uuid=pipeline_uuid, query_uuid=query_uuid, trigger_principal=ctx.principal, + entitlement_revision=ctx.entitlement_revision, ) diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 2d8c95bbc..5626f7a45 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -12,6 +12,7 @@ from quart.typing import RouteCallable from ....utils import constants from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError from ....workspace.errors import WorkspaceNotFoundError +from ....cloud.entitlements import EntitlementUnavailableError from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext @@ -244,6 +245,10 @@ class RouterGroup(abc.ABC): requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id') access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid) + entitlement_revision = await self._resolve_entitlement_revision( + access.execution.instance_uuid, + access.workspace.uuid, + ) request_context = RequestContext( instance_uuid=access.execution.instance_uuid, placement_generation=access.execution.placement_generation, @@ -260,6 +265,7 @@ class RouterGroup(abc.ABC): permissions=permissions_for_role(access.membership.role), membership_revision=access.membership.projection_revision, ), + entitlement_revision=entitlement_revision, ) quart.g.request_context = request_context quart.g.workspace_membership = access.membership @@ -272,6 +278,10 @@ class RouterGroup(abc.ABC): if inspect.isawaitable(authenticated): identity = await authenticated if identity is not None: + entitlement_revision = await self._resolve_entitlement_revision( + identity.instance_uuid, + identity.workspace_uuid, + ) request_context = RequestContext( instance_uuid=identity.instance_uuid, placement_generation=identity.placement_generation, @@ -287,6 +297,7 @@ class RouterGroup(abc.ABC): role=None, permissions=identity.permissions, ), + entitlement_revision=entitlement_revision, ) quart.g.request_context = request_context return request_context @@ -316,6 +327,18 @@ class RouterGroup(abc.ABC): quart.g.request_context = request_context return request_context + async def _resolve_entitlement_revision(self, instance_uuid: str, workspace_uuid: str) -> int: + deployment = getattr(self.ap, 'deployment', None) + if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False): + return 0 + resolver = getattr(self.ap, 'entitlement_resolver', None) + if resolver is None: + raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable') + if instance_uuid != resolver.instance_uuid: + raise EntitlementUnavailableError('Workspace entitlement targets another LangBot instance') + snapshot = await resolver.resolve(workspace_uuid) + return snapshot.entitlement_revision + @staticmethod def _inject_handler_context( handler: RouteCallable, @@ -339,6 +362,8 @@ class RouterGroup(abc.ABC): return self.http_status(404, 'resource_not_found', 'Resource not found') if isinstance(error, MembershipPermissionError): return self.http_status(403, error.code, str(error)) + if isinstance(error, EntitlementUnavailableError): + return self.http_status(403, 'entitlement_unavailable', str(error)) request_id = self.request_id() logger = getattr(self.ap, 'logger', self.quart_app.logger) logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}') diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py index 3bd8a3813..9633fa58c 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -5,6 +5,7 @@ from urllib.parse import unquote from ....authz import Permission from ....context import RequestContext +from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError from ... import group @@ -31,6 +32,8 @@ class MCPRouterGroup(group.RouterGroup): data = await quart.request.json try: server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data) + except MCPStdioDisabledError as exc: + return self.http_status(403, exc.code, str(exc)) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data={'uuid': server_uuid}) @@ -63,6 +66,8 @@ class MCPRouterGroup(group.RouterGroup): data = await quart.request.json try: await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data) + except MCPStdioDisabledError as exc: + return self.http_status(403, exc.code, str(exc)) except ValueError as exc: return self.http_status(400, -1, str(exc)) else: @@ -79,11 +84,14 @@ class MCPRouterGroup(group.RouterGroup): """测试MCP服务器连接""" server_name = unquote(server_name) server_data = await quart.request.json - task_id = await self.ap.mcp_service.test_mcp_server( - request_context, - server_name=server_name, - server_data=server_data, - ) + try: + task_id = await self.ap.mcp_service.test_mcp_server( + request_context, + server_name=server_name, + server_data=server_data, + ) + except MCPStdioDisabledError as exc: + return self.http_status(403, exc.code, str(exc)) return self.success(data={'task_id': task_id}) @self.route( diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 002c037af..be1c4dcb9 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -8,6 +8,7 @@ from .....utils import constants from .....entity.persistence.metadata import WorkspaceMetadata from ...authz import Permission from ...context import RequestContext +from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled @group.group_class('system', '/api/v1/system') @@ -70,6 +71,9 @@ class SystemRouterGroup(group.RouterGroup): 'disable_models_service': self.ap.instance_config.data.get('space', {}).get( 'disable_models_service', False ), + # Exposed independently of Box status so the WebUI cannot + # infer stdio permission from sandbox availability. + 'mcp_stdio_enabled': stdio_mcp_enabled(self.ap), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), 'outbound_ips': outbound_ips, 'wizard_status': wizard_status, diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index 9443de74c..27d32da17 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -11,6 +11,7 @@ from ....core import app, taskmgr from ....entity.persistence import mcp as persistence_mcp from ....entity.persistence import plugin as persistence_plugin from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession +from ....provider.tools.loaders.mcp_policy import require_stdio_mcp_enabled from ....workspace.errors import WorkspaceNotFoundError from ..context import ExecutionContext from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders @@ -207,6 +208,10 @@ class MCPService: execution_context = await self._execution_context(context) workspace_uuid = execution_context.workspace_uuid + # This gate is independent of Box availability. Cloud v2 disables + # stdio MCP even though Box Runtime itself remains available. + require_stdio_mcp_enabled(self.ap, server_data) + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_extensions = limitation.get('max_extensions', -1) if max_extensions >= 0: @@ -315,6 +320,13 @@ class MCPService: if duplicate is not None and duplicate['uuid'] != server_uuid: raise ValueError(f'MCP server already exists: {payload["name"]}') + effective_server = {**old_server, **payload} + # Existing disabled rows remain readable/deletable. Switching away + # from stdio or explicitly disabling one is also allowed, but an + # update may never leave a disabled stdio server enabled. + if bool(effective_server.get('enable', True)): + require_stdio_mcp_enabled(self.ap, effective_server) + result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.update(persistence_mcp.MCPServer) @@ -405,7 +417,8 @@ class MCPService: ctx = taskmgr.TaskContext.new() if server_name != '_': - await self._require_server(execution_context, server_name) + _, persisted_server = await self._require_server(execution_context, server_name) + require_stdio_mcp_enabled(self.ap, persisted_server) runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name) if runtime_mcp_session is None: raise WorkspaceNotFoundError('MCP server not found') @@ -427,6 +440,7 @@ class MCPService: payload = dict(server_data) payload.pop('workspace_uuid', None) payload['workspace_uuid'] = execution_context.workspace_uuid + require_stdio_mcp_enabled(self.ap, payload) runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server( execution_context, payload, diff --git a/src/langbot/pkg/cloud/__init__.py b/src/langbot/pkg/cloud/__init__.py new file mode 100644 index 000000000..41e4e4877 --- /dev/null +++ b/src/langbot/pkg/cloud/__init__.py @@ -0,0 +1,27 @@ +"""Contracts used by the optional closed Cloud control-plane bootstrap.""" + +from .bootstrap import ( + CloudBootstrapError, + OpenSourceDeployment, + VerifiedCloudDeployment, + resolve_deployment, +) +from .entitlements import ( + EntitlementProvider, + EntitlementResolver, + EntitlementSnapshot, + EntitlementUnavailableError, + OpenSourceEntitlementProvider, +) + +__all__ = [ + 'CloudBootstrapError', + 'EntitlementProvider', + 'EntitlementResolver', + 'EntitlementSnapshot', + 'EntitlementUnavailableError', + 'OpenSourceDeployment', + 'OpenSourceEntitlementProvider', + 'VerifiedCloudDeployment', + 'resolve_deployment', +] diff --git a/src/langbot/pkg/cloud/bootstrap.py b/src/langbot/pkg/cloud/bootstrap.py new file mode 100644 index 000000000..3ab5d093f --- /dev/null +++ b/src/langbot/pkg/cloud/bootstrap.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import dataclasses +import importlib.metadata +import inspect +import time +from collections.abc import Awaitable, Callable +from typing import Any, Protocol + +from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy +from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider + + +CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap' +REQUIRED_TENANT_ISOLATION_VERSION = 2 + + +class CloudBootstrapError(RuntimeError): + """Fail-closed Cloud bootstrap validation error.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class OpenSourceDeployment: + """Default deployment selected when no closed bootstrap is installed.""" + + mode: str = 'oss' + workspace_policy: SingleWorkspacePolicy = dataclasses.field(default_factory=SingleWorkspacePolicy) + entitlement_provider: OpenSourceEntitlementProvider = dataclasses.field( + default_factory=OpenSourceEntitlementProvider + ) + persistence_mode: str = 'oss_compat' + required_vector_backend: str | None = None + + @property + def multi_workspace_enabled(self) -> bool: + return False + + def validate_instance_config(self, config: dict[str, Any]) -> None: + del config + + +@dataclasses.dataclass(frozen=True, slots=True) +class VerifiedCloudDeployment: + """Receipt returned only after the closed package verifies a Manifest. + + Core deliberately does not accept a config flag as a substitute for this + object. The closed entry point owns root-key/JWS verification and the + entitlement adapter; open Core validates the receipt's runtime invariants. + """ + + instance_uuid: str + manifest_jti: str + manifest_generation: int + expires_at: int + release: str + capabilities: frozenset[str] + tenant_isolation_version: int + entitlement_provider: EntitlementProvider + verification_key_id: str + mode: str = dataclasses.field(default='cloud', init=False) + workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False) + persistence_mode: str = dataclasses.field(default='cloud_runtime', init=False) + required_vector_backend: str = dataclasses.field(default='pgvector', init=False) + + @property + def multi_workspace_enabled(self) -> bool: + return True + + def validate(self, expected_instance_uuid: str, *, now: int | None = None) -> None: + current_time = int(time.time()) if now is None else now + if not self.instance_uuid or self.instance_uuid != expected_instance_uuid: + raise CloudBootstrapError('Verified Cloud Manifest targets another LangBot instance') + if not self.manifest_jti or not self.verification_key_id: + raise CloudBootstrapError('Verified Cloud Manifest receipt is incomplete') + if isinstance(self.manifest_generation, bool) or self.manifest_generation <= 0: + raise CloudBootstrapError('Verified Cloud Manifest generation must be positive') + if self.expires_at <= current_time: + raise CloudBootstrapError('Verified Cloud Manifest is expired') + if self.tenant_isolation_version < REQUIRED_TENANT_ISOLATION_VERSION: + raise CloudBootstrapError('Verified Cloud Manifest requires an unsupported tenant isolation version') + if 'multi_workspace_v2' not in self.capabilities: + raise CloudBootstrapError('Verified Cloud Manifest does not grant multi_workspace_v2') + if not isinstance(self.entitlement_provider, EntitlementProvider): + raise CloudBootstrapError('Verified Cloud bootstrap did not provide an entitlement adapter') + + def validate_instance_config(self, config: dict[str, Any]) -> None: + if config.get('database', {}).get('use') != 'postgresql': + raise CloudBootstrapError('Cloud runtime requires database.use=postgresql') + if config.get('vdb', {}).get('use') != self.required_vector_backend: + raise CloudBootstrapError('Cloud runtime requires vdb.use=pgvector') + if config.get('mcp', {}).get('stdio', {}).get('enabled', True) is not False: + raise CloudBootstrapError('Cloud runtime requires mcp.stdio.enabled=false') + + +class CloudBootstrapProvider(Protocol): + def bootstrap( + self, + *, + instance_uuid: str, + instance_config: dict[str, Any], + ) -> VerifiedCloudDeployment | Awaitable[VerifiedCloudDeployment]: ... + + +async def _invoke_provider( + loaded: Any, + *, + instance_uuid: str, + instance_config: dict[str, Any], +) -> VerifiedCloudDeployment: + provider = loaded() if inspect.isclass(loaded) else loaded + bootstrap = getattr(provider, 'bootstrap', None) + if not callable(bootstrap): + raise CloudBootstrapError('Cloud bootstrap entry point must expose bootstrap()') + result = bootstrap(instance_uuid=instance_uuid, instance_config=instance_config) + if inspect.isawaitable(result): + result = await result + if not isinstance(result, VerifiedCloudDeployment): + raise CloudBootstrapError('Cloud bootstrap must return VerifiedCloudDeployment') + return result + + +async def resolve_deployment( + *, + instance_uuid: str, + instance_config: dict[str, Any], + entry_points: Callable[[], Any] | None = None, + now: int | None = None, +) -> OpenSourceDeployment | VerifiedCloudDeployment: + """Discover the optional closed bootstrap and validate its receipt. + + Absence selects OSS singleton mode. Presence is fail-closed: duplicate, + broken, invalid, or expired providers never fall back to an OSS Workspace. + """ + + discover = entry_points or importlib.metadata.entry_points + discovered = discover() + if hasattr(discovered, 'select'): + candidates = list(discovered.select(group=CLOUD_BOOTSTRAP_ENTRY_POINT)) + else: # Python/importlib compatibility for dict-like EntryPoints + candidates = list(discovered.get(CLOUD_BOOTSTRAP_ENTRY_POINT, ())) + if not candidates: + deployment = OpenSourceDeployment() + deployment.validate_instance_config(instance_config) + return deployment + if len(candidates) != 1: + raise CloudBootstrapError('Exactly one Cloud bootstrap provider may be installed') + + try: + loaded = candidates[0].load() + deployment = await _invoke_provider( + loaded, + instance_uuid=instance_uuid, + instance_config=instance_config, + ) + deployment.validate(instance_uuid, now=now) + deployment.validate_instance_config(instance_config) + return deployment + except CloudBootstrapError: + raise + except Exception as exc: + raise CloudBootstrapError('Closed Cloud bootstrap failed') from exc diff --git a/src/langbot/pkg/cloud/entitlements.py b/src/langbot/pkg/cloud/entitlements.py new file mode 100644 index 000000000..5cc359dc2 --- /dev/null +++ b/src/langbot/pkg/cloud/entitlements.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import asyncio +import json +import time +from typing import Protocol, runtime_checkable + +import pydantic + + +class EntitlementUnavailableError(RuntimeError): + """Raised when a trusted, currently-active entitlement is unavailable.""" + + +class EntitlementSnapshot(pydantic.BaseModel): + """Plan-agnostic capability projection consumed by open-source Core. + + Product and billing names deliberately do not appear here. The closed + Control Plane maps subscriptions to these generic features and limits. + """ + + model_config = pydantic.ConfigDict(frozen=True, extra='forbid') + + instance_uuid: str = pydantic.Field(min_length=1, max_length=256) + workspace_uuid: str = pydantic.Field(min_length=1, max_length=256) + entitlement_revision: int = pydantic.Field(ge=1) + status: str = pydantic.Field(pattern=r'^(active|suspended|cancelled)$') + not_before: int = pydantic.Field(ge=0) + expires_at: int = pydantic.Field(gt=0) + features: dict[str, bool] = pydantic.Field(default_factory=dict) + limits: dict[str, int] = pydantic.Field(default_factory=dict) + + @pydantic.field_validator('features') + @classmethod + def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]: + if any(not str(name).strip() for name in value): + raise ValueError('Entitlement feature names must be non-empty') + return dict(value) + + @pydantic.field_validator('limits') + @classmethod + def _validate_limits(cls, value: dict[str, int]) -> dict[str, int]: + normalized: dict[str, int] = {} + for name, limit in value.items(): + if not str(name).strip(): + raise ValueError('Entitlement limit names must be non-empty') + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0: + raise ValueError(f'Entitlement limit {name!r} must be a non-negative integer') + normalized[str(name)] = limit + return normalized + + def require_active( + self, + *, + instance_uuid: str, + workspace_uuid: str, + now: int | None = None, + ) -> EntitlementSnapshot: + current_time = int(time.time()) if now is None else now + if self.instance_uuid != instance_uuid or self.workspace_uuid != workspace_uuid: + raise EntitlementUnavailableError('Entitlement scope does not match the Workspace execution context') + if self.status != 'active': + raise EntitlementUnavailableError('Workspace entitlement is not active') + if current_time < self.not_before or current_time >= self.expires_at: + raise EntitlementUnavailableError('Workspace entitlement is not currently valid') + return self + + def require_feature(self, feature: str) -> None: + if self.features.get(feature) is not True: + raise EntitlementUnavailableError(f'Workspace entitlement does not grant {feature}') + + def limit(self, name: str) -> int: + value = self.limits.get(name) + if value is None: + raise EntitlementUnavailableError(f'Workspace entitlement does not define limit {name}') + return value + + +@runtime_checkable +class EntitlementProvider(Protocol): + """Closed Control Plane adapter injected by a verified Cloud bootstrap.""" + + async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot: + """Return the newest verified snapshot for one Workspace.""" + + +class OpenSourceEntitlementProvider: + """Marker provider for OSS; Cloud admission grants never use this class.""" + + async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot: + del workspace_uuid + raise EntitlementUnavailableError('Signed Workspace entitlements are only available in Cloud mode') + + +class EntitlementResolver: + """Validate scope/freshness and reject revision rollback or equivocation.""" + + def __init__(self, instance_uuid: str, provider: EntitlementProvider) -> None: + self.instance_uuid = instance_uuid + self.provider = provider + self._lock = asyncio.Lock() + self._snapshots: dict[str, tuple[int, str, EntitlementSnapshot]] = {} + + @staticmethod + def _fingerprint(snapshot: EntitlementSnapshot) -> str: + return json.dumps(snapshot.model_dump(mode='json'), sort_keys=True, separators=(',', ':')) + + async def resolve( + self, + workspace_uuid: str, + *, + minimum_revision: int = 0, + now: int | None = None, + ) -> EntitlementSnapshot: + candidate = await self.provider.get_workspace_entitlement(workspace_uuid) + if not isinstance(candidate, EntitlementSnapshot): + raise EntitlementUnavailableError('Entitlement provider returned an invalid snapshot') + # Deep-copy untrusted provider-owned containers before caching them. + candidate = EntitlementSnapshot.model_validate(candidate.model_dump()) + candidate.require_active( + instance_uuid=self.instance_uuid, + workspace_uuid=workspace_uuid, + now=now, + ) + if candidate.entitlement_revision < minimum_revision: + raise EntitlementUnavailableError('Workspace entitlement revision rolled back') + + fingerprint = self._fingerprint(candidate) + async with self._lock: + previous = self._snapshots.get(workspace_uuid) + if previous is not None: + previous_revision, previous_fingerprint, _ = previous + if candidate.entitlement_revision < previous_revision: + raise EntitlementUnavailableError('Workspace entitlement revision rolled back') + if candidate.entitlement_revision == previous_revision and fingerprint != previous_fingerprint: + raise EntitlementUnavailableError('Workspace entitlement revision has conflicting contents') + self._snapshots[workspace_uuid] = ( + candidate.entitlement_revision, + fingerprint, + candidate, + ) + return candidate.model_copy(deep=True) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 1952b775b..7f63d70bd 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -48,6 +48,8 @@ from ..survey import manager as survey_module from ..skill import manager as skill_mgr from ..workspace import service as workspace_service_module from ..workspace import collaboration as workspace_collaboration_module +from ..cloud import bootstrap as cloud_bootstrap_module +from ..cloud import entitlements as cloud_entitlements_module from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus @@ -128,6 +130,10 @@ class Application: workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None + deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None + + entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None + vector_db_mgr: vectordb_mgr.VectorDBManager = None http_ctrl: http_controller.HTTPController = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 3fe94223b..0c1cb0113 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from .. import stage, app -from ...utils import version, proxy +from ...utils import version, proxy, constants from ...pipeline import pool, controller, pipelinemgr from ...pipeline import aggregator as message_aggregator from ...box import service as box_service @@ -41,7 +41,8 @@ from ...telemetry import telemetry as telemetry_module from ...survey import manager as survey_module from ...workspace import service as workspace_service_module from ...workspace import collaboration as workspace_collaboration_module -from ...workspace import policy as workspace_policy_module +from ...cloud import bootstrap as cloud_bootstrap +from ...cloud.entitlements import EntitlementResolver from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ...api.http.authz import WorkspaceRequiredError @@ -52,6 +53,20 @@ class BuildAppStage(stage.BootingStage): async def run(self, ap: app.Application): """Build LangBot application""" + # Multi-Workspace mode is selected only by an installed closed + # bootstrap that returns a verified Manifest receipt. Mutable values + # such as system.edition are intentionally absent from this boundary. + deployment = await cloud_bootstrap.resolve_deployment( + instance_uuid=constants.instance_id, + instance_config=ap.instance_config.data, + ) + ap.deployment = deployment + ap.entitlement_resolver = ( + EntitlementResolver(constants.instance_id, deployment.entitlement_provider) + if deployment.multi_workspace_enabled + else None + ) + ap.task_mgr = taskmgr.AsyncTaskManager(ap) discover = discover_engine.ComponentDiscoveryEngine(ap) @@ -109,16 +124,14 @@ class BuildAppStage(stage.BootingStage): await storage_mgr_inst.initialize() ap.storage_mgr = storage_mgr_inst - persistence_mgr_inst = persistencemgr.PersistenceManager(ap) + persistence_mgr_inst = persistencemgr.PersistenceManager( + ap, + mode=persistencemgr.PersistenceMode(deployment.persistence_mode), + ) ap.persistence_mgr = persistence_mgr_inst await persistence_mgr_inst.initialize() - # The open-source Core is intentionally single-Workspace. A mutable - # config value such as ``system.edition`` is product metadata, not a - # trust credential, and must never activate SaaS routing. The closed - # Cloud bootstrap will install its verified policy only after checking - # a signed InstanceManifest; until that bootstrap exists, fail closed. - workspace_policy = workspace_policy_module.open_core_workspace_policy() + workspace_policy = deployment.workspace_policy workspace_service_inst = workspace_service_module.WorkspaceService( ap, policy=workspace_policy, diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index 6fc890f10..f4d7b770d 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import copy from typing import Any from langbot.pkg.utils import constants import yaml @@ -12,6 +13,44 @@ from .. import stage, app from ..bootutils import config +_RUNTIME_POLICY_DEFAULTS = { + 'plugin': { + 'worker': { + 'max_cpus': 1.0, + 'max_memory_mb': 512, + 'max_pids': 128, + 'max_open_files': 256, + 'max_file_size_mb': 512, + } + }, + 'mcp': {'stdio': {'enabled': True}}, +} + + +def _complete_runtime_policy_defaults(cfg: dict) -> dict: + """Backfill typed security-policy leaves before applying env overrides. + + The historic config loader intentionally does not deep-complete the whole + template. These fields are different: their native env overrides must + retain boolean/numeric types on upgraded instances, so their defaults must + exist before ``PLUGIN__...`` and ``MCP__...`` are parsed. + """ + + def merge(target: dict, defaults: dict, path: tuple[str, ...] = ()) -> None: + for key, default in defaults.items(): + if key not in target: + target[key] = copy.deepcopy(default) + continue + if isinstance(default, dict): + if not isinstance(target[key], dict): + dotted_path = '.'.join((*path, key)) + raise ValueError(f'{dotted_path} must be a mapping') + merge(target[key], default, (*path, key)) + + merge(cfg, _RUNTIME_POLICY_DEFAULTS) + return cfg + + def _apply_env_overrides_to_config(cfg: dict) -> dict: """Apply environment variable overrides to data/config.yaml @@ -150,6 +189,10 @@ class LoadConfigStage(stage.BootingStage): ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False) + # Deep-complete only typed execution-policy fields. This keeps native + # env coercion reliable for existing data/config.yaml files. + ap.instance_config.data = _complete_runtime_policy_defaults(ap.instance_config.data) + # Apply environment variable overrides to data/config.yaml ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data) diff --git a/src/langbot/pkg/persistence/alembic/versions/0011_postgres_tenant_rls.py b/src/langbot/pkg/persistence/alembic/versions/0011_postgres_tenant_rls.py new file mode 100644 index 000000000..3d3bdf7ae --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0011_postgres_tenant_rls.py @@ -0,0 +1,130 @@ +"""enforce PostgreSQL tenant isolation with row-level security + +Revision ID: 0011_postgres_tenant_rls +Revises: 0010_scope_resources +Create Date: 2026-07-19 + +The table list is deliberately duplicated from the runtime contract. Alembic +revisions must remain self-contained after application code evolves. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0011_postgres_tenant_rls' +down_revision = '0010_scope_resources' +branch_labels = None +depends_on = None + + +_POLICY_NAME = 'langbot_workspace_isolation' +_TENANT_SETTING = 'langbot.workspace_uuid' +_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid' +_TENANT_TABLE_COLUMNS: dict[str, str] = { + 'workspaces': 'uuid', + 'workspace_memberships': 'workspace_uuid', + 'workspace_invitations': 'workspace_uuid', + 'workspace_execution_states': 'workspace_uuid', + 'workspace_metadata': 'workspace_uuid', + 'api_keys': 'workspace_uuid', + 'bots': 'workspace_uuid', + 'bot_admins': 'workspace_uuid', + 'binary_storages': 'workspace_uuid', + 'mcp_servers': 'workspace_uuid', + 'model_providers': 'workspace_uuid', + 'llm_models': 'workspace_uuid', + 'embedding_models': 'workspace_uuid', + 'rerank_models': 'workspace_uuid', + 'legacy_pipelines': 'workspace_uuid', + 'pipeline_run_records': 'workspace_uuid', + 'plugin_settings': 'workspace_uuid', + 'knowledge_bases': 'workspace_uuid', + 'knowledge_base_files': 'workspace_uuid', + 'knowledge_base_chunks': 'workspace_uuid', + 'webhooks': 'workspace_uuid', + 'monitoring_messages': 'workspace_uuid', + 'monitoring_llm_calls': 'workspace_uuid', + 'monitoring_tool_calls': 'workspace_uuid', + 'monitoring_sessions': 'workspace_uuid', + 'monitoring_errors': 'workspace_uuid', + 'monitoring_embedding_calls': 'workspace_uuid', + 'monitoring_feedback': 'workspace_uuid', +} + + +def _quote_identifier(conn: sa.Connection, identifier: str) -> str: + return conn.dialect.identifier_preparer.quote(identifier) + + +def _record_oss_workspace_scope(conn: sa.Connection) -> None: + """Keep PostgreSQL OSS usable after FORCE RLS is enabled. + + The runtime reads this instance-level metadata and installs the singleton + Workspace as a transaction-local default. Cloud databases have no local + Workspace and therefore do not receive this compatibility value. + """ + local_workspaces = ( + conn.execute(sa.text("SELECT uuid FROM workspaces WHERE source = 'local' ORDER BY uuid")).scalars().all() + ) + if len(local_workspaces) != 1: + return + + existing = conn.execute( + sa.text('SELECT value FROM metadata WHERE key = :key'), + {'key': _OSS_WORKSPACE_METADATA_KEY}, + ).scalar_one_or_none() + if existing is None: + conn.execute( + sa.text('INSERT INTO metadata (key, value) VALUES (:key, :value)'), + {'key': _OSS_WORKSPACE_METADATA_KEY, 'value': local_workspaces[0]}, + ) + elif existing != local_workspaces[0]: + raise RuntimeError('Stored OSS Workspace scope does not match the local Workspace') + + +def upgrade() -> None: + conn = op.get_bind() + if conn.dialect.name != 'postgresql': + return + + existing_tables = set(sa.inspect(conn).get_table_names()) + missing_tables = set(_TENANT_TABLE_COLUMNS) - existing_tables + if missing_tables: + raise RuntimeError(f'Cannot enable tenant RLS before all tenant-owned tables exist: {sorted(missing_tables)!r}') + + _record_oss_workspace_scope(conn) + + policy_name = _quote_identifier(conn, _POLICY_NAME) + for table_name, tenant_column in _TENANT_TABLE_COLUMNS.items(): + table = _quote_identifier(conn, table_name) + column = _quote_identifier(conn, tenant_column) + tenant_value = f"CAST(NULLIF(current_setting('{_TENANT_SETTING}', true), '') AS VARCHAR(36))" + + 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_name} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy_name} ON {table} ' + f'USING ({column} = {tenant_value}) ' + f'WITH CHECK ({column} = {tenant_value})' + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + if conn.dialect.name != 'postgresql': + return + + existing_tables = set(sa.inspect(conn).get_table_names()) + policy_name = _quote_identifier(conn, _POLICY_NAME) + for table_name in _TENANT_TABLE_COLUMNS: + if table_name not in existing_tables: + continue + table = _quote_identifier(conn, table_name) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy_name} ON {table}')) + op.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY')) diff --git a/src/langbot/pkg/persistence/alembic_runner.py b/src/langbot/pkg/persistence/alembic_runner.py index b5daa174f..b7108de76 100644 --- a/src/langbot/pkg/persistence/alembic_runner.py +++ b/src/langbot/pkg/persistence/alembic_runner.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING from alembic.config import Config from alembic import command from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncEngine @@ -59,6 +60,16 @@ def _do_get_current(connection: Connection) -> str | None: return ctx.get_current_revision() +def get_alembic_head() -> str: + """Resolve the single release head without opening a database connection.""" + cfg = Config() + cfg.set_main_option('script_location', _ALEMBIC_DIR) + head = ScriptDirectory.from_config(cfg).get_current_head() + if head is None: + raise RuntimeError('Alembic has no migration head') + return head + + def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None: """Synchronous autogenerate — runs inside run_sync.""" cfg = _build_config(connection) diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 8454e83fd..47443642c 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import enum import sqlite3 import typing @@ -15,6 +16,7 @@ from ..entity import persistence from ..core import app from ..utils import constants, importutil from . import databases, migrations +from .tenant_uow import TENANT_POLICY_NAME, TENANT_SETTING, TENANT_TABLE_COLUMNS, TenantUnitOfWork importutil.import_modules_in_pkg(databases) importutil.import_modules_in_pkg(migrations) @@ -64,6 +66,15 @@ _PRE_WORKSPACE_ALEMBIC_REVISIONS = { } _WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy' _RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources' +_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid' + + +class PersistenceMode(enum.StrEnum): + """Trusted persistence startup mode selected by the process entrypoint.""" + + OSS_COMPAT = 'oss_compat' + CLOUD_RUNTIME = 'cloud_runtime' + RELEASE_MIGRATION = 'release_migration' class PersistenceManager: @@ -76,9 +87,12 @@ class PersistenceManager: meta: sqlalchemy.MetaData - def __init__(self, ap: app.Application): + def __init__(self, ap: app.Application, *, mode: PersistenceMode = PersistenceMode.OSS_COMPAT): + if not isinstance(mode, PersistenceMode): + raise TypeError('PersistenceManager mode must be a trusted PersistenceMode value') self.ap = ap self.meta = base.Base.metadata + self.mode = mode async def initialize(self): database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite') @@ -89,7 +103,27 @@ class PersistenceManager: await self.db.initialize() break + engine = self.get_db_engine() + if self.mode in {PersistenceMode.CLOUD_RUNTIME, PersistenceMode.RELEASE_MIGRATION}: + if engine.dialect.name != 'postgresql': + raise RuntimeError(f'{self.mode.value} persistence mode requires PostgreSQL') + + if self.mode == PersistenceMode.CLOUD_RUNTIME: + await self._validate_cloud_runtime() + return + self._enable_sqlite_foreign_keys() + await self._initialize_managed_schema() + + if self.mode == PersistenceMode.OSS_COMPAT: + await self.write_space_model_providers() + + async def _initialize_managed_schema(self) -> None: + """Create or migrate schema only in OSS and release processes.""" + from . import alembic_runner + + engine = self.get_db_engine() + release_bootstrap = self.mode == PersistenceMode.RELEASE_MIGRATION and await self._is_empty_schema() await self.create_tables() @@ -125,16 +159,36 @@ class PersistenceManager: self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.') - # Run Alembic migrations (new migration system) - await self._run_alembic_migrations() + if engine.dialect.name == 'postgresql': + current_revision = await alembic_runner.get_alembic_current(engine) + head_revision = alembic_runner.get_alembic_head() - # A legacy database may not contain tenant tables introduced by a - # newer release. They were deliberately deferred before 0009 because - # their Workspace/account FK targets did not exist yet; create them - # now that the tenancy contract is in place. - await self.create_tables() + if release_bootstrap: + # Base.metadata represents the complete 0010 schema. An empty + # Cloud database has no legacy data to transform and must not + # run 0009's OSS singleton Workspace bootstrap. + if current_revision is not None: + raise RuntimeError('Empty PostgreSQL release bootstrap unexpectedly has an Alembic revision') + await alembic_runner.run_alembic_stamp(engine, _RESOURCE_SCOPE_ALEMBIC_REVISION) + elif current_revision != head_revision: + # A legacy database may not contain tenant tables introduced + # by a newer release. Upgrade the account/resource contract, + # then create deferred tables before the RLS migration runs. + await self._run_alembic_migrations(_RESOURCE_SCOPE_ALEMBIC_REVISION) + await self.create_tables() - await self.write_space_model_providers() + await self._run_alembic_migrations() + await self._validate_postgres_tenant_schema(validate_runtime_role=False) + + if self.mode == PersistenceMode.OSS_COMPAT: + await self._install_oss_postgres_tenant_scope() + else: + await self._run_alembic_migrations() + + # SQLite keeps the historical post-migration create_all pass. New + # tenant tables are deferred until the Workspace/account schema is + # compatible with their foreign keys. + await self.create_tables() async def create_tables(self): async with self.get_db_engine().connect() as conn: @@ -173,6 +227,37 @@ class PersistenceManager: await self._ensure_instance_uuid_metadata() + async def _is_empty_schema(self) -> bool: + async with self.get_db_engine().connect() as conn: + table_names = await conn.run_sync(lambda sync_conn: set(sqlalchemy.inspect(sync_conn).get_table_names())) + return not table_names + + async def _install_oss_postgres_tenant_scope(self) -> None: + """Default every OSS PostgreSQL transaction to its singleton Workspace.""" + if getattr(self, '_oss_tenant_scope_listener_installed', False): + return + + async with self.get_db_engine().connect() as conn: + workspace_uuid = await conn.scalar( + sqlalchemy.select(metadata.Metadata.value).where( + metadata.Metadata.key == _OSS_WORKSPACE_METADATA_KEY, + ) + ) + if not isinstance(workspace_uuid, str) or not workspace_uuid.strip(): + raise RuntimeError( + 'PostgreSQL OSS mode requires exactly one local Workspace recorded before tenant RLS is enabled' + ) + workspace_uuid = workspace_uuid.strip() + + def set_oss_tenant_scope(conn: sqlalchemy.Connection) -> None: + conn.execute( + sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"), + {'workspace_uuid': workspace_uuid}, + ) + + sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope) + self._oss_tenant_scope_listener_installed = True + def _enable_sqlite_foreign_keys(self) -> None: """Enable SQLite FK enforcement for every pooled runtime connection.""" engine = self.get_db_engine() @@ -215,6 +300,122 @@ class PersistenceManager: f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}' ) + async def _validate_cloud_runtime(self) -> None: + """Validate a release-prepared schema without performing any DDL.""" + from . import alembic_runner + + engine = self.get_db_engine() + current_revision = await alembic_runner.get_alembic_current(engine) + head_revision = alembic_runner.get_alembic_head() + if current_revision != head_revision: + raise RuntimeError( + f'Cloud runtime database schema is not at the release head: {current_revision!r} != {head_revision!r}' + ) + + runtime_instance_uuid = constants.instance_id.strip() + if not runtime_instance_uuid: + raise RuntimeError('LangBot instance UUID is empty before Cloud persistence validation') + async with engine.connect() as conn: + persisted_instance_uuid = await conn.scalar( + sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid') + ) + if persisted_instance_uuid is None: + raise RuntimeError("Cloud runtime database is missing metadata['instance_uuid']") + if persisted_instance_uuid != runtime_instance_uuid: + raise RuntimeError( + 'LangBot instance UUID does not match the value bound to this database: ' + f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}' + ) + + await self._validate_postgres_tenant_schema(validate_runtime_role=True) + + async def _validate_postgres_tenant_schema(self, *, validate_runtime_role: bool) -> None: + """Fail closed when PostgreSQL cannot enforce the tenant contract.""" + engine = self.get_db_engine() + if engine.dialect.name != 'postgresql': + raise RuntimeError('PostgreSQL tenant schema validation requires PostgreSQL') + + table_query = sqlalchemy.text( + """ + SELECT + c.relname AS table_name, + c.relrowsecurity AS rls_enabled, + c.relforcerowsecurity AS rls_forced, + pg_get_userbyid(c.relowner) = current_user AS owned_by_runtime, + EXISTS ( + SELECT 1 + FROM pg_policy p + WHERE p.polrelid = c.oid + AND p.polname = :policy_name + AND p.polcmd = '*' + AND p.polpermissive + AND p.polqual IS NOT NULL + AND p.polwithcheck IS NOT NULL + ) AS policy_valid + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() + AND c.relkind IN ('r', 'p') + AND c.relname IN :table_names + """ + ).bindparams(sqlalchemy.bindparam('table_names', expanding=True)) + + async with engine.connect() as conn: + if validate_runtime_role: + role = ( + ( + await conn.execute( + sqlalchemy.text( + """ + SELECT rolsuper, rolbypassrls + FROM pg_roles + WHERE rolname = current_user + """ + ) + ) + ) + .mappings() + .one_or_none() + ) + if role is None: + raise RuntimeError('Cloud runtime PostgreSQL role could not be inspected') + if role['rolsuper']: + raise RuntimeError('Cloud runtime PostgreSQL role must not be a superuser') + if role['rolbypassrls']: + raise RuntimeError('Cloud runtime PostgreSQL role must not have BYPASSRLS') + + rows = ( + ( + await conn.execute( + table_query, + { + 'policy_name': TENANT_POLICY_NAME, + 'table_names': tuple(TENANT_TABLE_COLUMNS), + }, + ) + ) + .mappings() + .all() + ) + + by_table = {row['table_name']: row for row in rows} + missing_tables = set(TENANT_TABLE_COLUMNS) - set(by_table) + if missing_tables: + raise RuntimeError(f'PostgreSQL tenant tables are missing: {sorted(missing_tables)!r}') + + invalid_rls = sorted( + table_name + for table_name, row in by_table.items() + if not row['rls_enabled'] or not row['rls_forced'] or not row['policy_valid'] + ) + if invalid_rls: + raise RuntimeError(f'PostgreSQL tenant RLS contract is incomplete for tables: {invalid_rls!r}') + + if validate_runtime_role: + owned_tables = sorted(table_name for table_name, row in by_table.items() if row['owned_by_runtime']) + if owned_tables: + raise RuntimeError(f'Cloud runtime PostgreSQL role must not own tenant tables: {owned_tables!r}') + async def write_space_model_providers(self): if constants.edition != 'community': # SaaS Workspace/provider linkage is explicit control-plane state; @@ -277,7 +478,7 @@ class PersistenceManager: # ================================= - async def _run_alembic_migrations(self): + async def _run_alembic_migrations(self, target_revision: str = 'head'): """Run Alembic-based migrations after legacy migrations complete.""" from . import alembic_runner @@ -310,8 +511,8 @@ class PersistenceManager: # PostgreSQL has transactional DDL. SQLite has already crossed the # two destructive tenancy boundaries under verified backups; this # final call is a no-op today and applies future migrations. - await alembic_runner.run_alembic_upgrade(engine, 'head') - self.ap.logger.info('Alembic migrations completed.') + await alembic_runner.run_alembic_upgrade(engine, target_revision) + self.ap.logger.info(f'Alembic migrations completed at {target_revision}.') except Exception as e: self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True) raise @@ -359,6 +560,9 @@ class PersistenceManager: await conn.commit() return result + def tenant_uow(self, workspace_uuid: str) -> TenantUnitOfWork: + return TenantUnitOfWork(self.get_db_engine(), workspace_uuid) + def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine: return self.db.get_engine() diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py new file mode 100644 index 000000000..74f8c383d --- /dev/null +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import types +import typing + +import sqlalchemy +import sqlalchemy.ext.asyncio as sqlalchemy_asyncio + + +TENANT_SETTING = 'langbot.workspace_uuid' +TENANT_POLICY_NAME = 'langbot_workspace_isolation' + +# Keep this contract explicit. A new tenant-owned table must be added to both +# this runtime list and the corresponding Alembic migration before release. +TENANT_TABLE_COLUMNS: dict[str, str] = { + 'workspaces': 'uuid', + 'workspace_memberships': 'workspace_uuid', + 'workspace_invitations': 'workspace_uuid', + 'workspace_execution_states': 'workspace_uuid', + 'workspace_metadata': 'workspace_uuid', + 'api_keys': 'workspace_uuid', + 'bots': 'workspace_uuid', + 'bot_admins': 'workspace_uuid', + 'binary_storages': 'workspace_uuid', + 'mcp_servers': 'workspace_uuid', + 'model_providers': 'workspace_uuid', + 'llm_models': 'workspace_uuid', + 'embedding_models': 'workspace_uuid', + 'rerank_models': 'workspace_uuid', + 'legacy_pipelines': 'workspace_uuid', + 'pipeline_run_records': 'workspace_uuid', + 'plugin_settings': 'workspace_uuid', + 'knowledge_bases': 'workspace_uuid', + 'knowledge_base_files': 'workspace_uuid', + 'knowledge_base_chunks': 'workspace_uuid', + 'webhooks': 'workspace_uuid', + 'monitoring_messages': 'workspace_uuid', + 'monitoring_llm_calls': 'workspace_uuid', + 'monitoring_tool_calls': 'workspace_uuid', + 'monitoring_sessions': 'workspace_uuid', + 'monitoring_errors': 'workspace_uuid', + 'monitoring_embedding_calls': 'workspace_uuid', + 'monitoring_feedback': 'workspace_uuid', +} + + +class TenantUnitOfWork: + """Bind one Workspace scope to one database transaction. + + PostgreSQL receives a transaction-local setting used by RLS policies. + SQLite keeps the same transaction boundary without a database setting so + OSS callers can adopt this API without changing database engines. + """ + + def __init__(self, engine: sqlalchemy_asyncio.AsyncEngine, workspace_uuid: str) -> None: + workspace_uuid = workspace_uuid.strip() + if not workspace_uuid: + raise ValueError('TenantUnitOfWork requires a non-empty workspace_uuid') + + self._engine = engine + self.workspace_uuid = workspace_uuid + self._session: sqlalchemy_asyncio.AsyncSession | None = None + self._used = False + + @property + def session(self) -> sqlalchemy_asyncio.AsyncSession: + if self._session is None: + raise RuntimeError('TenantUnitOfWork is not active') + return self._session + + async def __aenter__(self) -> TenantUnitOfWork: + if self._used: + raise RuntimeError('TenantUnitOfWork instances cannot be reused') + self._used = True + + session = sqlalchemy_asyncio.AsyncSession(self._engine, expire_on_commit=False) + self._session = session + try: + await session.begin() + if self._engine.dialect.name == 'postgresql': + await session.execute( + sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"), + {'workspace_uuid': self.workspace_uuid}, + ) + except BaseException: + await session.rollback() + await session.close() + self._session = None + raise + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> bool: + session = self.session + try: + if exc_type is None: + await session.commit() + else: + await session.rollback() + finally: + await session.close() + self._session = None + return False + + async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.Result[typing.Any]: + return await self.session.execute(*args, **kwargs) + + async def flush(self) -> None: + await self.session.flush() diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 062bee4af..2eee315fc 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -30,6 +30,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.provider.message as provider_message from ....entity.persistence import mcp as persistence_mcp from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401 +from .mcp_policy import require_stdio_mcp_enabled, stdio_mcp_enabled # Synthesized LLM tools for MCP resources (not from server tools/list). # Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used. @@ -350,6 +351,11 @@ class RuntimeMCPSession: ) async def _init_stdio_python_server(self): + # Final transport gate: this must run before both the Box branch and + # the backwards-compatible host-stdio branch. Service/UI checks are + # usability guards; this is the execution security boundary. + require_stdio_mcp_enabled(self.ap, self.server_config) + if self._uses_box_stdio(): await self._box_stdio_runtime.initialize() return @@ -1436,6 +1442,12 @@ class MCPLoader(loader.ToolLoader): for server in servers: config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + if config.get('mode') == 'stdio' and not stdio_mcp_enabled(self.ap): + self.ap.logger.info( + f'Skipping disabled stdio MCP server {server.uuid}; ' + 'the persisted configuration is retained but no process is launched' + ) + continue try: binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid) execution_context = ExecutionContext( @@ -1511,6 +1523,7 @@ class MCPLoader(loader.ToolLoader): """ execution_context = await self._assert_execution_active(context) server_config = dict(server_config) + require_stdio_mcp_enabled(self.ap, server_config) configured_workspace = str(server_config.get('workspace_uuid') or '').strip() if configured_workspace and configured_workspace != execution_context.workspace_uuid: raise ValueError('MCP server configuration belongs to another Workspace') diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_policy.py b/src/langbot/pkg/provider/tools/loaders/mcp_policy.py new file mode 100644 index 000000000..629de3d1b --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/mcp_policy.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + + +MCP_STDIO_DISABLED_CODE = 'mcp_stdio_disabled' +MCP_STDIO_DISABLED_MESSAGE = 'Stdio MCP is disabled by instance policy' + + +class MCPStdioDisabledError(RuntimeError): + """Raised when an instance-level policy refuses stdio MCP execution.""" + + code = MCP_STDIO_DISABLED_CODE + + def __init__(self) -> None: + super().__init__(MCP_STDIO_DISABLED_MESSAGE) + + +def stdio_mcp_enabled(ap: Any) -> bool: + """Return the independent instance-level stdio MCP feature gate. + + The open-source default remains enabled for backwards compatibility. A + deployment can disable it with ``mcp.stdio.enabled: false`` (or + ``MCP__STDIO__ENABLED=false``). Invalid values fail closed instead of + accidentally enabling local process execution. + """ + + instance_config = getattr(ap, 'instance_config', None) + config = getattr(instance_config, 'data', None) + if not isinstance(config, dict): + return False + mcp_config = config.get('mcp', {}) + if not isinstance(mcp_config, dict): + return False + stdio_config = mcp_config.get('stdio', {}) + if not isinstance(stdio_config, dict): + return False + enabled = stdio_config.get('enabled', True) + return enabled if isinstance(enabled, bool) else False + + +def is_stdio_server(server_config: dict[str, Any] | None) -> bool: + return isinstance(server_config, dict) and str(server_config.get('mode') or '').strip().lower() == 'stdio' + + +def require_stdio_mcp_enabled(ap: Any, server_config: dict[str, Any] | None) -> None: + """Fail closed for a stdio server before choosing Box or host transport.""" + + if is_stdio_server(server_config) and not stdio_mcp_enabled(ap): + raise MCPStdioDisabledError() diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 944c24d13..c1a7f0cca 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -125,9 +125,22 @@ plugin: runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' enable_marketplace: true display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' + worker: + # Instance-wide maximum for every plugin installation. Plugin + # manifests cannot raise or override these limits. + max_cpus: 1.0 + max_memory_mb: 512 + max_pids: 128 + max_open_files: 256 + max_file_size_mb: 512 binary_storage: # Max bytes for a single plugin binary storage value max_value_bytes: 10485760 +mcp: + stdio: + # Independent gate for local stdio MCP transports. Cloud v2 sets + # MCP__STDIO__ENABLED=false even when Box Runtime is available. + enabled: true monitoring: auto_cleanup: # Enable automatic cleanup of expired monitoring records diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index eee2fd825..708c3b37a 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -15,15 +15,20 @@ from __future__ import annotations import logging import os +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + import pytest import sqlalchemy as sa from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy import text from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.user import User -from langbot.pkg.persistence.mgr import PersistenceManager +from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode +from langbot.pkg.persistence.tenant_uow import TENANT_POLICY_NAME, TENANT_TABLE_COLUMNS, TenantUnitOfWork from langbot.pkg.persistence.alembic_runner import ( run_alembic_upgrade, run_alembic_stamp, @@ -49,6 +54,44 @@ def _get_script_head() -> str: return ScriptDirectory.from_config(cfg).get_current_head() +def _application_for_postgres_url(postgres_url: str, logger_name: str) -> SimpleNamespace: + url = sa.engine.make_url(postgres_url) + return SimpleNamespace( + instance_config=SimpleNamespace( + data={ + 'database': { + 'use': 'postgresql', + 'postgresql': { + 'host': url.host, + 'port': url.port, + 'user': url.username, + 'password': url.password, + 'database': url.database, + }, + } + } + ), + logger=logging.getLogger(logger_name), + ) + + +async def _dispose_manager(manager: PersistenceManager | None) -> None: + if manager is not None and getattr(manager, 'db', None) is not None: + await manager.get_db_engine().dispose() + + +def _restore_postgres_manager_registry(monkeypatch) -> None: + """Undo the registry isolation used by test_database_decorator.py.""" + from langbot.pkg.persistence import mgr as persistence_mgr_module + from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager + + monkeypatch.setattr( + persistence_mgr_module.database, + 'preregistered_managers', + [PostgreSQLDatabaseManager], + ) + + pytestmark = [pytest.mark.integration, pytest.mark.slow] @@ -309,7 +352,7 @@ class TestPostgreSQLWorkspaceMigration: ) assert 'workspaces' not in tables_before_migration - await manager._run_alembic_migrations() + await manager._initialize_managed_schema() async with postgres_engine.connect() as conn: account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one() @@ -425,3 +468,278 @@ class TestPostgreSQLResourceTenancyMigration: ), {'workspace_uuid': second_workspace_uuid}, ) + + +class TestPostgreSQLTenantRuntime: + """Release bootstrap, RLS enforcement, and runtime-role safety.""" + + @pytest.mark.asyncio + async def test_oss_postgres_defaults_to_the_singleton_workspace( + self, + postgres_url, + clean_tables, + clean_alembic_version, + monkeypatch, + ): + instance_uuid = 'oss-postgres-rls-compatibility-test' + _restore_postgres_manager_registry(monkeypatch) + monkeypatch.setattr(constants, 'instance_id', instance_uuid) + manager = PersistenceManager( + _application_for_postgres_url(postgres_url, 'postgres-oss-rls-compatibility-test'), + mode=PersistenceMode.OSS_COMPAT, + ) + try: + await manager.initialize() + async with manager.get_db_engine().connect() as conn: + workspace_uuid = await conn.scalar(text("SELECT uuid FROM workspaces WHERE source = 'local'")) + tenant_setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) + visible_workspaces = await conn.scalar(text('SELECT COUNT(*) FROM workspaces')) + assert workspace_uuid == tenant_setting + assert visible_workspaces == 1 + finally: + await _dispose_manager(manager) + + @pytest.mark.asyncio + async def test_release_bootstrap_and_runtime_isolation( + self, + postgres_url, + postgres_engine, + clean_tables, + clean_alembic_version, + monkeypatch, + ): + instance_uuid = 'cloud-runtime-persistence-test' + workspace_a = '10000000-0000-0000-0000-000000000001' + workspace_b = '20000000-0000-0000-0000-000000000002' + role_suffix = uuid.uuid4().hex[:12] + runtime_role = f'lb_runtime_{role_suffix}' + bypass_role = f'lb_bypass_{role_suffix}' + owner_role = f'lb_owner_{role_suffix}' + role_password = f'Lb{uuid.uuid4().hex}' + created_roles: list[str] = [] + managers: list[PersistenceManager] = [] + runtime_engine: AsyncEngine | None = None + owner_changed = False + + _restore_postgres_manager_registry(monkeypatch) + monkeypatch.setattr(constants, 'instance_id', instance_uuid) + release_manager = PersistenceManager( + _application_for_postgres_url(postgres_url, 'postgres-release-bootstrap-test'), + mode=PersistenceMode.RELEASE_MIGRATION, + ) + managers.append(release_manager) + + async with postgres_engine.connect() as conn: + admin_user = await conn.scalar(text('SELECT current_user')) + quote = postgres_engine.dialect.identifier_preparer.quote + + def role_url(role_name: str) -> str: + return ( + sa.engine.make_url(postgres_url) + .set( + username=role_name, + password=role_password, + ) + .render_as_string(hide_password=False) + ) + + async def create_role(role_name: str, *, bypass_rls: bool = False) -> None: + bypass_clause = ' BYPASSRLS' if bypass_rls else '' + async with postgres_engine.connect() as conn: + await conn.execute( + text(f"CREATE ROLE {quote(role_name)} LOGIN{bypass_clause} PASSWORD '{role_password}'") + ) + await conn.execute( + text( + f'GRANT CONNECT ON DATABASE {quote(sa.engine.make_url(postgres_url).database)} ' + f'TO {quote(role_name)}' + ) + ) + await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(role_name)}')) + await conn.execute( + text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {quote(role_name)}') + ) + await conn.execute(text(f'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO {quote(role_name)}')) + created_roles.append(role_name) + + try: + await release_manager.initialize() + release_engine = release_manager.get_db_engine() + + assert await get_alembic_current(release_engine) == _get_script_head() + async with release_engine.connect() as conn: + assert await conn.scalar(text('SELECT COUNT(*) FROM workspaces')) == 0 + rls_rows = ( + ( + await conn.execute( + text( + """ + SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity, + EXISTS ( + SELECT 1 FROM pg_policy p + WHERE p.polrelid = c.oid AND p.polname = :policy_name + ) AS has_policy + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relname IN :table_names + """ + ).bindparams(sa.bindparam('table_names', expanding=True)), + { + 'policy_name': TENANT_POLICY_NAME, + 'table_names': tuple(TENANT_TABLE_COLUMNS), + }, + ) + ) + .mappings() + .all() + ) + assert {row['relname'] for row in rls_rows} == set(TENANT_TABLE_COLUMNS) + assert all(row['relrowsecurity'] and row['relforcerowsecurity'] and row['has_policy'] for row in rls_rows) + + for workspace_uuid, slug in ((workspace_a, 'workspace-a'), (workspace_b, 'workspace-b')): + async with TenantUnitOfWork(release_engine, workspace_uuid) as uow: + await uow.execute( + text( + """ + INSERT INTO workspaces + (uuid, instance_uuid, name, slug, type, status, source, projection_revision) + VALUES + (:uuid, :instance_uuid, :name, :slug, 'team', 'active', 'cloud_projection', 0) + """ + ), + { + 'uuid': workspace_uuid, + 'instance_uuid': instance_uuid, + 'name': slug, + 'slug': slug, + }, + ) + await uow.execute( + text( + 'INSERT INTO workspace_metadata (workspace_uuid, key, value) ' + "VALUES (:workspace_uuid, 'seed', :value)" + ), + {'workspace_uuid': workspace_uuid, 'value': slug}, + ) + + await create_role(runtime_role) + runtime_engine = create_async_engine(role_url(runtime_role), pool_size=1, max_overflow=0) + + async with runtime_engine.connect() as conn: + assert (await conn.execute(text('SELECT uuid FROM workspaces'))).all() == [] + assert (await conn.execute(text('SELECT * FROM workspace_metadata'))).all() == [] + + with pytest.raises(sa.exc.DBAPIError): + async with runtime_engine.begin() as conn: + await conn.execute( + text( + 'INSERT INTO workspace_metadata (workspace_uuid, key, value) ' + "VALUES (:workspace_uuid, 'no-scope', 'rejected')" + ), + {'workspace_uuid': workspace_a}, + ) + + async with TenantUnitOfWork(runtime_engine, workspace_a) as uow: + assert (await uow.execute(text('SELECT uuid FROM workspaces'))).scalars().all() == [workspace_a] + assert ( + await uow.execute( + text('SELECT uuid FROM workspaces WHERE uuid = :workspace_uuid'), + {'workspace_uuid': workspace_b}, + ) + ).all() == [] + + with pytest.raises(sa.exc.DBAPIError): + async with TenantUnitOfWork(runtime_engine, workspace_a) as uow: + await uow.execute( + text( + 'INSERT INTO workspace_metadata (workspace_uuid, key, value) ' + "VALUES (:workspace_uuid, 'cross-scope', 'rejected')" + ), + {'workspace_uuid': workspace_b}, + ) + + async with TenantUnitOfWork(runtime_engine, workspace_a) as uow: + assert (await uow.execute(text('SELECT value FROM workspace_metadata'))).scalars().all() == [ + 'workspace-a' + ] + async with TenantUnitOfWork(runtime_engine, workspace_b) as uow: + assert (await uow.execute(text('SELECT value FROM workspace_metadata'))).scalars().all() == [ + 'workspace-b' + ] + async with runtime_engine.connect() as conn: + setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) + assert setting in (None, '') + assert await conn.scalar(text('SELECT COUNT(*) FROM workspace_metadata')) == 0 + + with pytest.raises(RuntimeError, match='force rollback'): + async with TenantUnitOfWork(runtime_engine, workspace_a) as uow: + await uow.execute( + text( + 'INSERT INTO workspace_metadata (workspace_uuid, key, value) ' + "VALUES (:workspace_uuid, 'rolled-back', 'no')" + ), + {'workspace_uuid': workspace_a}, + ) + raise RuntimeError('force rollback') + async with TenantUnitOfWork(runtime_engine, workspace_a) as uow: + assert ( + await uow.session.scalar(text("SELECT COUNT(*) FROM workspace_metadata WHERE key = 'rolled-back'")) + == 0 + ) + async with runtime_engine.connect() as conn: + setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) + assert setting in (None, '') + + cloud_manager = PersistenceManager( + _application_for_postgres_url(role_url(runtime_role), 'postgres-cloud-runtime-test'), + mode=PersistenceMode.CLOUD_RUNTIME, + ) + cloud_manager.create_tables = AsyncMock(side_effect=AssertionError('Cloud runtime attempted create_all')) + cloud_manager._run_alembic_migrations = AsyncMock( + side_effect=AssertionError('Cloud runtime attempted an Alembic upgrade') + ) + managers.append(cloud_manager) + await cloud_manager.initialize() + cloud_manager.create_tables.assert_not_awaited() + cloud_manager._run_alembic_migrations.assert_not_awaited() + + superuser_manager = PersistenceManager( + _application_for_postgres_url(postgres_url, 'postgres-superuser-runtime-test'), + mode=PersistenceMode.CLOUD_RUNTIME, + ) + managers.append(superuser_manager) + with pytest.raises(RuntimeError, match='must not be a superuser'): + await superuser_manager.initialize() + + await create_role(bypass_role, bypass_rls=True) + bypass_manager = PersistenceManager( + _application_for_postgres_url(role_url(bypass_role), 'postgres-bypass-runtime-test'), + mode=PersistenceMode.CLOUD_RUNTIME, + ) + managers.append(bypass_manager) + with pytest.raises(RuntimeError, match='must not have BYPASSRLS'): + await bypass_manager.initialize() + + await create_role(owner_role) + async with postgres_engine.connect() as conn: + await conn.execute(text(f'ALTER TABLE workspace_metadata OWNER TO {quote(owner_role)}')) + owner_changed = True + owner_manager = PersistenceManager( + _application_for_postgres_url(role_url(owner_role), 'postgres-owner-runtime-test'), + mode=PersistenceMode.CLOUD_RUNTIME, + ) + managers.append(owner_manager) + with pytest.raises(RuntimeError, match='must not own tenant tables'): + await owner_manager.initialize() + finally: + if runtime_engine is not None: + await runtime_engine.dispose() + for manager in reversed(managers): + await _dispose_manager(manager) + + async with postgres_engine.connect() as conn: + if owner_changed: + await conn.execute(text(f'ALTER TABLE workspace_metadata OWNER TO {quote(admin_user)}')) + for role_name in reversed(created_roles): + await conn.execute(text(f'DROP OWNED BY {quote(role_name)}')) + await conn.execute(text(f'DROP ROLE {quote(role_name)}')) diff --git a/tests/integration/persistence/test_sqlite_migration_backup.py b/tests/integration/persistence/test_sqlite_migration_backup.py index 3e9ac276b..dab50dd96 100644 --- a/tests/integration/persistence/test_sqlite_migration_backup.py +++ b/tests/integration/persistence/test_sqlite_migration_backup.py @@ -47,7 +47,7 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path): await _manager(engine)._run_alembic_migrations() - assert await alembic_runner.get_alembic_current(engine) == '0010_scope_resources' + assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() payloads = _manifest_payloads(tmp_path / 'migration-backups') assert len(payloads) == 2 assert { @@ -102,6 +102,6 @@ async def test_failed_tenancy_migration_restores_backup_and_revision( monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade) await _manager(engine)._run_alembic_migrations() - assert await alembic_runner.get_alembic_current(engine) == '0010_scope_resources' + assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() finally: await engine.dispose() diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 0c1147f65..4fc56eba5 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -13,6 +13,7 @@ from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.user import User from langbot.pkg.persistence.mgr import PersistenceManager from langbot.pkg.persistence.alembic_runner import ( + get_alembic_head, get_alembic_current, run_alembic_downgrade, run_alembic_stamp, @@ -126,7 +127,7 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy assert execution_state['state'] == 'active' assert execution_state['write_fenced'] in (False, 0) - assert await get_alembic_current(legacy_engine) == '0010_scope_resources' + assert await get_alembic_current(legacy_engine) == get_alembic_head() async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_engine): diff --git a/tests/unit_tests/api/http/test_entitlement_context.py b/tests/unit_tests/api/http/test_entitlement_context.py new file mode 100644 index 000000000..600eb1082 --- /dev/null +++ b/tests/unit_tests/api/http/test_entitlement_context.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +from langbot.pkg.api.http.context import ( + ExecutionContext, + PrincipalContext, + PrincipalType, + RequestContext, + WorkspaceContext, +) +from langbot.pkg.api.http.controller.group import RouterGroup +from langbot.pkg.cloud.entitlements import EntitlementSnapshot, EntitlementUnavailableError +from langbot.pkg.cloud.entitlements import EntitlementResolver + + +class _Group(RouterGroup): + async def initialize(self) -> None: + return None + + +def _router(deployment) -> _Group: + provider = getattr(deployment, 'entitlement_provider', None) + resolver = EntitlementResolver('instance-a', provider) if provider is not None else None + ap = SimpleNamespace(deployment=deployment, entitlement_resolver=resolver) + return _Group(ap, quart.Quart(__name__)) + + +@pytest.mark.asyncio +async def test_cloud_request_resolves_verified_entitlement_revision(): + snapshot = EntitlementSnapshot( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + entitlement_revision=9, + status='active', + not_before=1, + expires_at=4_000_000_000, + features={}, + limits={}, + ) + provider = SimpleNamespace(get_workspace_entitlement=AsyncMock(return_value=snapshot)) + router = _router(SimpleNamespace(multi_workspace_enabled=True, entitlement_provider=provider)) + + revision = await router._resolve_entitlement_revision('instance-a', 'workspace-a') + + assert revision == 9 + provider.get_workspace_entitlement.assert_awaited_once_with('workspace-a') + + +@pytest.mark.asyncio +async def test_cloud_request_fails_closed_without_entitlement_provider(): + router = _router(SimpleNamespace(multi_workspace_enabled=True, entitlement_provider=None)) + + with pytest.raises(EntitlementUnavailableError): + await router._resolve_entitlement_revision('instance-a', 'workspace-a') + + +def test_execution_context_preserves_entitlement_revision(): + request = RequestContext( + instance_uuid='instance-a', + placement_generation=1, + request_id='request-a', + auth_type='user-token', + principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'), + workspace=WorkspaceContext( + workspace_uuid='workspace-a', + membership_uuid='membership-a', + role='owner', + permissions=frozenset(), + ), + entitlement_revision=11, + ) + + assert ExecutionContext.from_request(request).entitlement_revision == 11 diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py index 4b3504c46..b5dba59db 100644 --- a/tests/unit_tests/api/service/test_mcp_service.py +++ b/tests/unit_tests/api/service/test_mcp_service.py @@ -29,6 +29,7 @@ from langbot.pkg.api.http.context import ( ) from langbot.pkg.api.http.service.mcp import MCPService, redact_mcp_secrets, restore_mcp_secret_placeholders from langbot.pkg.entity.persistence.mcp import MCPServer +from langbot.pkg.provider.tools.loaders.mcp_policy import MCPStdioDisabledError from langbot.pkg.workspace.errors import WorkspaceNotFoundError @@ -351,6 +352,27 @@ class TestMCPServiceGetMCPServers: class TestMCPServiceCreateMCPServer: """Tests for create_mcp_server method.""" + async def test_create_stdio_rejected_by_independent_instance_gate(self): + ap = SimpleNamespace( + instance_config=SimpleNamespace( + data={ + 'mcp': {'stdio': {'enabled': False}}, + 'system': {'limitation': {'max_extensions': -1}}, + } + ), + persistence_mgr=SimpleNamespace(execute_async=AsyncMock()), + tool_mgr=None, + ) + service = _service(ap) + + with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'): + await service.create_mcp_server( + _CONTEXT, + {'name': 'local', 'mode': 'stdio', 'enable': True, 'extra_args': {}}, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + async def test_create_mcp_server_max_extensions_reached_raises(self): """Raises ValueError when max_extensions limit reached.""" # Setup @@ -887,6 +909,24 @@ class TestMCPServiceDeleteMCPServer: class TestMCPServiceTestMCPServer: """Tests for test_mcp_server method.""" + async def test_transient_stdio_test_rejected_by_instance_gate(self): + ap = SimpleNamespace( + instance_config=SimpleNamespace(data={'mcp': {'stdio': {'enabled': False}}}), + tool_mgr=SimpleNamespace(mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock())), + task_mgr=SimpleNamespace(create_user_task=Mock()), + ) + service = _service(ap) + + with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'): + await service.test_mcp_server( + _CONTEXT, + '_', + {'name': 'local', 'mode': 'stdio', 'enable': True, 'extra_args': {}}, + ) + + ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_not_awaited() + ap.task_mgr.create_user_task.assert_not_called() + async def test_test_mcp_server_existing_server(self): """Tests existing MCP server connection.""" # Setup diff --git a/tests/unit_tests/cloud/test_bootstrap.py b/tests/unit_tests/cloud/test_bootstrap.py new file mode 100644 index 000000000..35bb634f0 --- /dev/null +++ b/tests/unit_tests/cloud/test_bootstrap.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from langbot.pkg.cloud.bootstrap import ( + CloudBootstrapError, + OpenSourceDeployment, + VerifiedCloudDeployment, + resolve_deployment, +) +from langbot.pkg.cloud.entitlements import EntitlementSnapshot + + +pytestmark = pytest.mark.asyncio + + +class _Entitlements: + async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot: + return EntitlementSnapshot( + instance_uuid='instance-a', + workspace_uuid=workspace_uuid, + entitlement_revision=1, + status='active', + not_before=1, + expires_at=4_000_000_000, + features={'managed_sandbox': True}, + limits={'managed_sandbox_sessions': 1}, + ) + + +class _Provider: + def bootstrap(self, *, instance_uuid: str, instance_config: dict): + del instance_config + return VerifiedCloudDeployment( + instance_uuid=instance_uuid, + manifest_jti='manifest-a', + manifest_generation=3, + expires_at=4_000_000_000, + release='cloud-v2', + capabilities=frozenset({'multi_workspace_v2'}), + tenant_isolation_version=2, + entitlement_provider=_Entitlements(), + verification_key_id='root-2026', + ) + + +class _EntryPoint: + def __init__(self, value): + self.value = value + + def load(self): + return self.value + + +class _EntryPoints(list): + def select(self, *, group: str): + return self if group == 'langbot.cloud_bootstrap' else [] + + +def _cloud_config() -> dict: + return { + 'database': {'use': 'postgresql'}, + 'vdb': {'use': 'pgvector'}, + 'mcp': {'stdio': {'enabled': False}}, + # Proves mutable product metadata does not participate in selection. + 'system': {'edition': 'community'}, + } + + +async def test_no_closed_entry_point_selects_oss_singleton_even_if_edition_says_cloud(): + deployment = await resolve_deployment( + instance_uuid='instance-a', + instance_config={'system': {'edition': 'cloud'}}, + entry_points=lambda: _EntryPoints(), + ) + + assert isinstance(deployment, OpenSourceDeployment) + assert deployment.multi_workspace_enabled is False + + +async def test_verified_closed_entry_point_activates_cloud_policy(): + deployment = await resolve_deployment( + instance_uuid='instance-a', + instance_config=_cloud_config(), + entry_points=lambda: _EntryPoints([_EntryPoint(_Provider)]), + now=1_000, + ) + + assert isinstance(deployment, VerifiedCloudDeployment) + assert deployment.multi_workspace_enabled is True + assert deployment.persistence_mode == 'cloud_runtime' + + +@pytest.mark.parametrize( + ('field', 'value', 'message'), + [ + ('database', {'use': 'sqlite'}, 'database.use=postgresql'), + ('vdb', {'use': 'chroma'}, 'vdb.use=pgvector'), + ('mcp', {'stdio': {'enabled': True}}, 'mcp.stdio.enabled=false'), + ], +) +async def test_cloud_runtime_config_is_fail_closed(field, value, message): + config = _cloud_config() + config[field] = value + + with pytest.raises(CloudBootstrapError, match=message): + await resolve_deployment( + instance_uuid='instance-a', + instance_config=config, + entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]), + now=1_000, + ) + + +async def test_invalid_provider_never_falls_back_to_oss(): + provider = SimpleNamespace(bootstrap=lambda **_: object()) + + with pytest.raises(CloudBootstrapError, match='must return VerifiedCloudDeployment'): + await resolve_deployment( + instance_uuid='instance-a', + instance_config=_cloud_config(), + entry_points=lambda: _EntryPoints([_EntryPoint(provider)]), + now=1_000, + ) + + +async def test_duplicate_closed_providers_fail_closed(): + with pytest.raises(CloudBootstrapError, match='Exactly one'): + await resolve_deployment( + instance_uuid='instance-a', + instance_config=_cloud_config(), + entry_points=lambda: _EntryPoints([_EntryPoint(_Provider()), _EntryPoint(_Provider())]), + now=1_000, + ) diff --git a/tests/unit_tests/cloud/test_entitlements.py b/tests/unit_tests/cloud/test_entitlements.py new file mode 100644 index 000000000..835ab1f8a --- /dev/null +++ b/tests/unit_tests/cloud/test_entitlements.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock + +from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot, EntitlementUnavailableError + + +def _snapshot(**overrides) -> EntitlementSnapshot: + values = { + 'instance_uuid': 'instance-a', + 'workspace_uuid': 'workspace-a', + 'entitlement_revision': 7, + 'status': 'active', + 'not_before': 100, + 'expires_at': 200, + 'features': {'managed_sandbox': True, 'mcp_stdio': False}, + 'limits': {'managed_sandbox_sessions': 1}, + } + values.update(overrides) + return EntitlementSnapshot(**values) + + +def test_active_snapshot_exposes_only_generic_features_and_limits(): + snapshot = _snapshot().require_active( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + now=150, + ) + + snapshot.require_feature('managed_sandbox') + assert snapshot.limit('managed_sandbox_sessions') == 1 + assert 'plan' not in snapshot.model_fields + + +@pytest.mark.parametrize( + 'snapshot,now', + [ + (_snapshot(status='suspended'), 150), + (_snapshot(), 99), + (_snapshot(), 200), + ], +) +def test_inactive_or_expired_snapshot_fails_closed(snapshot, now): + with pytest.raises(EntitlementUnavailableError): + snapshot.require_active( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + now=now, + ) + + +def test_scope_mismatch_fails_closed(): + with pytest.raises(EntitlementUnavailableError, match='scope'): + _snapshot().require_active( + instance_uuid='instance-a', + workspace_uuid='workspace-b', + now=150, + ) + + +@pytest.mark.asyncio +async def test_resolver_rejects_revision_rollback(): + provider = AsyncMock() + provider.get_workspace_entitlement = AsyncMock(side_effect=[_snapshot(), _snapshot(entitlement_revision=6)]) + resolver = EntitlementResolver('instance-a', provider) + + await resolver.resolve('workspace-a', now=150) + with pytest.raises(EntitlementUnavailableError, match='rolled back'): + await resolver.resolve('workspace-a', now=150) + + +@pytest.mark.asyncio +async def test_resolver_rejects_same_revision_with_different_contents(): + provider = AsyncMock() + provider.get_workspace_entitlement = AsyncMock( + side_effect=[ + _snapshot(), + _snapshot(features={'managed_sandbox': False}), + ] + ) + resolver = EntitlementResolver('instance-a', provider) + + await resolver.resolve('workspace-a', now=150) + with pytest.raises(EntitlementUnavailableError, match='conflicting contents'): + await resolver.resolve('workspace-a', now=150) diff --git a/tests/unit_tests/core/test_load_config.py b/tests/unit_tests/core/test_load_config.py index 9a83f2fb6..34c3e57ae 100644 --- a/tests/unit_tests/core/test_load_config.py +++ b/tests/unit_tests/core/test_load_config.py @@ -259,6 +259,60 @@ class TestApplyEnvOverridesToConfig: assert result['system']['enable'] is False assert result['concurrency']['pipeline'] == 10 + def test_plugin_worker_and_stdio_policy_native_env_overrides(self): + load_config = get_load_config_module() + cfg = { + 'plugin': { + 'worker': { + 'max_cpus': 1.0, + 'max_memory_mb': 512, + 'max_pids': 128, + 'max_open_files': 256, + 'max_file_size_mb': 512, + } + }, + 'mcp': {'stdio': {'enabled': True}}, + } + env = { + 'PLUGIN__WORKER__MAX_CPUS': '2.5', + 'PLUGIN__WORKER__MAX_MEMORY_MB': '1024', + 'PLUGIN__WORKER__MAX_PIDS': '64', + 'PLUGIN__WORKER__MAX_OPEN_FILES': '128', + 'PLUGIN__WORKER__MAX_FILE_SIZE_MB': '256', + 'MCP__STDIO__ENABLED': 'false', + } + + with patch.dict(os.environ, env, clear=True): + result = load_config._apply_env_overrides_to_config(cfg) + + assert result['plugin']['worker'] == { + 'max_cpus': 2.5, + 'max_memory_mb': 1024, + 'max_pids': 64, + 'max_open_files': 128, + 'max_file_size_mb': 256, + } + assert result['mcp']['stdio']['enabled'] is False + + def test_runtime_policy_defaults_preserve_env_types_for_upgraded_config(self): + load_config = get_load_config_module() + cfg = {'plugin': {'enable': True}} + + completed = load_config._complete_runtime_policy_defaults(cfg) + with patch.dict( + os.environ, + { + 'PLUGIN__WORKER__MAX_MEMORY_MB': '768', + 'MCP__STDIO__ENABLED': 'false', + }, + clear=True, + ): + result = load_config._apply_env_overrides_to_config(completed) + + assert result['plugin']['worker']['max_memory_mb'] == 768 + assert isinstance(result['plugin']['worker']['max_memory_mb'], int) + assert result['mcp']['stdio']['enabled'] is False + def test_webhook_prefix_override(self): """Test overriding webhook_prefix via environment variable.""" load_config = get_load_config_module() diff --git a/tests/unit_tests/persistence/test_tenant_uow.py b/tests/unit_tests/persistence/test_tenant_uow.py new file mode 100644 index 000000000..5199a8bf5 --- /dev/null +++ b/tests/unit_tests/persistence/test_tenant_uow.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode +from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork + + +pytestmark = pytest.mark.asyncio + + +async def test_sqlite_tenant_uow_commits_and_rolls_back() -> None: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + table = sa.Table( + 'uow_rows', + sa.MetaData(), + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('workspace_uuid', sa.String(36), nullable=False), + ) + try: + async with engine.begin() as conn: + await conn.run_sync(table.metadata.create_all) + + async with TenantUnitOfWork(engine, 'workspace-a') as uow: + await uow.execute(sa.insert(table).values(id=1, workspace_uuid='workspace-a')) + + with pytest.raises(RuntimeError, match='roll back'): + async with TenantUnitOfWork(engine, 'workspace-a') as uow: + await uow.execute(sa.insert(table).values(id=2, workspace_uuid='workspace-a')) + raise RuntimeError('roll back this transaction') + + async with engine.connect() as conn: + rows = (await conn.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all() + assert rows == [1] + finally: + await engine.dispose() + + +async def test_tenant_uow_is_single_use_and_requires_an_active_scope() -> None: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + uow = TenantUnitOfWork(engine, 'workspace-a') + try: + with pytest.raises(RuntimeError, match='not active'): + _ = uow.session + + async with uow: + assert uow.session.in_transaction() + + with pytest.raises(RuntimeError, match='cannot be reused'): + async with uow: + pass + finally: + await engine.dispose() + + +def test_persistence_mode_must_be_a_trusted_enum() -> None: + with pytest.raises(TypeError, match='trusted PersistenceMode'): + PersistenceManager(object(), mode='cloud_runtime') # type: ignore[arg-type] + + manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME) + assert manager.mode is PersistenceMode.CLOUD_RUNTIME diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py index bf0d82a20..af42a67fb 100644 --- a/tests/unit_tests/provider/test_mcp_box_integration.py +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -154,6 +154,7 @@ def mcp_module(): def _make_ap(): ap = Mock() ap.logger = Mock() + ap.instance_config = SimpleNamespace(data={'mcp': {'stdio': {'enabled': True}}}) ap.workspace_service = Mock() ap.workspace_service.get_execution_binding = AsyncMock( return_value=SimpleNamespace( @@ -791,6 +792,31 @@ class TestBoxConfigParsing: assert s.box_config.host_path_mode == 'ro' +@pytest.mark.asyncio +async def test_stdio_instance_gate_runs_before_box_transport(mcp_module): + ap = _make_ap() + ap.instance_config.data['mcp']['stdio']['enabled'] = False + ap.box_service.available = True + session = _make_session( + mcp_module, + { + 'name': 'blocked', + 'uuid': 'blocked-uuid', + 'mode': 'stdio', + 'command': 'python', + 'args': [], + 'env': {}, + }, + ap=ap, + ) + session._box_stdio_runtime.initialize = AsyncMock() + + with pytest.raises(RuntimeError, match='disabled by instance policy'): + await session._init_stdio_python_server() + + session._box_stdio_runtime.initialize.assert_not_awaited() + + @pytest.mark.asyncio async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_module, tmp_path): mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] diff --git a/tests/unit_tests/provider/test_mcp_stdio_policy.py b/tests/unit_tests/provider/test_mcp_stdio_policy.py new file mode 100644 index 000000000..57012bb9c --- /dev/null +++ b/tests/unit_tests/provider/test_mcp_stdio_policy.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.provider.tools.loaders.mcp_policy import ( + MCPStdioDisabledError, + require_stdio_mcp_enabled, + stdio_mcp_enabled, +) +from langbot.pkg.provider.tools.loaders.mcp import MCPLoader + + +def _app(config: dict) -> SimpleNamespace: + return SimpleNamespace(instance_config=SimpleNamespace(data=config)) + + +def test_oss_default_remains_enabled_when_key_is_absent(): + assert stdio_mcp_enabled(_app({})) is True + + +@pytest.mark.parametrize( + 'value', + [False, 'false', 0, None, {}, []], +) +def test_disabled_or_invalid_values_fail_closed(value): + ap = _app({'mcp': {'stdio': {'enabled': value}}}) + + assert stdio_mcp_enabled(ap) is False + with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'): + require_stdio_mcp_enabled(ap, {'mode': 'stdio'}) + + +def test_remote_transport_is_independent_of_stdio_gate(): + ap = _app({'mcp': {'stdio': {'enabled': False}}}) + + require_stdio_mcp_enabled(ap, {'mode': 'remote'}) + + +@pytest.mark.asyncio +async def test_bootstrap_retains_but_does_not_launch_disabled_stdio_rows(): + server = SimpleNamespace(uuid='server-a', workspace_uuid='workspace-a') + result = Mock() + result.all.return_value = [server] + ap = _app({'mcp': {'stdio': {'enabled': False}}}) + ap.logger = Mock() + ap.persistence_mgr = SimpleNamespace( + execute_async=AsyncMock(return_value=result), + serialize_model=Mock( + return_value={ + 'uuid': 'server-a', + 'workspace_uuid': 'workspace-a', + 'name': 'local', + 'mode': 'stdio', + 'enable': True, + 'extra_args': {}, + } + ), + ) + ap.workspace_service = SimpleNamespace(get_execution_binding=AsyncMock()) + loader = MCPLoader(ap) + loader.host_mcp_server = AsyncMock() + + await loader.load_mcp_servers_from_db() + + loader.host_mcp_server.assert_not_awaited() + ap.workspace_service.get_execution_binding.assert_not_awaited() + assert loader.sessions == {} diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index 753e7a73c..45b983f04 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -56,6 +56,7 @@ import { import { CustomApiError } from '@/app/infra/entities/common'; import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice'; import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; +import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy'; function StatusDisplay({ testing, @@ -560,11 +561,15 @@ const MCPForm = forwardRef(function MCPForm( hint: boxHint, reason: boxReason, } = useBoxStatus(); + const { enabled: mcpStdioEnabled } = useMCPStdioPolicy(); // stdio mode requires the Box sandbox at runtime. If the user picks // stdio while Box is disabled / unreachable, the server would refuse // to start anyway — block creation upfront so they aren't surprised // by an immediate "Connection failed" on the detail page. - const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable; + const stdioBlockedByPolicy = watchMode === 'stdio' && !mcpStdioEnabled; + const stdioBlockedByBox = + watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable; + const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox; const { isDirty } = form.formState; useEffect(() => { @@ -572,8 +577,8 @@ const MCPForm = forwardRef(function MCPForm( }, [isDirty, onDirtyChange]); useEffect(() => { - onSaveBlockedChange?.(stdioBlockedByBox); - }, [stdioBlockedByBox, onSaveBlockedChange]); + onSaveBlockedChange?.(stdioBlocked); + }, [stdioBlocked, onSaveBlockedChange]); useEffect(() => { onTestingChange?.(mcpTesting); @@ -589,10 +594,9 @@ const MCPForm = forwardRef(function MCPForm( testMcp: () => testMcp(), isTesting: mcpTesting, }), - // testMcp now reads everything via form.getValues(), so it does not need - // the latest stdioArgs/extraArgs closure — but keep mcpTesting so the - // exposed isTesting flag stays accurate. - [mcpTesting], + // Form values are read through form.getValues(); policy and runtime health + // remain closure values and must refresh the imperative handler. + [mcpTesting, mcpStdioEnabled, boxAvailable], ); useEffect(() => { @@ -747,6 +751,10 @@ const MCPForm = forwardRef(function MCPForm( async function handleFormSubmit(value: z.infer) { // Belt-and-suspenders: even though the Save button is disabled when // stdio is unselectable, intercept programmatic submits too. + if (value.mode === 'stdio' && !mcpStdioEnabled) { + toast.error(t('mcp.stdioDisabledByPolicy')); + return; + } if (value.mode === 'stdio' && !boxAvailable) { toast.error(t('mcp.stdioBlockedByBoxToast')); return; @@ -814,6 +822,16 @@ const MCPForm = forwardRef(function MCPForm( try { const mode = form.getValues('mode'); + if (mode === 'stdio' && !mcpStdioEnabled) { + toast.error(t('mcp.stdioDisabledByPolicy')); + setMcpTesting(false); + return; + } + if (mode === 'stdio' && !boxAvailable) { + toast.error(t('mcp.stdioBlockedByBoxToast')); + setMcpTesting(false); + return; + } // Read every field via form.getValues() rather than the captured // `stdioArgs` / `extraArgs` state. testMcp() is invoked through an // imperative handle (formRef.current.testMcp()) whose closure is only @@ -1020,13 +1038,20 @@ const MCPForm = forwardRef(function MCPForm( {t('mcp.remote')} - + {t('mcp.local')} - {!boxAvailable && ( + {!mcpStdioEnabled ? ( + + ({t('mcp.disabledByPolicy')}) + + ) : !boxAvailable ? ( ({t('mcp.boxRequired')}) - )} + ) : null} @@ -1035,6 +1060,14 @@ const MCPForm = forwardRef(function MCPForm( ? t('mcp.localModeDescription') : t('mcp.remoteModeDescription')} + {stdioBlockedByPolicy && ( +
+ {t('mcp.stdioDisabledByPolicy')} +
+ )} {stdioBlockedByBox && ( { + try { + const info = await httpClient.getSystemInfo(); + setEnabled(info.mcp_stdio_enabled === true); + } catch { + setEnabled(false); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { enabled, loading, refresh }; +} diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index 67736e848..d718cfb5c 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -25,6 +25,7 @@ export const systemInfo: ApiRespSystemInfo = { debug: false, version: '', edition: 'community', + mcp_stdio_enabled: false, enable_marketplace: true, cloud_service_url: '', allow_modify_login_info: true, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 209c78dd6..83b2903ab 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -820,6 +820,9 @@ const enUS = { boxStdioRefusedSuggestion: 'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.', boxRequired: 'requires Box', + disabledByPolicy: 'disabled by policy', + stdioDisabledByPolicy: + 'Stdio MCP is disabled for this deployment. Use a remote MCP server instead.', stdioBlockedByBoxToast: 'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.', toolsFound: 'tools', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 6d25d0112..fd6e03879 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -833,6 +833,9 @@ const esES = { boxStdioRefusedSuggestion: 'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.', boxRequired: 'requiere Box', + disabledByPolicy: 'desactivado por la política', + stdioDisabledByPolicy: + 'Stdio MCP está deshabilitado en este despliegue. Use un servidor MCP remoto.', stdioBlockedByBoxToast: 'No se puede guardar el MCP en modo stdio mientras el sandbox de Box está desactivado o no disponible. Active Box o seleccione modo http/sse.', toolsFound: 'herramientas', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 35f94d6c0..6f6298224 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -826,6 +826,9 @@ const jaJP = { boxStdioRefusedSuggestion: 'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。', boxRequired: 'Box が必要', + disabledByPolicy: 'ポリシーにより無効', + stdioDisabledByPolicy: + 'このデプロイでは Stdio MCP が無効です。リモート MCP サーバーを使用してください。', stdioBlockedByBoxToast: 'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。', toolsFound: '個のツール', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 0f6072c49..5efdf6744 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -830,6 +830,9 @@ const ruRU = { boxStdioRefusedSuggestion: 'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.', boxRequired: 'требуется Box', + disabledByPolicy: 'отключено политикой', + stdioDisabledByPolicy: + 'Stdio MCP отключён в этом развёртывании. Используйте удалённый MCP-сервер.', stdioBlockedByBoxToast: 'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.', toolsFound: 'инструментов', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 704daf9cd..aaf308159 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -808,6 +808,9 @@ const thTH = { boxStdioRefusedSuggestion: 'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse', boxRequired: 'ต้องใช้ Box', + disabledByPolicy: 'ถูกปิดใช้งานโดยนโยบาย', + stdioDisabledByPolicy: + 'การติดตั้งใช้งานนี้ปิด Stdio MCP อยู่ โปรดใช้เซิร์ฟเวอร์ MCP แบบระยะไกล', stdioBlockedByBoxToast: 'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse', toolsFound: 'เครื่องมือ', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index b9770314e..5d004657f 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -823,6 +823,9 @@ const viVN = { boxStdioRefusedSuggestion: 'Hãy bật Box (box.enabled = true) và đảm bảo runtime hoạt động, hoặc chuyển server này sang chế độ http/sse.', boxRequired: 'cần Box', + disabledByPolicy: 'bị tắt theo chính sách', + stdioDisabledByPolicy: + 'Stdio MCP đã bị tắt trong bản triển khai này. Hãy dùng máy chủ MCP từ xa.', stdioBlockedByBoxToast: 'Không thể lưu MCP ở chế độ stdio khi Sandbox Box bị tắt hoặc không khả dụng. Hãy bật Box hoặc chọn chế độ http/sse.', toolsFound: 'công cụ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index e802c8e61..2e5e702a7 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -786,6 +786,8 @@ const zhHans = { boxStdioRefusedSuggestion: '请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。', boxRequired: '需要 Box', + disabledByPolicy: '已被策略禁用', + stdioDisabledByPolicy: '此部署已禁用 Stdio MCP,请改用远程 MCP 服务器。', stdioBlockedByBoxToast: 'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。', toolsFound: '个工具', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 1ae98f758..28f8aa6ed 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -784,6 +784,8 @@ const zhHant = { boxStdioRefusedSuggestion: '請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。', boxRequired: '需要 Box', + disabledByPolicy: '已被策略停用', + stdioDisabledByPolicy: '此部署已停用 Stdio MCP,請改用遠端 MCP 伺服器。', stdioBlockedByBoxToast: 'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。', toolsFound: '個工具',