mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 21:36:06 +00:00
feat(tenancy): establish cloud isolation foundations
This commit is contained in:
@@ -69,6 +69,7 @@ class ExecutionContext:
|
|||||||
pipeline_uuid: str | None = None
|
pipeline_uuid: str | None = None
|
||||||
query_uuid: str | None = None
|
query_uuid: str | None = None
|
||||||
trigger_principal: PrincipalContext | None = None
|
trigger_principal: PrincipalContext | None = None
|
||||||
|
entitlement_revision: int = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_request(
|
def from_request(
|
||||||
@@ -89,4 +90,5 @@ class ExecutionContext:
|
|||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
query_uuid=query_uuid,
|
query_uuid=query_uuid,
|
||||||
trigger_principal=ctx.principal,
|
trigger_principal=ctx.principal,
|
||||||
|
entitlement_revision=ctx.entitlement_revision,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from quart.typing import RouteCallable
|
|||||||
from ....utils import constants
|
from ....utils import constants
|
||||||
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
|
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
|
from ....cloud.entitlements import EntitlementUnavailableError
|
||||||
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
||||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
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')
|
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
|
||||||
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
|
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(
|
request_context = RequestContext(
|
||||||
instance_uuid=access.execution.instance_uuid,
|
instance_uuid=access.execution.instance_uuid,
|
||||||
placement_generation=access.execution.placement_generation,
|
placement_generation=access.execution.placement_generation,
|
||||||
@@ -260,6 +265,7 @@ class RouterGroup(abc.ABC):
|
|||||||
permissions=permissions_for_role(access.membership.role),
|
permissions=permissions_for_role(access.membership.role),
|
||||||
membership_revision=access.membership.projection_revision,
|
membership_revision=access.membership.projection_revision,
|
||||||
),
|
),
|
||||||
|
entitlement_revision=entitlement_revision,
|
||||||
)
|
)
|
||||||
quart.g.request_context = request_context
|
quart.g.request_context = request_context
|
||||||
quart.g.workspace_membership = access.membership
|
quart.g.workspace_membership = access.membership
|
||||||
@@ -272,6 +278,10 @@ class RouterGroup(abc.ABC):
|
|||||||
if inspect.isawaitable(authenticated):
|
if inspect.isawaitable(authenticated):
|
||||||
identity = await authenticated
|
identity = await authenticated
|
||||||
if identity is not None:
|
if identity is not None:
|
||||||
|
entitlement_revision = await self._resolve_entitlement_revision(
|
||||||
|
identity.instance_uuid,
|
||||||
|
identity.workspace_uuid,
|
||||||
|
)
|
||||||
request_context = RequestContext(
|
request_context = RequestContext(
|
||||||
instance_uuid=identity.instance_uuid,
|
instance_uuid=identity.instance_uuid,
|
||||||
placement_generation=identity.placement_generation,
|
placement_generation=identity.placement_generation,
|
||||||
@@ -287,6 +297,7 @@ class RouterGroup(abc.ABC):
|
|||||||
role=None,
|
role=None,
|
||||||
permissions=identity.permissions,
|
permissions=identity.permissions,
|
||||||
),
|
),
|
||||||
|
entitlement_revision=entitlement_revision,
|
||||||
)
|
)
|
||||||
quart.g.request_context = request_context
|
quart.g.request_context = request_context
|
||||||
return request_context
|
return request_context
|
||||||
@@ -316,6 +327,18 @@ class RouterGroup(abc.ABC):
|
|||||||
quart.g.request_context = request_context
|
quart.g.request_context = request_context
|
||||||
return 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
|
@staticmethod
|
||||||
def _inject_handler_context(
|
def _inject_handler_context(
|
||||||
handler: RouteCallable,
|
handler: RouteCallable,
|
||||||
@@ -339,6 +362,8 @@ class RouterGroup(abc.ABC):
|
|||||||
return self.http_status(404, 'resource_not_found', 'Resource not found')
|
return self.http_status(404, 'resource_not_found', 'Resource not found')
|
||||||
if isinstance(error, MembershipPermissionError):
|
if isinstance(error, MembershipPermissionError):
|
||||||
return self.http_status(403, error.code, str(error))
|
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()
|
request_id = self.request_id()
|
||||||
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
||||||
logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
|
logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from urllib.parse import unquote
|
|||||||
|
|
||||||
from ....authz import Permission
|
from ....authz import Permission
|
||||||
from ....context import RequestContext
|
from ....context import RequestContext
|
||||||
|
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||||
from ... import group
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
try:
|
try:
|
||||||
server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data)
|
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:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
return self.success(data={'uuid': server_uuid})
|
return self.success(data={'uuid': server_uuid})
|
||||||
@@ -63,6 +66,8 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
try:
|
try:
|
||||||
await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data)
|
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:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
else:
|
else:
|
||||||
@@ -79,11 +84,14 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
"""测试MCP服务器连接"""
|
"""测试MCP服务器连接"""
|
||||||
server_name = unquote(server_name)
|
server_name = unquote(server_name)
|
||||||
server_data = await quart.request.json
|
server_data = await quart.request.json
|
||||||
task_id = await self.ap.mcp_service.test_mcp_server(
|
try:
|
||||||
request_context,
|
task_id = await self.ap.mcp_service.test_mcp_server(
|
||||||
server_name=server_name,
|
request_context,
|
||||||
server_data=server_data,
|
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})
|
return self.success(data={'task_id': task_id})
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .....utils import constants
|
|||||||
from .....entity.persistence.metadata import WorkspaceMetadata
|
from .....entity.persistence.metadata import WorkspaceMetadata
|
||||||
from ...authz import Permission
|
from ...authz import Permission
|
||||||
from ...context import RequestContext
|
from ...context import RequestContext
|
||||||
|
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('system', '/api/v1/system')
|
@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': self.ap.instance_config.data.get('space', {}).get(
|
||||||
'disable_models_service', False
|
'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', {}),
|
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||||
'outbound_ips': outbound_ips,
|
'outbound_ips': outbound_ips,
|
||||||
'wizard_status': wizard_status,
|
'wizard_status': wizard_status,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from ....core import app, taskmgr
|
|||||||
from ....entity.persistence import mcp as persistence_mcp
|
from ....entity.persistence import mcp as persistence_mcp
|
||||||
from ....entity.persistence import plugin as persistence_plugin
|
from ....entity.persistence import plugin as persistence_plugin
|
||||||
from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession
|
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 ....workspace.errors import WorkspaceNotFoundError
|
||||||
from ..context import ExecutionContext
|
from ..context import ExecutionContext
|
||||||
from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders
|
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)
|
execution_context = await self._execution_context(context)
|
||||||
workspace_uuid = execution_context.workspace_uuid
|
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', {})
|
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||||
max_extensions = limitation.get('max_extensions', -1)
|
max_extensions = limitation.get('max_extensions', -1)
|
||||||
if max_extensions >= 0:
|
if max_extensions >= 0:
|
||||||
@@ -315,6 +320,13 @@ class MCPService:
|
|||||||
if duplicate is not None and duplicate['uuid'] != server_uuid:
|
if duplicate is not None and duplicate['uuid'] != server_uuid:
|
||||||
raise ValueError(f'MCP server already exists: {payload["name"]}')
|
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(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.update(persistence_mcp.MCPServer)
|
sqlalchemy.update(persistence_mcp.MCPServer)
|
||||||
@@ -405,7 +417,8 @@ class MCPService:
|
|||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
|
||||||
if server_name != '_':
|
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)
|
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
|
||||||
if runtime_mcp_session is None:
|
if runtime_mcp_session is None:
|
||||||
raise WorkspaceNotFoundError('MCP server not found')
|
raise WorkspaceNotFoundError('MCP server not found')
|
||||||
@@ -427,6 +440,7 @@ class MCPService:
|
|||||||
payload = dict(server_data)
|
payload = dict(server_data)
|
||||||
payload.pop('workspace_uuid', None)
|
payload.pop('workspace_uuid', None)
|
||||||
payload['workspace_uuid'] = execution_context.workspace_uuid
|
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(
|
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(
|
||||||
execution_context,
|
execution_context,
|
||||||
payload,
|
payload,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -48,6 +48,8 @@ from ..survey import manager as survey_module
|
|||||||
from ..skill import manager as skill_mgr
|
from ..skill import manager as skill_mgr
|
||||||
from ..workspace import service as workspace_service_module
|
from ..workspace import service as workspace_service_module
|
||||||
from ..workspace import collaboration as workspace_collaboration_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 ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus
|
from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus
|
||||||
|
|
||||||
@@ -128,6 +130,10 @@ class Application:
|
|||||||
|
|
||||||
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
|
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
|
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||||
|
|
||||||
http_ctrl: http_controller.HTTPController = None
|
http_ctrl: http_controller.HTTPController = None
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app
|
||||||
from ...utils import version, proxy
|
from ...utils import version, proxy, constants
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
from ...pipeline import aggregator as message_aggregator
|
from ...pipeline import aggregator as message_aggregator
|
||||||
from ...box import service as box_service
|
from ...box import service as box_service
|
||||||
@@ -39,7 +39,8 @@ from ...telemetry import telemetry as telemetry_module
|
|||||||
from ...survey import manager as survey_module
|
from ...survey import manager as survey_module
|
||||||
from ...workspace import service as workspace_service_module
|
from ...workspace import service as workspace_service_module
|
||||||
from ...workspace import collaboration as workspace_collaboration_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.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
from ...api.http.authz import WorkspaceRequiredError
|
from ...api.http.authz import WorkspaceRequiredError
|
||||||
|
|
||||||
@@ -50,6 +51,20 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
|
|
||||||
async def run(self, ap: app.Application):
|
async def run(self, ap: app.Application):
|
||||||
"""Build LangBot 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)
|
ap.task_mgr = taskmgr.AsyncTaskManager(ap)
|
||||||
|
|
||||||
discover = discover_engine.ComponentDiscoveryEngine(ap)
|
discover = discover_engine.ComponentDiscoveryEngine(ap)
|
||||||
@@ -107,16 +122,14 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
await storage_mgr_inst.initialize()
|
await storage_mgr_inst.initialize()
|
||||||
ap.storage_mgr = storage_mgr_inst
|
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
|
ap.persistence_mgr = persistence_mgr_inst
|
||||||
await persistence_mgr_inst.initialize()
|
await persistence_mgr_inst.initialize()
|
||||||
|
|
||||||
# The open-source Core is intentionally single-Workspace. A mutable
|
workspace_policy = deployment.workspace_policy
|
||||||
# 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_service_inst = workspace_service_module.WorkspaceService(
|
workspace_service_inst = workspace_service_module.WorkspaceService(
|
||||||
ap,
|
ap,
|
||||||
policy=workspace_policy,
|
policy=workspace_policy,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import copy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
import yaml
|
import yaml
|
||||||
@@ -12,6 +13,44 @@ from .. import stage, app
|
|||||||
from ..bootutils import config
|
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:
|
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||||
"""Apply environment variable overrides to data/config.yaml
|
"""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)
|
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
|
# Apply environment variable overrides to data/config.yaml
|
||||||
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
|
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.runtime.migration import MigrationContext
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
@@ -59,6 +60,16 @@ def _do_get_current(connection: Connection) -> str | None:
|
|||||||
return ctx.get_current_revision()
|
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:
|
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
|
||||||
"""Synchronous autogenerate — runs inside run_sync."""
|
"""Synchronous autogenerate — runs inside run_sync."""
|
||||||
cfg = _build_config(connection)
|
cfg = _build_config(connection)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import enum
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ from ..entity import persistence
|
|||||||
from ..core import app
|
from ..core import app
|
||||||
from ..utils import constants, importutil
|
from ..utils import constants, importutil
|
||||||
from . import databases, migrations
|
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(databases)
|
||||||
importutil.import_modules_in_pkg(migrations)
|
importutil.import_modules_in_pkg(migrations)
|
||||||
@@ -64,6 +66,15 @@ _PRE_WORKSPACE_ALEMBIC_REVISIONS = {
|
|||||||
}
|
}
|
||||||
_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
||||||
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
_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:
|
class PersistenceManager:
|
||||||
@@ -76,9 +87,12 @@ class PersistenceManager:
|
|||||||
|
|
||||||
meta: sqlalchemy.MetaData
|
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.ap = ap
|
||||||
self.meta = base.Base.metadata
|
self.meta = base.Base.metadata
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||||
@@ -89,7 +103,27 @@ class PersistenceManager:
|
|||||||
await self.db.initialize()
|
await self.db.initialize()
|
||||||
break
|
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()
|
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()
|
await self.create_tables()
|
||||||
|
|
||||||
@@ -125,16 +159,36 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||||
|
|
||||||
# Run Alembic migrations (new migration system)
|
if engine.dialect.name == 'postgresql':
|
||||||
await self._run_alembic_migrations()
|
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
|
if release_bootstrap:
|
||||||
# newer release. They were deliberately deferred before 0009 because
|
# Base.metadata represents the complete 0010 schema. An empty
|
||||||
# their Workspace/account FK targets did not exist yet; create them
|
# Cloud database has no legacy data to transform and must not
|
||||||
# now that the tenancy contract is in place.
|
# run 0009's OSS singleton Workspace bootstrap.
|
||||||
await self.create_tables()
|
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 def create_tables(self):
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
@@ -173,6 +227,37 @@ class PersistenceManager:
|
|||||||
|
|
||||||
await self._ensure_instance_uuid_metadata()
|
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:
|
def _enable_sqlite_foreign_keys(self) -> None:
|
||||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||||
engine = self.get_db_engine()
|
engine = self.get_db_engine()
|
||||||
@@ -215,6 +300,122 @@ class PersistenceManager:
|
|||||||
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
|
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):
|
async def write_space_model_providers(self):
|
||||||
if constants.edition != 'community':
|
if constants.edition != 'community':
|
||||||
# SaaS Workspace/provider linkage is explicit control-plane state;
|
# 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."""
|
"""Run Alembic-based migrations after legacy migrations complete."""
|
||||||
from . import alembic_runner
|
from . import alembic_runner
|
||||||
|
|
||||||
@@ -310,8 +511,8 @@ class PersistenceManager:
|
|||||||
# PostgreSQL has transactional DDL. SQLite has already crossed the
|
# PostgreSQL has transactional DDL. SQLite has already crossed the
|
||||||
# two destructive tenancy boundaries under verified backups; this
|
# two destructive tenancy boundaries under verified backups; this
|
||||||
# final call is a no-op today and applies future migrations.
|
# final call is a no-op today and applies future migrations.
|
||||||
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
await alembic_runner.run_alembic_upgrade(engine, target_revision)
|
||||||
self.ap.logger.info('Alembic migrations completed.')
|
self.ap.logger.info(f'Alembic migrations completed at {target_revision}.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
||||||
raise
|
raise
|
||||||
@@ -359,6 +560,9 @@ class PersistenceManager:
|
|||||||
await conn.commit()
|
await conn.commit()
|
||||||
return result
|
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:
|
def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
|
||||||
return self.db.get_engine()
|
return self.db.get_engine()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -32,13 +32,8 @@ from ....workspace.errors import WorkspaceError, WorkspaceInvariantError
|
|||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
from ....entity.persistence import mcp as persistence_mcp
|
from ....entity.persistence import mcp as persistence_mcp
|
||||||
from .mcp_stdio import (
|
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
|
||||||
BoxStdioSessionRuntime,
|
from .mcp_policy import require_stdio_mcp_enabled, stdio_mcp_enabled
|
||||||
MCPServerBoxConfig as MCPServerBoxConfig, # noqa: F401 - public re-export
|
|
||||||
MCPSessionErrorPhase,
|
|
||||||
_ColdStartRetry,
|
|
||||||
_get_default_memory_mb,
|
|
||||||
) # noqa: F401
|
|
||||||
|
|
||||||
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
||||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
||||||
@@ -367,6 +362,11 @@ class RuntimeMCPSession:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _init_stdio_python_server(self):
|
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():
|
if self._uses_box_stdio():
|
||||||
await self._box_stdio_runtime.initialize()
|
await self._box_stdio_runtime.initialize()
|
||||||
return
|
return
|
||||||
@@ -1502,6 +1502,12 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
|
|
||||||
for server in servers:
|
for server in servers:
|
||||||
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
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:
|
try:
|
||||||
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
|
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
|
||||||
execution_context = ExecutionContext(
|
execution_context = ExecutionContext(
|
||||||
@@ -1577,6 +1583,7 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
"""
|
"""
|
||||||
execution_context = await self._assert_execution_active(context)
|
execution_context = await self._assert_execution_active(context)
|
||||||
server_config = dict(server_config)
|
server_config = dict(server_config)
|
||||||
|
require_stdio_mcp_enabled(self.ap, server_config)
|
||||||
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
|
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
|
||||||
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
|
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
|
||||||
raise ValueError('MCP server configuration belongs to another Workspace')
|
raise ValueError('MCP server configuration belongs to another Workspace')
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -125,9 +125,22 @@ plugin:
|
|||||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||||
enable_marketplace: true
|
enable_marketplace: true
|
||||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
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:
|
binary_storage:
|
||||||
# Max bytes for a single plugin binary storage value
|
# Max bytes for a single plugin binary storage value
|
||||||
max_value_bytes: 10485760
|
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:
|
monitoring:
|
||||||
auto_cleanup:
|
auto_cleanup:
|
||||||
# Enable automatic cleanup of expired monitoring records
|
# Enable automatic cleanup of expired monitoring records
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.exc import IntegrityError
|
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 sqlalchemy import text
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
from langbot.pkg.entity.persistence.user import User
|
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 (
|
from langbot.pkg.persistence.alembic_runner import (
|
||||||
run_alembic_upgrade,
|
run_alembic_upgrade,
|
||||||
run_alembic_stamp,
|
run_alembic_stamp,
|
||||||
@@ -49,6 +54,44 @@ def _get_script_head() -> str:
|
|||||||
return ScriptDirectory.from_config(cfg).get_current_head()
|
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]
|
pytestmark = [pytest.mark.integration, pytest.mark.slow]
|
||||||
|
|
||||||
|
|
||||||
@@ -309,7 +352,7 @@ class TestPostgreSQLWorkspaceMigration:
|
|||||||
)
|
)
|
||||||
assert 'workspaces' not in tables_before_migration
|
assert 'workspaces' not in tables_before_migration
|
||||||
|
|
||||||
await manager._run_alembic_migrations()
|
await manager._initialize_managed_schema()
|
||||||
|
|
||||||
async with postgres_engine.connect() as conn:
|
async with postgres_engine.connect() as conn:
|
||||||
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
||||||
@@ -425,3 +468,278 @@ class TestPostgreSQLResourceTenancyMigration:
|
|||||||
),
|
),
|
||||||
{'workspace_uuid': second_workspace_uuid},
|
{'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)}'))
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
|||||||
|
|
||||||
await _manager(engine)._run_alembic_migrations()
|
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')
|
payloads = _manifest_payloads(tmp_path / 'migration-backups')
|
||||||
assert len(payloads) == 2
|
assert len(payloads) == 2
|
||||||
assert {
|
assert {
|
||||||
@@ -102,6 +102,6 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
|
|
||||||
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
|
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
|
||||||
await _manager(engine)._run_alembic_migrations()
|
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:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from langbot.pkg.entity.persistence.base import Base
|
|||||||
from langbot.pkg.entity.persistence.user import User
|
from langbot.pkg.entity.persistence.user import User
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
from langbot.pkg.persistence.alembic_runner import (
|
from langbot.pkg.persistence.alembic_runner import (
|
||||||
|
get_alembic_head,
|
||||||
get_alembic_current,
|
get_alembic_current,
|
||||||
run_alembic_downgrade,
|
run_alembic_downgrade,
|
||||||
run_alembic_stamp,
|
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['state'] == 'active'
|
||||||
assert execution_state['write_fenced'] in (False, 0)
|
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):
|
async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_engine):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.api.http.service.mcp import MCPService, redact_mcp_secrets, restore_mcp_secret_placeholders
|
||||||
from langbot.pkg.entity.persistence.mcp import MCPServer
|
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
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||||
|
|
||||||
|
|
||||||
@@ -351,6 +352,27 @@ class TestMCPServiceGetMCPServers:
|
|||||||
class TestMCPServiceCreateMCPServer:
|
class TestMCPServiceCreateMCPServer:
|
||||||
"""Tests for create_mcp_server method."""
|
"""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):
|
async def test_create_mcp_server_max_extensions_reached_raises(self):
|
||||||
"""Raises ValueError when max_extensions limit reached."""
|
"""Raises ValueError when max_extensions limit reached."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -887,6 +909,24 @@ class TestMCPServiceDeleteMCPServer:
|
|||||||
class TestMCPServiceTestMCPServer:
|
class TestMCPServiceTestMCPServer:
|
||||||
"""Tests for test_mcp_server method."""
|
"""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):
|
async def test_test_mcp_server_existing_server(self):
|
||||||
"""Tests existing MCP server connection."""
|
"""Tests existing MCP server connection."""
|
||||||
# Setup
|
# Setup
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -259,6 +259,60 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
assert result['system']['enable'] is False
|
assert result['system']['enable'] is False
|
||||||
assert result['concurrency']['pipeline'] == 10
|
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):
|
def test_webhook_prefix_override(self):
|
||||||
"""Test overriding webhook_prefix via environment variable."""
|
"""Test overriding webhook_prefix via environment variable."""
|
||||||
load_config = get_load_config_module()
|
load_config = get_load_config_module()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -154,6 +154,7 @@ def mcp_module():
|
|||||||
def _make_ap():
|
def _make_ap():
|
||||||
ap = Mock()
|
ap = Mock()
|
||||||
ap.logger = Mock()
|
ap.logger = Mock()
|
||||||
|
ap.instance_config = SimpleNamespace(data={'mcp': {'stdio': {'enabled': True}}})
|
||||||
ap.workspace_service = Mock()
|
ap.workspace_service = Mock()
|
||||||
ap.workspace_service.get_execution_binding = AsyncMock(
|
ap.workspace_service.get_execution_binding = AsyncMock(
|
||||||
return_value=SimpleNamespace(
|
return_value=SimpleNamespace(
|
||||||
@@ -948,6 +949,31 @@ class TestBoxConfigParsing:
|
|||||||
assert s.box_config.host_path_mode == 'ro'
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_module, tmp_path):
|
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']
|
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||||
|
|||||||
@@ -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 == {}
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
import { CustomApiError } from '@/app/infra/entities/common';
|
||||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||||
|
import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy';
|
||||||
|
|
||||||
function StatusDisplay({
|
function StatusDisplay({
|
||||||
testing,
|
testing,
|
||||||
@@ -566,11 +567,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
hint: boxHint,
|
hint: boxHint,
|
||||||
reason: boxReason,
|
reason: boxReason,
|
||||||
} = useBoxStatus();
|
} = useBoxStatus();
|
||||||
|
const { enabled: mcpStdioEnabled } = useMCPStdioPolicy();
|
||||||
// stdio mode requires the Box sandbox at runtime. If the user picks
|
// stdio mode requires the Box sandbox at runtime. If the user picks
|
||||||
// stdio while Box is disabled / unreachable, the server would refuse
|
// stdio while Box is disabled / unreachable, the server would refuse
|
||||||
// to start anyway — block creation upfront so they aren't surprised
|
// to start anyway — block creation upfront so they aren't surprised
|
||||||
// by an immediate "Connection failed" on the detail page.
|
// 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;
|
const { isDirty } = form.formState;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -578,8 +583,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
}, [isDirty, onDirtyChange]);
|
}, [isDirty, onDirtyChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSaveBlockedChange?.(stdioBlockedByBox);
|
onSaveBlockedChange?.(stdioBlocked);
|
||||||
}, [stdioBlockedByBox, onSaveBlockedChange]);
|
}, [stdioBlocked, onSaveBlockedChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onTestingChange?.(mcpTesting);
|
onTestingChange?.(mcpTesting);
|
||||||
@@ -595,10 +600,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
testMcp: () => testMcp(),
|
testMcp: () => testMcp(),
|
||||||
isTesting: mcpTesting,
|
isTesting: mcpTesting,
|
||||||
}),
|
}),
|
||||||
// testMcp now reads everything via form.getValues(), so it does not need
|
// Form values are read through form.getValues(); policy and runtime health
|
||||||
// the latest stdioArgs/extraArgs closure — but keep mcpTesting so the
|
// remain closure values and must refresh the imperative handler.
|
||||||
// exposed isTesting flag stays accurate.
|
[mcpTesting, mcpStdioEnabled, boxAvailable],
|
||||||
[mcpTesting],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -760,6 +764,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||||
// Belt-and-suspenders: even though the Save button is disabled when
|
// Belt-and-suspenders: even though the Save button is disabled when
|
||||||
// stdio is unselectable, intercept programmatic submits too.
|
// stdio is unselectable, intercept programmatic submits too.
|
||||||
|
if (value.mode === 'stdio' && !mcpStdioEnabled) {
|
||||||
|
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (value.mode === 'stdio' && !boxAvailable) {
|
if (value.mode === 'stdio' && !boxAvailable) {
|
||||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||||
return;
|
return;
|
||||||
@@ -829,6 +837,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const mode = form.getValues('mode');
|
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
|
// Read every field via form.getValues() rather than the captured
|
||||||
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
|
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
|
||||||
// imperative handle (formRef.current.testMcp()) whose closure is only
|
// imperative handle (formRef.current.testMcp()) whose closure is only
|
||||||
@@ -1037,13 +1055,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
|
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
|
||||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
<SelectItem
|
||||||
|
value="stdio"
|
||||||
|
disabled={!mcpStdioEnabled || !boxAvailable}
|
||||||
|
>
|
||||||
{t('mcp.local')}
|
{t('mcp.local')}
|
||||||
{!boxAvailable && (
|
{!mcpStdioEnabled ? (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({t('mcp.disabledByPolicy')})
|
||||||
|
</span>
|
||||||
|
) : !boxAvailable ? (
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
({t('mcp.boxRequired')})
|
({t('mcp.boxRequired')})
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1052,6 +1077,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
? t('mcp.localModeDescription')
|
? t('mcp.localModeDescription')
|
||||||
: t('mcp.remoteModeDescription')}
|
: t('mcp.remoteModeDescription')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
{stdioBlockedByPolicy && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t('mcp.stdioDisabledByPolicy')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{stdioBlockedByBox && (
|
{stdioBlockedByBox && (
|
||||||
<BoxUnavailableNotice
|
<BoxUnavailableNotice
|
||||||
hint={boxHint}
|
hint={boxHint}
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ export interface ApiRespSystemInfo {
|
|||||||
debug: boolean;
|
debug: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
edition: string;
|
edition: string;
|
||||||
|
/** Independent instance-level gate for local stdio MCP transports. */
|
||||||
|
mcp_stdio_enabled: boolean;
|
||||||
cloud_service_url: string;
|
cloud_service_url: string;
|
||||||
enable_marketplace: boolean;
|
enable_marketplace: boolean;
|
||||||
allow_modify_login_info: boolean;
|
allow_modify_login_info: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the instance-level stdio MCP gate independently of Box health.
|
||||||
|
*
|
||||||
|
* The hook fails closed while loading or when System Info is unavailable.
|
||||||
|
* This is only a WebUI guard; the backend loader enforces the same gate at
|
||||||
|
* the final transport boundary.
|
||||||
|
*/
|
||||||
|
export function useMCPStdioPolicy() {
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export const systemInfo: ApiRespSystemInfo = {
|
|||||||
debug: false,
|
debug: false,
|
||||||
version: '',
|
version: '',
|
||||||
edition: 'community',
|
edition: 'community',
|
||||||
|
mcp_stdio_enabled: false,
|
||||||
enable_marketplace: true,
|
enable_marketplace: true,
|
||||||
cloud_service_url: '',
|
cloud_service_url: '',
|
||||||
allow_modify_login_info: true,
|
allow_modify_login_info: true,
|
||||||
|
|||||||
@@ -827,6 +827,9 @@ const enUS = {
|
|||||||
boxStdioRefusedSuggestion:
|
boxStdioRefusedSuggestion:
|
||||||
'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.',
|
'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.',
|
||||||
boxRequired: 'requires Box',
|
boxRequired: 'requires Box',
|
||||||
|
disabledByPolicy: 'disabled by policy',
|
||||||
|
stdioDisabledByPolicy:
|
||||||
|
'Stdio MCP is disabled for this deployment. Use a remote MCP server instead.',
|
||||||
stdioBlockedByBoxToast:
|
stdioBlockedByBoxToast:
|
||||||
'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.',
|
'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.',
|
||||||
toolsFound: 'tools',
|
toolsFound: 'tools',
|
||||||
|
|||||||
@@ -840,6 +840,9 @@ const esES = {
|
|||||||
boxStdioRefusedSuggestion:
|
boxStdioRefusedSuggestion:
|
||||||
'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.',
|
'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',
|
boxRequired: 'requiere Box',
|
||||||
|
disabledByPolicy: 'desactivado por la política',
|
||||||
|
stdioDisabledByPolicy:
|
||||||
|
'Stdio MCP está deshabilitado en este despliegue. Use un servidor MCP remoto.',
|
||||||
stdioBlockedByBoxToast:
|
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.',
|
'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',
|
toolsFound: 'herramientas',
|
||||||
|
|||||||
@@ -833,6 +833,9 @@ const jaJP = {
|
|||||||
boxStdioRefusedSuggestion:
|
boxStdioRefusedSuggestion:
|
||||||
'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。',
|
'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。',
|
||||||
boxRequired: 'Box が必要',
|
boxRequired: 'Box が必要',
|
||||||
|
disabledByPolicy: 'ポリシーにより無効',
|
||||||
|
stdioDisabledByPolicy:
|
||||||
|
'このデプロイでは Stdio MCP が無効です。リモート MCP サーバーを使用してください。',
|
||||||
stdioBlockedByBoxToast:
|
stdioBlockedByBoxToast:
|
||||||
'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。',
|
'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。',
|
||||||
toolsFound: '個のツール',
|
toolsFound: '個のツール',
|
||||||
|
|||||||
@@ -837,6 +837,9 @@ const ruRU = {
|
|||||||
boxStdioRefusedSuggestion:
|
boxStdioRefusedSuggestion:
|
||||||
'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.',
|
'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.',
|
||||||
boxRequired: 'требуется Box',
|
boxRequired: 'требуется Box',
|
||||||
|
disabledByPolicy: 'отключено политикой',
|
||||||
|
stdioDisabledByPolicy:
|
||||||
|
'Stdio MCP отключён в этом развёртывании. Используйте удалённый MCP-сервер.',
|
||||||
stdioBlockedByBoxToast:
|
stdioBlockedByBoxToast:
|
||||||
'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.',
|
'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.',
|
||||||
toolsFound: 'инструментов',
|
toolsFound: 'инструментов',
|
||||||
|
|||||||
@@ -815,6 +815,9 @@ const thTH = {
|
|||||||
boxStdioRefusedSuggestion:
|
boxStdioRefusedSuggestion:
|
||||||
'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse',
|
'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse',
|
||||||
boxRequired: 'ต้องใช้ Box',
|
boxRequired: 'ต้องใช้ Box',
|
||||||
|
disabledByPolicy: 'ถูกปิดใช้งานโดยนโยบาย',
|
||||||
|
stdioDisabledByPolicy:
|
||||||
|
'การติดตั้งใช้งานนี้ปิด Stdio MCP อยู่ โปรดใช้เซิร์ฟเวอร์ MCP แบบระยะไกล',
|
||||||
stdioBlockedByBoxToast:
|
stdioBlockedByBoxToast:
|
||||||
'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse',
|
'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse',
|
||||||
toolsFound: 'เครื่องมือ',
|
toolsFound: 'เครื่องมือ',
|
||||||
|
|||||||
@@ -830,6 +830,9 @@ const viVN = {
|
|||||||
boxStdioRefusedSuggestion:
|
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.',
|
'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',
|
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:
|
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.',
|
'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ụ',
|
toolsFound: 'công cụ',
|
||||||
|
|||||||
@@ -793,6 +793,8 @@ const zhHans = {
|
|||||||
boxStdioRefusedSuggestion:
|
boxStdioRefusedSuggestion:
|
||||||
'请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。',
|
'请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。',
|
||||||
boxRequired: '需要 Box',
|
boxRequired: '需要 Box',
|
||||||
|
disabledByPolicy: '已被策略禁用',
|
||||||
|
stdioDisabledByPolicy: '此部署已禁用 Stdio MCP,请改用远程 MCP 服务器。',
|
||||||
stdioBlockedByBoxToast:
|
stdioBlockedByBoxToast:
|
||||||
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
|
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
|
||||||
toolsFound: '个工具',
|
toolsFound: '个工具',
|
||||||
|
|||||||
@@ -791,6 +791,8 @@ const zhHant = {
|
|||||||
boxStdioRefusedSuggestion:
|
boxStdioRefusedSuggestion:
|
||||||
'請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。',
|
'請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。',
|
||||||
boxRequired: '需要 Box',
|
boxRequired: '需要 Box',
|
||||||
|
disabledByPolicy: '已被策略停用',
|
||||||
|
stdioDisabledByPolicy: '此部署已停用 Stdio MCP,請改用遠端 MCP 伺服器。',
|
||||||
stdioBlockedByBoxToast:
|
stdioBlockedByBoxToast:
|
||||||
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
|
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
|
||||||
toolsFound: '個工具',
|
toolsFound: '個工具',
|
||||||
|
|||||||
Reference in New Issue
Block a user