feat(tenancy): establish cloud isolation foundations

This commit is contained in:
Junyan Qin
2026-07-19 22:39:58 +08:00
parent 59b1570ead
commit 41772920ef
41 changed files with 1984 additions and 43 deletions
+2
View File
@@ -69,6 +69,7 @@ class ExecutionContext:
pipeline_uuid: str | None = None
query_uuid: str | None = None
trigger_principal: PrincipalContext | None = None
entitlement_revision: int = 0
@classmethod
def from_request(
@@ -89,4 +90,5 @@ class ExecutionContext:
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
trigger_principal=ctx.principal,
entitlement_revision=ctx.entitlement_revision,
)
@@ -12,6 +12,7 @@ from quart.typing import RouteCallable
from ....utils import constants
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
from ....workspace.errors import WorkspaceNotFoundError
from ....cloud.entitlements import EntitlementUnavailableError
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
@@ -244,6 +245,10 @@ class RouterGroup(abc.ABC):
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
entitlement_revision = await self._resolve_entitlement_revision(
access.execution.instance_uuid,
access.workspace.uuid,
)
request_context = RequestContext(
instance_uuid=access.execution.instance_uuid,
placement_generation=access.execution.placement_generation,
@@ -260,6 +265,7 @@ class RouterGroup(abc.ABC):
permissions=permissions_for_role(access.membership.role),
membership_revision=access.membership.projection_revision,
),
entitlement_revision=entitlement_revision,
)
quart.g.request_context = request_context
quart.g.workspace_membership = access.membership
@@ -272,6 +278,10 @@ class RouterGroup(abc.ABC):
if inspect.isawaitable(authenticated):
identity = await authenticated
if identity is not None:
entitlement_revision = await self._resolve_entitlement_revision(
identity.instance_uuid,
identity.workspace_uuid,
)
request_context = RequestContext(
instance_uuid=identity.instance_uuid,
placement_generation=identity.placement_generation,
@@ -287,6 +297,7 @@ class RouterGroup(abc.ABC):
role=None,
permissions=identity.permissions,
),
entitlement_revision=entitlement_revision,
)
quart.g.request_context = request_context
return request_context
@@ -316,6 +327,18 @@ class RouterGroup(abc.ABC):
quart.g.request_context = request_context
return request_context
async def _resolve_entitlement_revision(self, instance_uuid: str, workspace_uuid: str) -> int:
deployment = getattr(self.ap, 'deployment', None)
if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False):
return 0
resolver = getattr(self.ap, 'entitlement_resolver', None)
if resolver is None:
raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
if instance_uuid != resolver.instance_uuid:
raise EntitlementUnavailableError('Workspace entitlement targets another LangBot instance')
snapshot = await resolver.resolve(workspace_uuid)
return snapshot.entitlement_revision
@staticmethod
def _inject_handler_context(
handler: RouteCallable,
@@ -339,6 +362,8 @@ class RouterGroup(abc.ABC):
return self.http_status(404, 'resource_not_found', 'Resource not found')
if isinstance(error, MembershipPermissionError):
return self.http_status(403, error.code, str(error))
if isinstance(error, EntitlementUnavailableError):
return self.http_status(403, 'entitlement_unavailable', str(error))
request_id = self.request_id()
logger = getattr(self.ap, 'logger', self.quart_app.logger)
logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
@@ -5,6 +5,7 @@ from urllib.parse import unquote
from ....authz import Permission
from ....context import RequestContext
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
from ... import group
@@ -31,6 +32,8 @@ class MCPRouterGroup(group.RouterGroup):
data = await quart.request.json
try:
server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data)
except MCPStdioDisabledError as exc:
return self.http_status(403, exc.code, str(exc))
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'uuid': server_uuid})
@@ -63,6 +66,8 @@ class MCPRouterGroup(group.RouterGroup):
data = await quart.request.json
try:
await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data)
except MCPStdioDisabledError as exc:
return self.http_status(403, exc.code, str(exc))
except ValueError as exc:
return self.http_status(400, -1, str(exc))
else:
@@ -79,11 +84,14 @@ class MCPRouterGroup(group.RouterGroup):
"""测试MCP服务器连接"""
server_name = unquote(server_name)
server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(
request_context,
server_name=server_name,
server_data=server_data,
)
try:
task_id = await self.ap.mcp_service.test_mcp_server(
request_context,
server_name=server_name,
server_data=server_data,
)
except MCPStdioDisabledError as exc:
return self.http_status(403, exc.code, str(exc))
return self.success(data={'task_id': task_id})
@self.route(
@@ -8,6 +8,7 @@ from .....utils import constants
from .....entity.persistence.metadata import WorkspaceMetadata
from ...authz import Permission
from ...context import RequestContext
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
@group.group_class('system', '/api/v1/system')
@@ -70,6 +71,9 @@ class SystemRouterGroup(group.RouterGroup):
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
'disable_models_service', False
),
# Exposed independently of Box status so the WebUI cannot
# infer stdio permission from sandbox availability.
'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'outbound_ips': outbound_ips,
'wizard_status': wizard_status,
+15 -1
View File
@@ -11,6 +11,7 @@ from ....core import app, taskmgr
from ....entity.persistence import mcp as persistence_mcp
from ....entity.persistence import plugin as persistence_plugin
from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession
from ....provider.tools.loaders.mcp_policy import require_stdio_mcp_enabled
from ....workspace.errors import WorkspaceNotFoundError
from ..context import ExecutionContext
from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders
@@ -207,6 +208,10 @@ class MCPService:
execution_context = await self._execution_context(context)
workspace_uuid = execution_context.workspace_uuid
# This gate is independent of Box availability. Cloud v2 disables
# stdio MCP even though Box Runtime itself remains available.
require_stdio_mcp_enabled(self.ap, server_data)
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_extensions = limitation.get('max_extensions', -1)
if max_extensions >= 0:
@@ -315,6 +320,13 @@ class MCPService:
if duplicate is not None and duplicate['uuid'] != server_uuid:
raise ValueError(f'MCP server already exists: {payload["name"]}')
effective_server = {**old_server, **payload}
# Existing disabled rows remain readable/deletable. Switching away
# from stdio or explicitly disabling one is also allowed, but an
# update may never leave a disabled stdio server enabled.
if bool(effective_server.get('enable', True)):
require_stdio_mcp_enabled(self.ap, effective_server)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_mcp.MCPServer)
@@ -405,7 +417,8 @@ class MCPService:
ctx = taskmgr.TaskContext.new()
if server_name != '_':
await self._require_server(execution_context, server_name)
_, persisted_server = await self._require_server(execution_context, server_name)
require_stdio_mcp_enabled(self.ap, persisted_server)
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
if runtime_mcp_session is None:
raise WorkspaceNotFoundError('MCP server not found')
@@ -427,6 +440,7 @@ class MCPService:
payload = dict(server_data)
payload.pop('workspace_uuid', None)
payload['workspace_uuid'] = execution_context.workspace_uuid
require_stdio_mcp_enabled(self.ap, payload)
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(
execution_context,
payload,
+27
View File
@@ -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',
]
+161
View File
@@ -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
+142
View File
@@ -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)
+6
View File
@@ -48,6 +48,8 @@ from ..survey import manager as survey_module
from ..skill import manager as skill_mgr
from ..workspace import service as workspace_service_module
from ..workspace import collaboration as workspace_collaboration_module
from ..cloud import bootstrap as cloud_bootstrap_module
from ..cloud import entitlements as cloud_entitlements_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus
@@ -128,6 +130,10 @@ class Application:
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
vector_db_mgr: vectordb_mgr.VectorDBManager = None
http_ctrl: http_controller.HTTPController = None
+22 -9
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
from .. import stage, app
from ...utils import version, proxy
from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator
from ...box import service as box_service
@@ -41,7 +41,8 @@ from ...telemetry import telemetry as telemetry_module
from ...survey import manager as survey_module
from ...workspace import service as workspace_service_module
from ...workspace import collaboration as workspace_collaboration_module
from ...workspace import policy as workspace_policy_module
from ...cloud import bootstrap as cloud_bootstrap
from ...cloud.entitlements import EntitlementResolver
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ...api.http.authz import WorkspaceRequiredError
@@ -52,6 +53,20 @@ class BuildAppStage(stage.BootingStage):
async def run(self, ap: app.Application):
"""Build LangBot application"""
# Multi-Workspace mode is selected only by an installed closed
# bootstrap that returns a verified Manifest receipt. Mutable values
# such as system.edition are intentionally absent from this boundary.
deployment = await cloud_bootstrap.resolve_deployment(
instance_uuid=constants.instance_id,
instance_config=ap.instance_config.data,
)
ap.deployment = deployment
ap.entitlement_resolver = (
EntitlementResolver(constants.instance_id, deployment.entitlement_provider)
if deployment.multi_workspace_enabled
else None
)
ap.task_mgr = taskmgr.AsyncTaskManager(ap)
discover = discover_engine.ComponentDiscoveryEngine(ap)
@@ -109,16 +124,14 @@ class BuildAppStage(stage.BootingStage):
await storage_mgr_inst.initialize()
ap.storage_mgr = storage_mgr_inst
persistence_mgr_inst = persistencemgr.PersistenceManager(ap)
persistence_mgr_inst = persistencemgr.PersistenceManager(
ap,
mode=persistencemgr.PersistenceMode(deployment.persistence_mode),
)
ap.persistence_mgr = persistence_mgr_inst
await persistence_mgr_inst.initialize()
# The open-source Core is intentionally single-Workspace. A mutable
# config value such as ``system.edition`` is product metadata, not a
# trust credential, and must never activate SaaS routing. The closed
# Cloud bootstrap will install its verified policy only after checking
# a signed InstanceManifest; until that bootstrap exists, fail closed.
workspace_policy = workspace_policy_module.open_core_workspace_policy()
workspace_policy = deployment.workspace_policy
workspace_service_inst = workspace_service_module.WorkspaceService(
ap,
policy=workspace_policy,
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import copy
from typing import Any
from langbot.pkg.utils import constants
import yaml
@@ -12,6 +13,44 @@ from .. import stage, app
from ..bootutils import config
_RUNTIME_POLICY_DEFAULTS = {
'plugin': {
'worker': {
'max_cpus': 1.0,
'max_memory_mb': 512,
'max_pids': 128,
'max_open_files': 256,
'max_file_size_mb': 512,
}
},
'mcp': {'stdio': {'enabled': True}},
}
def _complete_runtime_policy_defaults(cfg: dict) -> dict:
"""Backfill typed security-policy leaves before applying env overrides.
The historic config loader intentionally does not deep-complete the whole
template. These fields are different: their native env overrides must
retain boolean/numeric types on upgraded instances, so their defaults must
exist before ``PLUGIN__...`` and ``MCP__...`` are parsed.
"""
def merge(target: dict, defaults: dict, path: tuple[str, ...] = ()) -> None:
for key, default in defaults.items():
if key not in target:
target[key] = copy.deepcopy(default)
continue
if isinstance(default, dict):
if not isinstance(target[key], dict):
dotted_path = '.'.join((*path, key))
raise ValueError(f'{dotted_path} must be a mapping')
merge(target[key], default, (*path, key))
merge(cfg, _RUNTIME_POLICY_DEFAULTS)
return cfg
def _apply_env_overrides_to_config(cfg: dict) -> dict:
"""Apply environment variable overrides to data/config.yaml
@@ -150,6 +189,10 @@ class LoadConfigStage(stage.BootingStage):
ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False)
# Deep-complete only typed execution-policy fields. This keeps native
# env coercion reliable for existing data/config.yaml files.
ap.instance_config.data = _complete_runtime_policy_defaults(ap.instance_config.data)
# Apply environment variable overrides to data/config.yaml
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
@@ -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 import command
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -59,6 +60,16 @@ def _do_get_current(connection: Connection) -> str | None:
return ctx.get_current_revision()
def get_alembic_head() -> str:
"""Resolve the single release head without opening a database connection."""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
head = ScriptDirectory.from_config(cfg).get_current_head()
if head is None:
raise RuntimeError('Alembic has no migration head')
return head
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
"""Synchronous autogenerate — runs inside run_sync."""
cfg = _build_config(connection)
+216 -12
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import datetime
import enum
import sqlite3
import typing
@@ -15,6 +16,7 @@ from ..entity import persistence
from ..core import app
from ..utils import constants, importutil
from . import databases, migrations
from .tenant_uow import TENANT_POLICY_NAME, TENANT_SETTING, TENANT_TABLE_COLUMNS, TenantUnitOfWork
importutil.import_modules_in_pkg(databases)
importutil.import_modules_in_pkg(migrations)
@@ -64,6 +66,15 @@ _PRE_WORKSPACE_ALEMBIC_REVISIONS = {
}
_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
class PersistenceMode(enum.StrEnum):
"""Trusted persistence startup mode selected by the process entrypoint."""
OSS_COMPAT = 'oss_compat'
CLOUD_RUNTIME = 'cloud_runtime'
RELEASE_MIGRATION = 'release_migration'
class PersistenceManager:
@@ -76,9 +87,12 @@ class PersistenceManager:
meta: sqlalchemy.MetaData
def __init__(self, ap: app.Application):
def __init__(self, ap: app.Application, *, mode: PersistenceMode = PersistenceMode.OSS_COMPAT):
if not isinstance(mode, PersistenceMode):
raise TypeError('PersistenceManager mode must be a trusted PersistenceMode value')
self.ap = ap
self.meta = base.Base.metadata
self.mode = mode
async def initialize(self):
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
@@ -89,7 +103,27 @@ class PersistenceManager:
await self.db.initialize()
break
engine = self.get_db_engine()
if self.mode in {PersistenceMode.CLOUD_RUNTIME, PersistenceMode.RELEASE_MIGRATION}:
if engine.dialect.name != 'postgresql':
raise RuntimeError(f'{self.mode.value} persistence mode requires PostgreSQL')
if self.mode == PersistenceMode.CLOUD_RUNTIME:
await self._validate_cloud_runtime()
return
self._enable_sqlite_foreign_keys()
await self._initialize_managed_schema()
if self.mode == PersistenceMode.OSS_COMPAT:
await self.write_space_model_providers()
async def _initialize_managed_schema(self) -> None:
"""Create or migrate schema only in OSS and release processes."""
from . import alembic_runner
engine = self.get_db_engine()
release_bootstrap = self.mode == PersistenceMode.RELEASE_MIGRATION and await self._is_empty_schema()
await self.create_tables()
@@ -125,16 +159,36 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
# Run Alembic migrations (new migration system)
await self._run_alembic_migrations()
if engine.dialect.name == 'postgresql':
current_revision = await alembic_runner.get_alembic_current(engine)
head_revision = alembic_runner.get_alembic_head()
# A legacy database may not contain tenant tables introduced by a
# newer release. They were deliberately deferred before 0009 because
# their Workspace/account FK targets did not exist yet; create them
# now that the tenancy contract is in place.
await self.create_tables()
if release_bootstrap:
# Base.metadata represents the complete 0010 schema. An empty
# Cloud database has no legacy data to transform and must not
# run 0009's OSS singleton Workspace bootstrap.
if current_revision is not None:
raise RuntimeError('Empty PostgreSQL release bootstrap unexpectedly has an Alembic revision')
await alembic_runner.run_alembic_stamp(engine, _RESOURCE_SCOPE_ALEMBIC_REVISION)
elif current_revision != head_revision:
# A legacy database may not contain tenant tables introduced
# by a newer release. Upgrade the account/resource contract,
# then create deferred tables before the RLS migration runs.
await self._run_alembic_migrations(_RESOURCE_SCOPE_ALEMBIC_REVISION)
await self.create_tables()
await self.write_space_model_providers()
await self._run_alembic_migrations()
await self._validate_postgres_tenant_schema(validate_runtime_role=False)
if self.mode == PersistenceMode.OSS_COMPAT:
await self._install_oss_postgres_tenant_scope()
else:
await self._run_alembic_migrations()
# SQLite keeps the historical post-migration create_all pass. New
# tenant tables are deferred until the Workspace/account schema is
# compatible with their foreign keys.
await self.create_tables()
async def create_tables(self):
async with self.get_db_engine().connect() as conn:
@@ -173,6 +227,37 @@ class PersistenceManager:
await self._ensure_instance_uuid_metadata()
async def _is_empty_schema(self) -> bool:
async with self.get_db_engine().connect() as conn:
table_names = await conn.run_sync(lambda sync_conn: set(sqlalchemy.inspect(sync_conn).get_table_names()))
return not table_names
async def _install_oss_postgres_tenant_scope(self) -> None:
"""Default every OSS PostgreSQL transaction to its singleton Workspace."""
if getattr(self, '_oss_tenant_scope_listener_installed', False):
return
async with self.get_db_engine().connect() as conn:
workspace_uuid = await conn.scalar(
sqlalchemy.select(metadata.Metadata.value).where(
metadata.Metadata.key == _OSS_WORKSPACE_METADATA_KEY,
)
)
if not isinstance(workspace_uuid, str) or not workspace_uuid.strip():
raise RuntimeError(
'PostgreSQL OSS mode requires exactly one local Workspace recorded before tenant RLS is enabled'
)
workspace_uuid = workspace_uuid.strip()
def set_oss_tenant_scope(conn: sqlalchemy.Connection) -> None:
conn.execute(
sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"),
{'workspace_uuid': workspace_uuid},
)
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
self._oss_tenant_scope_listener_installed = True
def _enable_sqlite_foreign_keys(self) -> None:
"""Enable SQLite FK enforcement for every pooled runtime connection."""
engine = self.get_db_engine()
@@ -215,6 +300,122 @@ class PersistenceManager:
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
)
async def _validate_cloud_runtime(self) -> None:
"""Validate a release-prepared schema without performing any DDL."""
from . import alembic_runner
engine = self.get_db_engine()
current_revision = await alembic_runner.get_alembic_current(engine)
head_revision = alembic_runner.get_alembic_head()
if current_revision != head_revision:
raise RuntimeError(
f'Cloud runtime database schema is not at the release head: {current_revision!r} != {head_revision!r}'
)
runtime_instance_uuid = constants.instance_id.strip()
if not runtime_instance_uuid:
raise RuntimeError('LangBot instance UUID is empty before Cloud persistence validation')
async with engine.connect() as conn:
persisted_instance_uuid = await conn.scalar(
sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid')
)
if persisted_instance_uuid is None:
raise RuntimeError("Cloud runtime database is missing metadata['instance_uuid']")
if persisted_instance_uuid != runtime_instance_uuid:
raise RuntimeError(
'LangBot instance UUID does not match the value bound to this database: '
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
)
await self._validate_postgres_tenant_schema(validate_runtime_role=True)
async def _validate_postgres_tenant_schema(self, *, validate_runtime_role: bool) -> None:
"""Fail closed when PostgreSQL cannot enforce the tenant contract."""
engine = self.get_db_engine()
if engine.dialect.name != 'postgresql':
raise RuntimeError('PostgreSQL tenant schema validation requires PostgreSQL')
table_query = sqlalchemy.text(
"""
SELECT
c.relname AS table_name,
c.relrowsecurity AS rls_enabled,
c.relforcerowsecurity AS rls_forced,
pg_get_userbyid(c.relowner) = current_user AS owned_by_runtime,
EXISTS (
SELECT 1
FROM pg_policy p
WHERE p.polrelid = c.oid
AND p.polname = :policy_name
AND p.polcmd = '*'
AND p.polpermissive
AND p.polqual IS NOT NULL
AND p.polwithcheck IS NOT NULL
) AS policy_valid
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
AND c.relkind IN ('r', 'p')
AND c.relname IN :table_names
"""
).bindparams(sqlalchemy.bindparam('table_names', expanding=True))
async with engine.connect() as conn:
if validate_runtime_role:
role = (
(
await conn.execute(
sqlalchemy.text(
"""
SELECT rolsuper, rolbypassrls
FROM pg_roles
WHERE rolname = current_user
"""
)
)
)
.mappings()
.one_or_none()
)
if role is None:
raise RuntimeError('Cloud runtime PostgreSQL role could not be inspected')
if role['rolsuper']:
raise RuntimeError('Cloud runtime PostgreSQL role must not be a superuser')
if role['rolbypassrls']:
raise RuntimeError('Cloud runtime PostgreSQL role must not have BYPASSRLS')
rows = (
(
await conn.execute(
table_query,
{
'policy_name': TENANT_POLICY_NAME,
'table_names': tuple(TENANT_TABLE_COLUMNS),
},
)
)
.mappings()
.all()
)
by_table = {row['table_name']: row for row in rows}
missing_tables = set(TENANT_TABLE_COLUMNS) - set(by_table)
if missing_tables:
raise RuntimeError(f'PostgreSQL tenant tables are missing: {sorted(missing_tables)!r}')
invalid_rls = sorted(
table_name
for table_name, row in by_table.items()
if not row['rls_enabled'] or not row['rls_forced'] or not row['policy_valid']
)
if invalid_rls:
raise RuntimeError(f'PostgreSQL tenant RLS contract is incomplete for tables: {invalid_rls!r}')
if validate_runtime_role:
owned_tables = sorted(table_name for table_name, row in by_table.items() if row['owned_by_runtime'])
if owned_tables:
raise RuntimeError(f'Cloud runtime PostgreSQL role must not own tenant tables: {owned_tables!r}')
async def write_space_model_providers(self):
if constants.edition != 'community':
# SaaS Workspace/provider linkage is explicit control-plane state;
@@ -277,7 +478,7 @@ class PersistenceManager:
# =================================
async def _run_alembic_migrations(self):
async def _run_alembic_migrations(self, target_revision: str = 'head'):
"""Run Alembic-based migrations after legacy migrations complete."""
from . import alembic_runner
@@ -310,8 +511,8 @@ class PersistenceManager:
# PostgreSQL has transactional DDL. SQLite has already crossed the
# two destructive tenancy boundaries under verified backups; this
# final call is a no-op today and applies future migrations.
await alembic_runner.run_alembic_upgrade(engine, 'head')
self.ap.logger.info('Alembic migrations completed.')
await alembic_runner.run_alembic_upgrade(engine, target_revision)
self.ap.logger.info(f'Alembic migrations completed at {target_revision}.')
except Exception as e:
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
raise
@@ -359,6 +560,9 @@ class PersistenceManager:
await conn.commit()
return result
def tenant_uow(self, workspace_uuid: str) -> TenantUnitOfWork:
return TenantUnitOfWork(self.get_db_engine(), workspace_uuid)
def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
return self.db.get_engine()
+113
View File
@@ -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()
@@ -30,6 +30,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
from .mcp_policy import require_stdio_mcp_enabled, stdio_mcp_enabled
# Synthesized LLM tools for MCP resources (not from server tools/list).
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
@@ -350,6 +351,11 @@ class RuntimeMCPSession:
)
async def _init_stdio_python_server(self):
# Final transport gate: this must run before both the Box branch and
# the backwards-compatible host-stdio branch. Service/UI checks are
# usability guards; this is the execution security boundary.
require_stdio_mcp_enabled(self.ap, self.server_config)
if self._uses_box_stdio():
await self._box_stdio_runtime.initialize()
return
@@ -1436,6 +1442,12 @@ class MCPLoader(loader.ToolLoader):
for server in servers:
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
if config.get('mode') == 'stdio' and not stdio_mcp_enabled(self.ap):
self.ap.logger.info(
f'Skipping disabled stdio MCP server {server.uuid}; '
'the persisted configuration is retained but no process is launched'
)
continue
try:
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
execution_context = ExecutionContext(
@@ -1511,6 +1523,7 @@ class MCPLoader(loader.ToolLoader):
"""
execution_context = await self._assert_execution_active(context)
server_config = dict(server_config)
require_stdio_mcp_enabled(self.ap, server_config)
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
raise ValueError('MCP server configuration belongs to another Workspace')
@@ -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()
+13
View File
@@ -125,9 +125,22 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
worker:
# Instance-wide maximum for every plugin installation. Plugin
# manifests cannot raise or override these limits.
max_cpus: 1.0
max_memory_mb: 512
max_pids: 128
max_open_files: 256
max_file_size_mb: 512
binary_storage:
# Max bytes for a single plugin binary storage value
max_value_bytes: 10485760
mcp:
stdio:
# Independent gate for local stdio MCP transports. Cloud v2 sets
# MCP__STDIO__ENABLED=false even when Box Runtime is available.
enabled: true
monitoring:
auto_cleanup:
# Enable automatic cleanup of expired monitoring records