Files
LangBot/src/langbot/pkg/plugin/connector.py
T
RockChinQ e1ac5e0fc8 feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture

* Add OSS and commercial workspace boundaries

* docs: redesign multi-tenant workspace architecture

* feat(tenancy): implement workspace isolation

* docs(tenancy): record verification evidence

* docs(tenancy): revise single-instance SaaS topology

* docs(tenancy): refine architecture options

* docs: finalize cloud v2 multi-tenant decisions

* feat(tenancy): establish cloud isolation foundations

* feat(tenancy): harden shared cloud runtime boundaries

* docs(tenancy): record final isolation verification

* fix(tenancy): close isolation and permission gaps

* docs(tenancy): record final isolation verification

* feat(tenancy): connect cloud workspace control plane

* fix(build): install git for pinned SDK

* docs(cloud): update control plane verification

* chore: update multi-tenant SDK pin

* fix(cloud): skip legacy model sync during startup

* test(cloud): preserve minimal model manager fixtures

* fix(cloud): preserve authenticated account context

* fix(cloud): reuse authenticated account for user info

* feat(cloud): complete Workspace settings navigation

* test(web): cover Workspace dropdown menu

* feat(web): place workspace controls in sidebar

* refactor(web): streamline workspace controls

* style(web): format workspace layout test

* fix(cloud): surface runtime and workspace plan status

* fix(plugin): keep runtime identity stable across restarts

* fix(ui): widen and center workspace switcher

* fix(ui): hide roles from workspace switcher

* fix(ui): align workspace switcher with sidebar entries

* feat(workspace): add in-product collaboration and direct Cloud launch

* style: format collaboration changes

* fix(workspace): bind collaboration APIs to tenant UoW

* fix(cloud): preserve Core-owned collaboration state

* test(cloud): require Space identity for invite registration

* feat(cloud): complete secure invitation experience

* style(web): format invitation flows

* fix(cloud): recover box runtime without unscoped skill reload

* feat(oss): enforce invitation account and owner billing flows

* style: format OSS account service

* test(oss): cover invitation logout handoff

* fix(oss): resolve workspace owner in scoped session

* feat(cloud): harden multi-tenant runtime resources

* fix(cloud): bound runtime restart storms

* fix(cloud): eliminate periodic runtime CPU spikes

* fix(cloud): enforce instance capacity ceilings

* fix(cloud): scope public login capability discovery

* fix(cloud): bound tenant maintenance and monitoring work

* fix(runtime): bound tenant resource amplification

* fix(deps): pin green multi-tenant plugin SDK

* fix(cloud): handle unavailable skill capability

* fix(security): require authentication for image file endpoint (H-2)

- Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY
- Added Permission.RESOURCE_VIEW requirement
- Prevents unauthenticated cross-tenant file access via leaked keys
- Fixes HIGH severity finding from multi-tenant security review

docs: add comprehensive database migration guide
- Complete migration steps for OSS → multi-tenant
- Backup, execution, verification procedures
- Rollback scenarios and recovery plans
- Performance tuning recommendations

* test: add comprehensive cross-tenant isolation tests

Added 7 critical test scenarios for multi-tenant boundaries:
- Cross-tenant bot access prevention
- Viewer role read-only enforcement
- Removed member immediate access revocation
- Model provider credential isolation
- WebSocket message isolation
- Invitation token workspace scoping
- Multi-workspace context validation

These tests address P0-2 coverage gaps for:
- workspaces.py (membership & invitation flows)
- user.py (authentication & authorization)
- websocket_chat.py (real-time isolation)
- plugins.py (resource access control)

docs: finalize database migration guide

* fix(security): resolve M-1, M-2, M-3 security findings

M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.

* fix(cloud): unblock tenant CI and enforce knowledge quotas

* fix(tenancy): scope rerank model sync

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-30 21:43:35 +08:00

2303 lines
103 KiB
Python

# For connect to plugin runtime.
from __future__ import annotations
import asyncio
import contextlib
import contextvars
import hashlib
import json
import time
import uuid
from typing import Any
import typing
import os
import secrets
import sys
import httpx
import sqlalchemy
from urllib.parse import urljoin, urlparse
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
from ..core import app
from . import handler
from .archive import inspect_plugin_archive_metadata
from .github import (
validate_github_plugin_install_info,
validate_github_release_asset_url,
)
from ..utils import constants, httpclient, platform
from ..utils.managed_runtime import ManagedRuntimeConnector
from langbot_plugin.runtime.io.controllers.stdio import (
client as stdio_client_controller,
)
from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller
from langbot_plugin.api.entities import events
from langbot_plugin.api.entities import context
import langbot_plugin.runtime.io.connection as base_connection
from langbot_plugin.api.definition.components.manifest import ComponentManifest
from langbot_plugin.api.entities.builtin.command import (
context as command_context,
errors as command_errors,
)
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
from langbot_plugin.runtime.security import (
PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
validate_runtime_secret,
)
from langbot_plugin.entities.io.context import (
InstallationBinding,
PluginInstallationDesiredState,
PluginWorkerPolicy,
RuntimeIdentity,
)
from ..core import taskmgr
from ..entity.persistence import bstorage as persistence_bstorage
from ..entity.persistence import plugin as persistence_plugin
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..workspace.errors import WorkspaceNotFoundError
_PLUGIN_ARTIFACT_OWNER_TYPE = 'plugin_artifact'
_PLUGIN_ARTIFACT_KEY = 'package.lbpkg'
_PLUGIN_ARTIFACT_STORAGE_MARKER = 'tenant_binary_storage_v1'
_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES = 10 * 1024 * 1024
_MARKETPLACE_METADATA_MAX_BYTES = 1024 * 1024
_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024
_MARKETPLACE_SKILL_DOWNLOAD_MAX_BYTES = 10 * 1024 * 1024
_GITHUB_PLUGIN_DOWNLOAD_MAX_REDIRECTS = 5
_GITHUB_ASSET_HOSTS = frozenset(
{
'api.github.com',
'github.com',
'objects.githubusercontent.com',
'release-assets.githubusercontent.com',
}
)
_HTTP_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
_CONNECT_TIMEOUT_SEC = 30.0
_HEARTBEAT_INTERVAL_SEC = 20.0
_HEARTBEAT_FAILURE_THRESHOLD = 3
_RECONNECT_MAX_DELAY_SEC = 60.0
async def _read_httpx_response_limited(
response: httpx.Response,
*,
max_bytes: int,
) -> bytes:
content_length = response.headers.get('content-length')
if content_length is not None:
try:
declared_size = int(content_length)
except ValueError:
declared_size = None
if declared_size is not None and declared_size > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
body = bytearray()
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
body.extend(chunk)
if len(body) > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
return bytes(body)
async def _marketplace_get(
client: httpx.AsyncClient,
url: str,
*,
max_bytes: int,
allow_not_found: bool = False,
) -> tuple[int, bytes]:
async with client.stream('GET', url) as response:
if allow_not_found and response.status_code == 404:
return response.status_code, b''
response.raise_for_status()
return response.status_code, await _read_httpx_response_limited(
response,
max_bytes=max_bytes,
)
def _decode_json_object(body: bytes, *, subject: str) -> dict[str, Any]:
try:
payload = json.loads(body)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError(f'{subject} returned invalid JSON') from exc
if not isinstance(payload, dict):
raise ValueError(f'{subject} returned a non-object response')
return payload
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
class PluginInstallationFailedError(RuntimeError):
"""Stable Runtime desired-state failure for one plugin installation."""
def __init__(
self,
installation_uuid: str,
error_code: str,
message: str,
) -> None:
self.installation_uuid = installation_uuid
self.error_code = error_code
self.runtime_message = message
super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
class PluginRuntimeConnector(ManagedRuntimeConnector):
"""Plugin runtime connector"""
handler: handler.RuntimeConnectionHandler
handler_task: asyncio.Task
heartbeat_task: asyncio.Task | None = None
stdio_client_controller: stdio_client_controller.StdioClientController
ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController
runtime_disconnect_callback: typing.Callable[
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
]
is_enable_plugin: bool = True
"""Mark if the plugin system is enabled"""
def __init__(
self,
ap: app.Application,
runtime_disconnect_callback: typing.Callable[
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
],
):
super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
self.runtime_profile: typing.Literal['oss_dev', 'shared'] = (
'shared' if getattr(getattr(ap, 'deployment', None), 'mode', 'oss') == 'cloud' else 'oss_dev'
)
self.runtime_identity: RuntimeIdentity | None = None
self._runtime_id = self._build_runtime_id()
self.worker_policy: PluginWorkerPolicy | None = None
self._execution_context: contextvars.ContextVar[ExecutionContext | None] = contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_execution_context',
default=None,
)
self._known_desired_states: dict[str, PluginInstallationDesiredState] = {}
self._workspace_installations: dict[str, set[str]] = {}
self._installation_failures: dict[str, dict[str, str]] = {}
self._state_lock = asyncio.Lock()
self._control_token = str(os.environ.get(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV) or '').strip()
self._transport_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
self._generation = 0
self._connected = asyncio.Event()
@staticmethod
def _build_runtime_id() -> str:
"""Return the durable identity of this instance's shared Runtime."""
return f'{constants.instance_id}:plugin-runtime'
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
return runtime_handler
def _runtime_available(self) -> bool:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
return False
# Unit-level and explicitly injected handlers do not own a transport.
# A managed transport must also have completed its handshake.
return self._transport_task is None or self._connected.is_set()
def _load_worker_policy(self) -> PluginWorkerPolicy:
"""Validate the instance policy without consulting plugin manifests."""
worker = self.ap.instance_config.data.get('plugin', {}).get('worker')
if not isinstance(worker, dict):
raise ValueError('plugin.worker must be configured')
policy_data = {
'max_cpus': worker.get('max_cpus'),
'max_memory_mb': worker.get('max_memory_mb'),
'max_pids': worker.get('max_pids'),
'max_open_files': worker.get('max_open_files'),
'max_file_size_mb': worker.get('max_file_size_mb'),
'require_hard_limits': worker.get('require_hard_limits', False),
}
for field_name in (
'max_workers',
'max_total_cpus',
'max_total_memory_mb',
'max_installations',
'max_concurrent_restarts',
'restart_failure_threshold',
'restart_failure_window_seconds',
'restart_circuit_open_seconds',
):
if field_name in PluginWorkerPolicy.model_fields and field_name in worker:
policy_data[field_name] = worker.get(field_name)
return PluginWorkerPolicy.model_validate(policy_data)
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
try:
self._control_token = validate_runtime_secret(
self._control_token,
name=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
)
except ValueError as exc:
raise PluginRuntimeNotConnectedError(
f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
'for an external Plugin Runtime'
) from exc
return {PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER: self._control_token}
@staticmethod
def _execution_from_binding(binding: Any) -> ExecutionContext:
return ExecutionContext(
instance_uuid=str(binding.instance_uuid),
workspace_uuid=str(binding.workspace_uuid),
placement_generation=int(binding.placement_generation),
)
@staticmethod
def _binding_from_setting(
execution_context: ExecutionContext,
setting: persistence_plugin.PluginSetting,
) -> InstallationBinding:
return InstallationBinding(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
installation_uuid=setting.installation_uuid,
runtime_revision=setting.runtime_revision,
artifact_digest=setting.artifact_digest,
)
def _legacy_oss_bridge_binding(self, execution_context: ExecutionContext) -> InstallationBinding:
seed = f'langbot:oss-plugin-bridge:{execution_context.instance_uuid}:{execution_context.workspace_uuid}'
return InstallationBinding(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
installation_uuid=str(uuid.uuid5(uuid.NAMESPACE_URL, seed)),
runtime_revision=1,
artifact_digest=hashlib.sha256(seed.encode()).hexdigest(),
)
@staticmethod
def _artifact_unique_key(execution_context: ExecutionContext, artifact_digest: str) -> str:
# StorageMgr imports the Application graph that wires this connector;
# keep the dependency lazy so either module remains independently
# importable by tools and focused tests.
from ..storage.mgr import StorageMgr
return StorageMgr.canonical_binary_storage_key(
execution_context,
owner_type=_PLUGIN_ARTIFACT_OWNER_TYPE,
owner=artifact_digest,
key=_PLUGIN_ARTIFACT_KEY,
)
async def _store_artifact_package(
self,
execution_context: ExecutionContext,
artifact_digest: str,
artifact_package: bytes,
) -> None:
"""Persist one verified lbpkg in the existing tenant-scoped blob table."""
if hashlib.sha256(artifact_package).hexdigest() != artifact_digest:
raise ValueError('Plugin package digest does not match its desired state')
unique_key = self._artifact_unique_key(execution_context, artifact_digest)
async def store(execute) -> None:
result = await execute(
sqlalchemy.select(persistence_bstorage.BinaryStorage.value)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
existing_value = result.scalar_one_or_none()
if existing_value is not None:
if hashlib.sha256(bytes(existing_value)).hexdigest() != artifact_digest:
raise ValueError('Persisted plugin package failed digest verification')
return
await execute(
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
workspace_uuid=execution_context.workspace_uuid,
unique_key=unique_key,
key=_PLUGIN_ARTIFACT_KEY,
owner_type=_PLUGIN_ARTIFACT_OWNER_TYPE,
owner=artifact_digest,
value=artifact_package,
)
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
await store(uow.execute)
else:
await store(self.ap.persistence_mgr.execute_async)
async def _load_artifact_package(
self,
execution_context: ExecutionContext,
artifact_digest: str,
) -> bytes | None:
unique_key = self._artifact_unique_key(execution_context, artifact_digest)
statement = (
sqlalchemy.select(persistence_bstorage.BinaryStorage.value)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
async def load(execute) -> bytes | None:
result = await execute(statement)
stored_value = result.scalar_one_or_none()
if stored_value is None:
return None
artifact_package = bytes(stored_value)
if hashlib.sha256(artifact_package).hexdigest() != artifact_digest:
raise ValueError('Persisted plugin package failed digest verification')
return artifact_package
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
return await load(uow.execute)
return await load(self.ap.persistence_mgr.execute_async)
async def _delete_artifact_if_unreferenced(
self,
execution_context: ExecutionContext,
artifact_digest: str,
*,
execute=None,
) -> None:
"""Delete a tenant copy only after its last desired-state reference."""
async def cleanup(run) -> None:
result = await run(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.artifact_digest == artifact_digest)
)
if int(result.scalar_one()) != 0:
return
await run(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.owner_type == _PLUGIN_ARTIFACT_OWNER_TYPE)
.where(persistence_bstorage.BinaryStorage.owner == artifact_digest)
)
if execute is not None:
await cleanup(execute)
return
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
await cleanup(uow.execute)
else:
await cleanup(self.ap.persistence_mgr.execute_async)
async def _load_workspace_settings(
self,
execution_context: ExecutionContext,
) -> list[persistence_plugin.PluginSetting]:
statement = (
sqlalchemy.select(*persistence_plugin.PluginSetting.__table__.c)
.where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
.order_by(
persistence_plugin.PluginSetting.priority.desc(),
persistence_plugin.PluginSetting.created_at.asc(),
)
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
result = await uow.execute(statement)
return [persistence_plugin.PluginSetting(**dict(row)) for row in result.mappings().all()]
result = await self.ap.persistence_mgr.execute_async(statement)
return [persistence_plugin.PluginSetting(**dict(row)) for row in result.mappings().all()]
async def _load_workspace_desired_states(
self,
execution_context: ExecutionContext,
) -> tuple[PluginInstallationDesiredState, ...]:
settings = await self._load_workspace_settings(execution_context)
desired_states: list[PluginInstallationDesiredState] = []
for setting in settings:
binding = self._binding_from_setting(execution_context, setting)
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is not None:
runtime_handler.register_installation_binding(
binding,
plugin_author=setting.plugin_author,
plugin_name=setting.plugin_name,
)
desired_states.append(
PluginInstallationDesiredState(
binding=binding,
enabled=setting.enabled,
)
)
return tuple(desired_states)
async def _apply_desired_state(
self,
desired: PluginInstallationDesiredState,
*,
artifact_package: bytes | None = None,
) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
result = await runtime_handler.apply_plugin_installation(
desired.binding,
artifact_package=artifact_package,
enabled=desired.enabled,
)
self._raise_apply_failure(desired, result)
if result.get('state') != 'artifact_missing':
self._installation_failures.pop(desired.binding.installation_uuid, None)
return result
execution_context = self._execution_from_binding(desired.binding)
persisted_package = await self._load_artifact_package(
execution_context,
desired.binding.artifact_digest,
)
if persisted_package is None:
if self.runtime_profile == 'oss_dev':
self.ap.logger.warning(
'Keeping legacy OSS plugin installation %s on data/plugins; no durable lbpkg is available',
desired.binding.installation_uuid,
)
return result
raise RuntimeError(
f'Durable plugin artifact {desired.binding.artifact_digest} is missing for '
f'installation {desired.binding.installation_uuid}'
)
repaired = await runtime_handler.apply_plugin_installation(
desired.binding,
artifact_package=persisted_package,
enabled=desired.enabled,
)
self._raise_apply_failure(desired, repaired)
if repaired.get('state') == 'artifact_missing':
raise RuntimeError(
f'Runtime rejected durable plugin artifact for installation {desired.binding.installation_uuid}'
)
self._installation_failures.pop(desired.binding.installation_uuid, None)
return repaired
@staticmethod
def _runtime_failure_fields(
installation_uuid: str,
value: Any,
) -> tuple[str, str, str]:
if not isinstance(value, dict):
return (
installation_uuid,
'runtime_installation_failed',
('Plugin Runtime returned a malformed installation failure'),
)
reported_uuid = str(value.get('installation_uuid') or installation_uuid).strip()
if reported_uuid != installation_uuid:
return (
installation_uuid,
'runtime_installation_failed',
('Plugin Runtime returned a mismatched installation failure'),
)
error_code = str(value.get('error_code') or 'runtime_installation_failed').strip()
if (
not error_code
or len(error_code) > 64
or not error_code.isascii()
or any(not (character.isalnum() or character in {'_', '-'}) for character in error_code)
):
error_code = 'runtime_installation_failed'
message = 'Plugin Runtime failed to apply the desired installation'
candidate = str(value.get('message') or '').replace('\r', ' ').replace('\n', ' ').strip()
if candidate:
message = candidate[:512]
return installation_uuid, error_code, message
def _raise_apply_failure(
self,
desired: PluginInstallationDesiredState,
result: dict[str, Any],
) -> None:
if result.get('state') != 'failed':
return
installation_uuid, error_code, message = self._runtime_failure_fields(
desired.binding.installation_uuid,
result,
)
failure = {
'installation_uuid': installation_uuid,
'error_code': error_code,
'message': message,
}
self._installation_failures[installation_uuid] = failure
self.ap.logger.error(
'Plugin installation %s failed during apply [%s]: %s',
installation_uuid,
error_code,
message,
)
raise PluginInstallationFailedError(
installation_uuid,
error_code,
message,
)
def _record_reconcile_failures(
self,
desired_states: dict[str, PluginInstallationDesiredState],
result: dict[str, Any],
) -> None:
reported = result.get('failed_installations', [])
if not isinstance(reported, list):
self.ap.logger.error('Plugin Runtime returned malformed failed_installations during reconcile')
reported = []
failures: dict[str, dict[str, str]] = {}
for value in reported:
requested_uuid = str(value.get('installation_uuid') or '').strip() if isinstance(value, dict) else ''
if requested_uuid not in desired_states:
self.ap.logger.error(
'Plugin Runtime reported failure for unknown installation %s',
requested_uuid or '<missing>',
)
continue
installation_uuid, error_code, message = self._runtime_failure_fields(
requested_uuid,
value,
)
failure = {
'installation_uuid': installation_uuid,
'error_code': error_code,
'message': message,
}
failures[installation_uuid] = failure
if self._installation_failures.get(installation_uuid) != failure:
self.ap.logger.error(
'Plugin installation %s failed during reconcile [%s]: %s',
installation_uuid,
error_code,
message,
)
for installation_uuid in tuple(self._installation_failures):
if installation_uuid not in failures:
self._installation_failures.pop(installation_uuid, None)
self._installation_failures.update(failures)
async def _repair_reconcile_missing_artifacts(
self,
desired_states: dict[str, PluginInstallationDesiredState],
result: dict[str, Any],
) -> None:
for installation_uuid in result.get('missing_artifacts', []):
desired = desired_states.get(str(installation_uuid))
if desired is None:
raise RuntimeError(f'Runtime reported an unknown missing installation {installation_uuid}')
execution_context = self._execution_from_binding(desired.binding)
persisted_package = await self._load_artifact_package(
execution_context,
desired.binding.artifact_digest,
)
if persisted_package is None:
if self.runtime_profile == 'oss_dev':
self.ap.logger.warning(
'Keeping legacy OSS plugin installation %s on data/plugins; no durable lbpkg is available',
desired.binding.installation_uuid,
)
continue
raise RuntimeError(
f'Durable plugin artifact {desired.binding.artifact_digest} is missing for '
f'installation {desired.binding.installation_uuid}'
)
try:
await self._apply_desired_state(
desired,
artifact_package=persisted_package,
)
except PluginInstallationFailedError as exc:
failures = result.setdefault('failed_installations', [])
if not any(
isinstance(item, dict) and item.get('installation_uuid') == exc.installation_uuid
for item in failures
):
failures.append(
{
'installation_uuid': exc.installation_uuid,
'error_code': exc.error_code,
'message': exc.runtime_message,
}
)
async def _prepare_connected_runtime(self) -> None:
"""Handshake follow-up: pin OSS compatibility, then replay authority."""
runtime_handler = self._runtime_handler()
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is None:
raise RuntimeError('Plugin Runtime requires the Workspace projection service')
if self.runtime_profile == 'shared':
list_bindings = getattr(workspace_service, 'list_active_execution_bindings', None)
if not callable(list_bindings):
raise RuntimeError('Shared plugin Runtime requires instance-scoped Workspace discovery')
projected_bindings = await list_bindings()
await self.reconcile_projected_workspaces(
self._execution_from_binding(binding) for binding in projected_bindings
)
return
if self.runtime_profile == 'oss_dev':
local_binding = await workspace_service.get_local_execution_binding()
execution_context = self._execution_from_binding(local_binding)
bridge = self._legacy_oss_bridge_binding(execution_context)
# One fully-bound action releases the SDK's deliberately retained
# pre-v4 data/plugins/debug compatibility path. Shared mode never
# creates this bridge.
with runtime_handler.installation_scope(bridge):
await runtime_handler.list_plugins()
desired_states = await self._load_workspace_desired_states(execution_context)
self._workspace_installations[execution_context.workspace_uuid] = {
state.binding.installation_uuid for state in desired_states
}
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
self._record_reconcile_failures(self._known_desired_states, result)
async def reconcile_projected_workspaces(
self,
contexts: typing.Iterable[ExecutionContext],
) -> dict[str, Any]:
"""Replay a closed control plane's complete projected Workspace set.
PostgreSQL RLS intentionally prevents Core from globally scanning
Workspaces. The caller enumerates authoritative projections; Core then
reads each Workspace inside a tenant UoW and performs one instance-wide
Runtime reconcile.
"""
runtime_handler = self._runtime_handler()
async with self._state_lock:
all_states: dict[str, PluginInstallationDesiredState] = {}
workspace_installations: dict[str, set[str]] = {}
for context in contexts:
execution_context = await self._validate_execution_context(context)
states = await self._load_workspace_desired_states(execution_context)
installation_ids = {state.binding.installation_uuid for state in states}
if installation_ids:
# A newly registered Cloud Workspace normally has no
# plugins. Avoid retaining an empty set for every account.
workspace_installations[execution_context.workspace_uuid] = installation_ids
for state in states:
if state.binding.installation_uuid in all_states:
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
all_states[state.binding.installation_uuid] = state
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
await self._repair_reconcile_missing_artifacts(all_states, result)
self._record_reconcile_failures(all_states, result)
for installation_uuid, previous in tuple(self._known_desired_states.items()):
if installation_uuid not in all_states:
runtime_handler.unregister_installation_binding(previous.binding)
self._known_desired_states = all_states
self._workspace_installations = workspace_installations
return result
async def _validate_execution_context(self, context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise WorkspaceNotFoundError('Plugin resource not found')
binding = await self.ap.workspace_service.get_execution_binding(
workspace_uuid,
expected_generation=generation,
)
if binding.instance_uuid != instance_uuid:
raise WorkspaceNotFoundError('Plugin resource not found')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
trigger_principal=getattr(context, 'principal', None),
entitlement_revision=getattr(context, 'entitlement_revision', 0),
)
async def _synchronize_workspace(self, execution_context: ExecutionContext) -> None:
if not self.is_enable_plugin or not hasattr(self, 'handler'):
return
runtime_handler = self._runtime_handler()
desired_states = await self._load_workspace_desired_states(execution_context)
desired_by_uuid = {state.binding.installation_uuid: state for state in desired_states}
async with self._state_lock:
previous_ids = set(self._workspace_installations.get(execution_context.workspace_uuid, set()))
for installation_uuid in previous_ids - set(desired_by_uuid):
previous = self._known_desired_states.get(installation_uuid)
if previous is not None:
await runtime_handler.remove_plugin_installation(previous.binding)
runtime_handler.unregister_installation_binding(previous.binding)
self._known_desired_states.pop(installation_uuid, None)
self._installation_failures.pop(installation_uuid, None)
for installation_uuid, desired in desired_by_uuid.items():
if self._known_desired_states.get(installation_uuid) == desired:
continue
try:
await self._apply_desired_state(desired)
except PluginInstallationFailedError:
# The failure is retained per installation. Continue
# restoring the remaining desired state in this Workspace.
pass
self._known_desired_states[installation_uuid] = desired
if desired_by_uuid:
self._workspace_installations[execution_context.workspace_uuid] = set(desired_by_uuid)
else:
self._workspace_installations.pop(
execution_context.workspace_uuid,
None,
)
async def _current_execution_context(self) -> ExecutionContext:
current = self._execution_context.get()
if current is not None:
return current
if self.runtime_profile != 'oss_dev':
raise WorkspaceNotFoundError('Plugin resource not found')
binding = await self.ap.workspace_service.get_local_execution_binding()
current = self._execution_from_binding(binding)
self._execution_context.set(current)
await self._synchronize_workspace(current)
return current
async def heartbeat_loop(self):
failures = 0
while not self._closing:
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
try:
await self.ping_plugin_runtime()
failures = 0
self.ap.logger.debug('Heartbeat to plugin runtime success.')
except Exception as e:
failures += 1
self.ap.logger.warning(
f'Plugin runtime heartbeat failed ({failures}/{_HEARTBEAT_FAILURE_THRESHOLD}): {e}'
)
if failures >= _HEARTBEAT_FAILURE_THRESHOLD:
self._connected.clear()
self.schedule_reconnect()
failures = 0
async def initialize(self):
if not self.is_enable_plugin:
self.ap.logger.info('Plugin system is disabled.')
return
self.runtime_identity = RuntimeIdentity(
instance_uuid=constants.instance_id,
runtime_id=self._runtime_id,
)
self.worker_policy = self._load_worker_policy()
async with self._lifecycle_lock:
if self._closing:
raise PluginRuntimeNotConnectedError('Plugin runtime connector is shutting down')
if self._connected.is_set() and hasattr(self, 'handler'):
return
self._generation += 1
generation = self._generation
await self._stop_transport()
self._connected = asyncio.Event()
connect_errors: list[Exception] = []
async def new_connection_callback(
connection: base_connection.Connection,
):
if generation != self._generation or self._closing:
await connection.close()
return
connection_ready = False
disconnect_notified = False
async def notify_disconnect() -> None:
nonlocal disconnect_notified
if (
connection_ready
and not disconnect_notified
and generation == self._generation
and not self._closing
):
disconnect_notified = True
self._connected.clear()
await self.runtime_disconnect_callback(self)
async def disconnect_callback(
rchandler: handler.RuntimeConnectionHandler,
) -> bool:
await notify_disconnect()
return False
runtime_handler = handler.RuntimeConnectionHandler(
connection,
disconnect_callback,
self.ap,
)
self.handler = runtime_handler
self.handler_task = asyncio.create_task(runtime_handler.run())
try:
await runtime_handler.ping()
if self.runtime_identity is None or self.worker_policy is None: # pragma: no cover
raise RuntimeError('Plugin Runtime identity or worker policy was not loaded')
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
await runtime_handler.set_runtime_config(
runtime_identity=self.runtime_identity,
worker_policy=self.worker_policy,
runtime_profile=self.runtime_profile,
cloud_service_url=space_url or None,
)
if space_url:
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
await self._prepare_connected_runtime()
if generation == self._generation and not self._closing:
connection_ready = True
self._connected.set()
self.ap.logger.info('Connected to instance-scoped plugin runtime.')
await self.handler_task
except asyncio.CancelledError:
raise
except Exception as exc:
if not self._connected.is_set():
connect_errors.append(exc)
self._connected.set()
finally:
if generation == self._generation and not self._closing:
self._connected.clear()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
await notify_disconnect()
task_coro: typing.Coroutine[Any, Any, Any]
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
self.ap.logger.info('use websocket to connect to plugin runtime')
control_headers = self._control_headers(allow_generate=False)
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
'runtime_ws_url',
'ws://langbot_plugin_runtime:5400/control/ws',
)
async def connection_failed(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception | None = None,
) -> None:
del ctrl
connect_errors.append(exc or RuntimeError('WebSocket connection failed'))
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
additional_headers=control_headers,
)
task_coro = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
# Windows cannot use the stdio subprocess transport, so launch
# a managed runtime and authenticate its WebSocket controller.
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
control_headers = self._control_headers(allow_generate=True)
await self._start_runtime_subprocess(
'-m',
'langbot_plugin.cli.__init__',
'rt',
env_overrides={PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: self._control_token},
)
ws_url = 'ws://localhost:5400/control/ws'
async def connection_failed(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception | None = None,
) -> None:
del ctrl
connect_errors.append(exc or RuntimeError('WebSocket connection failed'))
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
additional_headers=control_headers,
)
task_coro = self.ctrl.run(new_connection_callback)
else:
self.ap.logger.info('use stdio to connect to plugin runtime')
self.ctrl = stdio_client_controller.StdioClientController(
command=sys.executable,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
env=os.environ.copy(),
capture_stderr=False,
)
task_coro = self.ctrl.run(new_connection_callback)
self._transport_task = asyncio.create_task(task_coro)
try:
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
except asyncio.TimeoutError as exc:
await self._stop_transport()
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
if connect_errors:
await self._stop_transport()
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
if self.heartbeat_task is None or self.heartbeat_task.done():
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
def schedule_reconnect(self) -> None:
if self._closing or not self.is_enable_plugin:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
return
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
async def _reconnect_loop(self) -> None:
delay = 1.0
try:
while not self._closing:
try:
await self.initialize()
return
except Exception as exc:
self.ap.logger.warning(f'Plugin runtime reconnection failed: {exc}; retrying in {delay:.0f}s')
await asyncio.sleep(delay)
delay = min(delay * 2, _RECONNECT_MAX_DELAY_SEC)
finally:
self._reconnect_task = None
async def _stop_transport(self) -> None:
self._connected.clear()
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is not None:
with contextlib.suppress(Exception):
await runtime_handler.close()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
tasks = [
task
for task in (
getattr(self, 'handler_task', None),
self._transport_task,
)
if task is not None and task is not asyncio.current_task()
]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._transport_task = None
if hasattr(self, 'handler_task'):
del self.handler_task
close_ctrl = getattr(getattr(self, 'ctrl', None), 'close', None)
if close_ctrl is not None:
with contextlib.suppress(Exception):
await close_ctrl()
async def aclose(self) -> None:
self._closing = True
self._generation += 1
reconnect_task = self._reconnect_task
self._reconnect_task = None
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
reconnect_task.cancel()
await asyncio.gather(reconnect_task, return_exceptions=True)
if self.heartbeat_task is not None:
self.heartbeat_task.cancel()
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
self.heartbeat_task = None
await self._stop_transport()
await self._close_managed_subprocess()
async def initialize_plugins(self):
pass
async def ping_plugin_runtime(self):
return await self._runtime_handler().ping()
async def require_workspace_context(self, context: TenantContext) -> ExecutionContext:
"""Validate and select one Workspace for this asyncio request task."""
execution_context = await self._validate_execution_context(context)
self._execution_context.set(execution_context)
if not self.is_enable_plugin:
return execution_context
if not hasattr(self, 'handler'):
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
await self._synchronize_workspace(execution_context)
return execution_context
def _inspect_plugin_package(
self,
file_bytes: bytes,
task_context: taskmgr.TaskContext | None,
) -> tuple[str | None, str | None]:
"""Extract plugin identity and dependency metadata from a plugin package."""
plugin_author = None
plugin_name = None
try:
manifest, dependencies, archive_names = inspect_plugin_archive_metadata(
file_bytes,
require_manifest=False,
)
metadata = manifest.get('metadata', {})
if isinstance(metadata, dict):
plugin_author = metadata.get('author')
plugin_name = metadata.get('name')
has_requirements = any(
name.replace('\\', '/').lower().endswith('requirements.txt') for name in archive_names
)
if task_context is not None and has_requirements:
task_context.metadata['deps_total'] = len(dependencies)
task_context.metadata['deps_list'] = dependencies
except Exception:
pass
return plugin_author, plugin_name
async def _operation_bindings(
self,
*,
include_plugins: list[str] | None = None,
include_disabled: bool = False,
) -> list[InstallationBinding]:
execution_context = await self._current_execution_context()
settings = await self._load_workspace_settings(execution_context)
bindings: list[InstallationBinding] = (
[self._legacy_oss_bridge_binding(execution_context)] if self.runtime_profile == 'oss_dev' else []
)
for setting in settings:
plugin_id = f'{setting.plugin_author}/{setting.plugin_name}'
if not include_disabled and not setting.enabled:
continue
if include_plugins is not None and plugin_id not in include_plugins:
continue
if self.runtime_profile == 'oss_dev' and (
not isinstance(setting.install_info, dict)
or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
):
continue
bindings.append(self._binding_from_setting(execution_context, setting))
return bindings
async def _setting_for_plugin(
self,
plugin_author: str,
plugin_name: str,
*,
require_enabled: bool = False,
) -> tuple[ExecutionContext, persistence_plugin.PluginSetting]:
execution_context = await self._current_execution_context()
settings = await self._load_workspace_settings(execution_context)
for setting in settings:
if setting.plugin_author == plugin_author and setting.plugin_name == plugin_name:
if require_enabled and not setting.enabled:
raise ValueError(f'Plugin {plugin_author}/{plugin_name} is disabled')
return execution_context, setting
raise ValueError(f'Plugin {plugin_author}/{plugin_name} is not installed in this Workspace')
async def _target_binding(
self,
plugin_author: str,
plugin_name: str,
*,
require_enabled: bool = True,
) -> InstallationBinding:
execution_context, setting = await self._setting_for_plugin(
plugin_author,
plugin_name,
require_enabled=require_enabled,
)
if self.runtime_profile == 'oss_dev' and (
not isinstance(setting.install_info, dict)
or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
):
return self._legacy_oss_bridge_binding(execution_context)
return self._binding_from_setting(execution_context, setting)
async def _target_binding_for_component(
self,
component_name: str,
*,
component_kind: typing.Literal['tool', 'command'],
include_plugins: list[str] | None,
) -> InstallationBinding:
runtime_handler = self._runtime_handler()
for binding in await self._operation_bindings(include_plugins=include_plugins):
with runtime_handler.installation_scope(binding):
components = (
await runtime_handler.list_tools(include_plugins=include_plugins)
if component_kind == 'tool'
else await runtime_handler.list_commands(include_plugins=include_plugins)
)
for component in components:
manifest = ComponentManifest.model_validate(component)
if manifest.metadata.name == component_name:
return binding
raise ValueError(f'Plugin {component_kind} {component_name!r} was not found in this Workspace')
async def _install_mcp_from_marketplace(
self,
execution_context: ExecutionContext,
mcp_data: dict[str, Any],
task_context: taskmgr.TaskContext | None = None,
):
"""Install an MCP server from marketplace data.
Marketplace MCP records carry the runtime-ready ``mode`` and
``extra_args`` directly (the same shape LangBot stores in
``mcp_servers``), so they are used as-is rather than reconstructed.
For ``stdio`` this preserves ``command``/``args``/``env``/``box``;
for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/
``ssereadtimeout``.
"""
mode = mcp_data.get('mode') or 'stdio'
extra_args = mcp_data.get('extra_args') or {}
# The MCP transport selection was simplified to two modes: 'stdio'
# (local, Box-sandboxed) and 'remote' (the runtime auto-detects
# Streamable HTTP vs. legacy SSE from the URL). Marketplace records may
# still carry the older 'http'/'sse' modes — normalize them to 'remote'
# so the installed server shows up correctly in the two-option UI. The
# connection args (url/headers/timeout/ssereadtimeout) are preserved and
# consumed by the auto-detecting remote transport regardless.
if mode in ('http', 'sse'):
mode = 'remote'
# Marketplace records carry the rendered README markdown; persist it so
# the detail page Docs tab works offline and without a marketplace round-trip.
readme = mcp_data.get('readme') or ''
# Use __ instead of / to avoid URL routing issues with slashes
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
existing = await self.ap.mcp_service.get_mcp_server_by_name(execution_context, name)
if existing is not None:
self.ap.logger.info(f'MCP server {name} already exists, skipping installation')
return
server_data = {
'name': name,
'enable': True,
'mode': mode,
'extra_args': extra_args,
'readme': readme,
}
await self.ap.mcp_service.create_mcp_server(execution_context, server_data)
self.ap.logger.info(f'Installed MCP server {name} from marketplace')
async def _install_skill_from_zip(
self,
execution_context: ExecutionContext,
file_bytes: bytes,
filename: str,
task_context: taskmgr.TaskContext | None = None,
):
"""Install a skill from marketplace ZIP data."""
from ..api.http.service.skill import SkillService
skill_service = SkillService(self.ap)
self.ap.logger.info(f'Installing skill from marketplace ZIP ({len(file_bytes)} bytes)')
# Install from ZIP using skill service
result = await skill_service.install_from_zip_upload(
execution_context,
file_bytes=file_bytes,
filename=filename + '.zip',
)
self.ap.logger.info(f'Skill installed successfully: {result}')
def _build_plugin_startup_failure_message(
self,
plugin_author: str,
plugin_name: str,
task_context: taskmgr.TaskContext | None,
) -> str:
dep_hint = ''
if task_context is not None:
current_dep = task_context.metadata.get('current_dep')
if current_dep:
dep_hint = f' Last dependency: {current_dep}.'
return (
f'Plugin {plugin_author}/{plugin_name} failed to start after installation. '
f'Dependency installation or plugin initialization may have failed.{dep_hint} '
f'Please check the plugin requirements and runtime logs.'
)
async def _wait_for_installed_plugin_ready(
self,
plugin_author: str | None,
plugin_name: str | None,
task_context: taskmgr.TaskContext | None,
timeout: float = 30,
):
"""Wait until the installed plugin is registered by the runtime.
The plugin runtime launches plugins asynchronously. If dependency installation
fails, the plugin process exits before registration; without this check the
install task can incorrectly finish successfully.
"""
if not plugin_author or not plugin_name:
return
deadline = time.time() + timeout
last_error: Exception | None = None
while time.time() < deadline:
try:
plugin = await self.get_plugin_info(plugin_author, plugin_name)
if plugin is not None:
status = plugin.get('status')
if status == 'initialized':
return
except Exception as e:
last_error = e
await asyncio.sleep(0.5)
message = self._build_plugin_startup_failure_message(plugin_author, plugin_name, task_context)
if last_error is not None:
message = f'{message} Last runtime error: {last_error}'
raise RuntimeError(message)
async def _persist_installation_package(
self,
execution_context: ExecutionContext,
*,
plugin_author: str,
plugin_name: str,
install_source: PluginInstallSource,
install_info: dict[str, Any],
artifact_digest: str,
) -> tuple[InstallationBinding, str | None, bool]:
safe_install_info = {
key: value
for key, value in install_info.items()
if key not in {'plugin_file', 'plugin_file_key'}
and key != '_artifact_storage'
and isinstance(value, (str, int, float, bool, list, dict, type(None)))
}
safe_install_info['_artifact_storage'] = _PLUGIN_ARTIFACT_STORAGE_MARKER
statement = (
sqlalchemy.select(
persistence_plugin.PluginSetting.installation_uuid,
persistence_plugin.PluginSetting.runtime_revision,
persistence_plugin.PluginSetting.artifact_digest,
persistence_plugin.PluginSetting.install_info,
)
.where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
async def persist(execute):
result = await execute(statement)
setting = result.first()
if setting is None:
installation_uuid = str(uuid.uuid4())
runtime_revision = 1
previous_digest = None
previous_was_durable = False
await execute(
sqlalchemy.insert(persistence_plugin.PluginSetting).values(
workspace_uuid=execution_context.workspace_uuid,
plugin_author=plugin_author,
plugin_name=plugin_name,
installation_uuid=installation_uuid,
artifact_digest=artifact_digest,
runtime_revision=runtime_revision,
install_source=install_source.value,
install_info=safe_install_info,
enabled=True,
priority=0,
config={},
)
)
else:
installation_uuid = setting.installation_uuid
runtime_revision = setting.runtime_revision + 1
previous_digest = setting.artifact_digest
previous_was_durable = (
isinstance(setting.install_info, dict)
and setting.install_info.get('_artifact_storage') == _PLUGIN_ARTIFACT_STORAGE_MARKER
)
await execute(
sqlalchemy.update(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
.values(
artifact_digest=artifact_digest,
runtime_revision=runtime_revision,
install_source=install_source.value,
install_info=safe_install_info,
enabled=True,
)
)
return (
InstallationBinding(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
installation_uuid=installation_uuid,
runtime_revision=runtime_revision,
artifact_digest=artifact_digest,
),
previous_digest,
previous_was_durable,
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
return await persist(uow.execute)
return await persist(self.ap.persistence_mgr.execute_async)
async def _download_github_package(
self,
install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None,
) -> bytes:
normalized = validate_github_plugin_install_info(install_info)
owner = normalized['owner']
repo = normalized['repo']
release_tag = normalized['release_tag']
async with httpx.AsyncClient(
trust_env=False,
follow_redirects=False,
timeout=httpx.Timeout(60, connect=10),
event_hooks=httpclient.httpx_response_limit_hooks(_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES),
) as client:
asset_id: int | None = None
if 'asset_id' in normalized:
release_id = normalized['release_id']
asset_id = normalized['asset_id']
metadata_url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}'
response = await client.get(
metadata_url,
headers={
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'LangBot-Plugin-Installer',
},
)
if response.status_code in _HTTP_REDIRECT_STATUSES:
raise ValueError('GitHub release metadata unexpectedly redirected')
response.raise_for_status()
release = await httpclient.parse_json_response(response)
if not isinstance(release, dict):
raise ValueError('GitHub release metadata is invalid')
if release.get('id') != release_id or str(release.get('tag_name') or '') != release_tag:
raise ValueError('GitHub release metadata does not match the requested release')
assets = release.get('assets')
if not isinstance(assets, list):
raise ValueError('GitHub release has no asset metadata')
asset = next(
(
candidate
for candidate in assets
if isinstance(candidate, dict) and candidate.get('id') == asset_id
),
None,
)
if asset is None:
raise ValueError('GitHub release asset does not belong to the requested release')
declared_size = asset.get('size')
if isinstance(declared_size, int) and declared_size > _GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError('GitHub plugin package exceeds the 10 MiB download limit')
if asset.get('state') not in {None, 'uploaded'}:
raise ValueError('GitHub release asset is not ready for download')
asset_url = f'https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}'
else:
asset_url = normalized['asset_url']
downloaded = 0
chunks: list[bytes] = []
start_time = time.time()
current_url = asset_url
if task_context is not None:
task_context.set_current_action('downloading plugin package')
task_context.metadata.update({'download_total': 0, 'download_current': 0, 'download_speed': 0})
for redirect_count in range(_GITHUB_PLUGIN_DOWNLOAD_MAX_REDIRECTS + 1):
self._validate_github_download_hop(
current_url,
owner=owner,
repo=repo,
release_tag=release_tag,
asset_id=asset_id,
)
parsed = urlparse(current_url)
request_headers = {
'Accept-Encoding': 'identity',
'User-Agent': 'LangBot-Plugin-Installer',
}
if (parsed.hostname or '').lower() == 'api.github.com':
request_headers.update(
{
'Accept': 'application/octet-stream',
'X-GitHub-Api-Version': '2022-11-28',
}
)
async with client.stream('GET', current_url, headers=request_headers) as response:
if response.status_code in _HTTP_REDIRECT_STATUSES:
location = response.headers.get('location')
if not location:
raise ValueError('GitHub release asset redirect is missing a location')
if redirect_count >= _GITHUB_PLUGIN_DOWNLOAD_MAX_REDIRECTS:
raise ValueError('GitHub release asset exceeded the redirect limit')
next_url = urljoin(current_url, location)
self._validate_github_download_hop(
next_url,
owner=owner,
repo=repo,
release_tag=release_tag,
asset_id=asset_id,
)
current_url = next_url
continue
response.raise_for_status()
content_length_header = response.headers.get('content-length')
try:
content_length = int(content_length_header) if content_length_header is not None else 0
except ValueError as exc:
raise ValueError('GitHub release asset has an invalid content length') from exc
if content_length < 0:
raise ValueError('GitHub release asset has an invalid content length')
if content_length > _GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError('GitHub plugin package exceeds the 10 MiB download limit')
if task_context is not None:
task_context.metadata['download_total'] = content_length
async for chunk in response.aiter_bytes(chunk_size=8192):
downloaded += len(chunk)
if downloaded > _GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError('GitHub plugin package exceeds the 10 MiB download limit')
chunks.append(chunk)
if task_context is not None:
elapsed = time.time() - start_time
task_context.metadata.update(
{
'download_current': downloaded,
'download_speed': downloaded / elapsed if elapsed > 0 else 0,
}
)
return b''.join(chunks)
raise ValueError('GitHub release asset exceeded the redirect limit')
@staticmethod
def _validate_github_download_hop(
url: str,
*,
owner: str,
repo: str,
release_tag: str,
asset_id: int | None,
) -> None:
"""Reject redirects away from the small set of GitHub asset hosts."""
parsed = urlparse(str(url or '').strip())
try:
port = parsed.port
except ValueError as exc:
raise ValueError('GitHub release asset URL has an invalid port') from exc
hostname = (parsed.hostname or '').lower()
if (
parsed.scheme != 'https'
or hostname not in _GITHUB_ASSET_HOSTS
or parsed.username is not None
or parsed.password is not None
or port not in {None, 443}
or parsed.fragment
):
raise ValueError('GitHub release asset redirected to an untrusted host')
if hostname == 'api.github.com':
expected_path = f'/repos/{owner}/{repo}/releases/assets/{asset_id}'
if asset_id is None or parsed.path != expected_path or parsed.query:
raise ValueError('GitHub release asset API URL is not trusted')
elif hostname == 'github.com':
validate_github_release_asset_url(
url,
owner=owner,
repo=repo,
release_tag=release_tag,
)
elif not parsed.path or parsed.path == '/':
raise ValueError('GitHub release asset redirect has no object path')
async def _download_marketplace_package(
self,
execution_context: ExecutionContext,
plugin_author: str,
plugin_name: str,
task_context: taskmgr.TaskContext | None,
) -> tuple[bytes | None, str | None]:
"""Return a plugin package, or install an MCP/skill and return none."""
space_url = self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app').rstrip('/')
async with httpx.AsyncClient(
trust_env=True,
timeout=15,
event_hooks=httpclient.httpx_response_limit_hooks(_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES),
) as client:
mcp_status, mcp_body = await _marketplace_get(
client,
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}',
max_bytes=_MARKETPLACE_METADATA_MAX_BYTES,
allow_not_found=True,
)
if mcp_status == 200:
mcp_payload = _decode_json_object(mcp_body, subject='Marketplace MCP metadata')
mcp_data = mcp_payload.get('data', {}).get('mcp', {})
if not isinstance(mcp_data, dict):
raise ValueError(f'MCP {plugin_author}/{plugin_name} metadata is invalid')
if not mcp_data.get('mode'):
raise ValueError(f'MCP {plugin_author}/{plugin_name} has no mode')
await self._install_mcp_from_marketplace(execution_context, mcp_data, task_context)
try:
async with client.stream(
'POST',
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}/install',
):
pass
except Exception as report_err:
self.ap.logger.debug(f'Failed to report MCP install: {report_err}')
return None, None
skill_status, _skill_body = await _marketplace_get(
client,
f'{space_url}/api/v1/marketplace/skills/{plugin_author}/{plugin_name}',
max_bytes=_MARKETPLACE_METADATA_MAX_BYTES,
allow_not_found=True,
)
if skill_status == 200:
_download_status, skill_package = await _marketplace_get(
client,
f'{space_url}/api/v1/marketplace/skills/download/{plugin_author}/{plugin_name}',
max_bytes=_MARKETPLACE_SKILL_DOWNLOAD_MAX_BYTES,
)
await self._install_skill_from_zip(
execution_context,
skill_package,
f'{plugin_author}-{plugin_name}',
task_context,
)
return None, None
_versions_status, versions_body = await _marketplace_get(
client,
f'{space_url}/api/v1/marketplace/plugins/{plugin_author}/{plugin_name}/versions',
max_bytes=_MARKETPLACE_METADATA_MAX_BYTES,
)
versions_payload = _decode_json_object(
versions_body,
subject='Marketplace plugin versions',
)
versions = versions_payload.get('data', {}).get('versions', [])
if (
not isinstance(versions, list)
or not versions
or not isinstance(versions[0], dict)
or not versions[0].get('version')
):
raise ValueError(f'Plugin {plugin_author}/{plugin_name} has no versions')
latest_version = str(versions[0]['version'])
_download_status, plugin_package = await _marketplace_get(
client,
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
)
return plugin_package, latest_version
async def install_plugin(
self,
install_source: PluginInstallSource,
install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None = None,
) -> None:
runtime_handler = self._runtime_handler()
execution_context = await self._current_execution_context()
plugin_author = str(install_info.get('plugin_author') or '')
plugin_name = str(install_info.get('plugin_name') or '')
file_bytes: bytes | None
if install_source == PluginInstallSource.MARKETPLACE:
file_bytes, version = await self._download_marketplace_package(
execution_context,
plugin_author,
plugin_name,
task_context,
)
if file_bytes is None:
return
install_info = {**install_info, 'plugin_version': version}
elif install_source == PluginInstallSource.LOCAL:
candidate = install_info.get('plugin_file')
if not isinstance(candidate, bytes):
raise ValueError('Local plugin package is missing')
file_bytes = candidate
elif install_source == PluginInstallSource.GITHUB:
file_bytes = await self._download_github_package(
install_info,
task_context,
)
install_info = validate_github_plugin_install_info(install_info)
else:
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
if not manifest_author or not manifest_name:
raise ValueError('Plugin package manifest identity is missing')
if plugin_author and plugin_author != manifest_author:
raise ValueError('Plugin package author does not match the requested plugin')
if plugin_name and plugin_name != manifest_name:
raise ValueError('Plugin package name does not match the requested plugin')
plugin_author, plugin_name = manifest_author, manifest_name
if task_context is not None:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
try:
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
execution_context,
plugin_author=plugin_author,
plugin_name=plugin_name,
install_source=install_source,
install_info=install_info,
artifact_digest=artifact_digest,
)
except Exception:
await self._delete_artifact_if_unreferenced(execution_context, artifact_digest)
raise
runtime_handler.register_installation_binding(
binding,
plugin_author=plugin_author,
plugin_name=plugin_name,
)
await self._apply_desired_state(
PluginInstallationDesiredState(binding=binding, enabled=True),
artifact_package=file_bytes,
)
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
self._known_desired_states[binding.installation_uuid] = desired
self._workspace_installations.setdefault(binding.workspace_uuid, set()).add(binding.installation_uuid)
if previous_digest is not None and previous_digest != artifact_digest:
await self._delete_artifact_if_unreferenced(execution_context, previous_digest)
if previous_digest is not None and not previous_was_durable and self.runtime_profile == 'oss_dev':
bridge = self._legacy_oss_bridge_binding(execution_context)
try:
with runtime_handler.installation_scope(bridge):
async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
async def upgrade_plugin(
self,
plugin_author: str,
plugin_name: str,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
_execution_context, setting = await self._setting_for_plugin(plugin_author, plugin_name)
if setting.install_source != PluginInstallSource.MARKETPLACE.value:
raise ValueError(f'Plugin {plugin_author}/{plugin_name} is not installed from marketplace')
if task_context is not None:
task_context.set_current_action('checking for latest version')
await self.install_plugin(
PluginInstallSource.MARKETPLACE,
{'plugin_author': plugin_author, 'plugin_name': plugin_name},
task_context=task_context,
)
return {}
async def delete_plugin(
self,
plugin_author: str,
plugin_name: str,
delete_data: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
execution_context, setting = await self._setting_for_plugin(plugin_author, plugin_name)
binding = self._binding_from_setting(execution_context, setting)
is_legacy_oss = self.runtime_profile == 'oss_dev' and (
not isinstance(setting.install_info, dict)
or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
)
if is_legacy_oss:
bridge = self._legacy_oss_bridge_binding(execution_context)
with runtime_handler.installation_scope(bridge):
async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
pass
await runtime_handler.remove_plugin_installation(binding)
runtime_handler.unregister_installation_binding(binding)
async def delete(execute):
await execute(
sqlalchemy.delete(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
await self._delete_artifact_if_unreferenced(
execution_context,
setting.artifact_digest,
execute=execute,
)
if delete_data:
await execute(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.owner_type == 'plugin')
.where(persistence_bstorage.BinaryStorage.owner == f'{plugin_author}/{plugin_name}')
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
await delete(uow.execute)
else:
await delete(self.ap.persistence_mgr.execute_async)
self._known_desired_states.pop(binding.installation_uuid, None)
workspace_installations = self._workspace_installations.get(binding.workspace_uuid)
if workspace_installations is not None:
workspace_installations.discard(binding.installation_uuid)
if not workspace_installations:
self._workspace_installations.pop(binding.workspace_uuid, None)
if task_context is not None:
task_context.set_current_action('plugin removed')
return {}
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
"""List plugins, optionally filtered by component kinds.
Args:
component_kinds: Optional list of component kinds to filter by.
If provided, only plugins that contain at least one
component of the specified kinds will be returned.
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
"""
if not self.is_enable_plugin or not self._runtime_available():
return []
runtime_handler = self._runtime_handler()
plugins: list[dict[str, Any]] = []
seen_plugin_ids: set[str] = set()
for binding in await self._operation_bindings():
with runtime_handler.installation_scope(binding):
scoped_plugins = await runtime_handler.list_plugins()
for plugin in scoped_plugins:
metadata = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {})
plugin_id = f'{metadata.get("author", "")}/{metadata.get("name", "")}'
if plugin_id not in seen_plugin_ids:
seen_plugin_ids.add(plugin_id)
plugins.append(plugin)
# Filter plugins by component kinds if specified
if component_kinds is not None:
filtered_plugins = []
for plugin in plugins:
components = plugin.get('components', [])
has_matching_component = False
for component in components:
component_kind = component.get('manifest', {}).get('manifest', {}).get('kind', '')
if component_kind in component_kinds:
has_matching_component = True
break
if has_matching_component:
filtered_plugins.append(plugin)
plugins = filtered_plugins
# Sort plugins: debug plugins first, then by installation time (newest first)
# Get installation timestamps from database in a single query
plugin_timestamps = {}
if plugins:
execution_context = await self._current_execution_context()
for setting in await self._load_workspace_settings(execution_context):
plugin_timestamps[f'{setting.plugin_author}/{setting.plugin_name}'] = setting.created_at
# Sort: debug plugins first (descending), then by created_at (descending)
def sort_key(plugin):
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
plugin_id = f'{author}/{name}'
is_debug = plugin.get('debug', False)
created_at = plugin_timestamps.get(plugin_id)
# Return tuple: (not is_debug, -timestamp)
# not is_debug: False (0) for debug plugins, True (1) for non-debug
# -timestamp: to sort newest first (will be None for plugins without timestamp)
timestamp_value = -created_at.timestamp() if created_at else 0
return (not is_debug, timestamp_value)
plugins.sort(key=sort_key)
return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
binding = await self._target_binding(author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_info(author, plugin_name)
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
execution_context, setting = await self._setting_for_plugin(plugin_author, plugin_name)
next_revision = setting.runtime_revision + 1
statement = (
sqlalchemy.update(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
.where(persistence_plugin.PluginSetting.runtime_revision == setting.runtime_revision)
.values(config=config, runtime_revision=next_revision)
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(execution_context.workspace_uuid) as uow:
result = await uow.execute(statement)
else:
result = await self.ap.persistence_mgr.execute_async(statement)
if result.rowcount != 1:
raise RuntimeError('Plugin configuration changed concurrently')
binding = InstallationBinding(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
installation_uuid=setting.installation_uuid,
runtime_revision=next_revision,
artifact_digest=setting.artifact_digest,
)
runtime_handler.register_installation_binding(
binding,
plugin_author=plugin_author,
plugin_name=plugin_name,
)
desired = PluginInstallationDesiredState(
binding=binding,
enabled=setting.enabled,
)
is_legacy_oss = self.runtime_profile == 'oss_dev' and (
not isinstance(setting.install_info, dict)
or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
)
if is_legacy_oss:
bridge = self._legacy_oss_bridge_binding(execution_context)
with runtime_handler.installation_scope(bridge):
await runtime_handler.set_plugin_config(plugin_author, plugin_name, config)
else:
await self._apply_desired_state(desired)
self._known_desired_states[binding.installation_uuid] = desired
return {}
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_icon(plugin_author, plugin_name)
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_readme(plugin_author, plugin_name, language)
async def get_plugin_logs(
self,
plugin_author: str,
plugin_name: str,
limit: int = 200,
level: str | None = None,
) -> list[dict[str, Any]]:
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_assets(plugin_author, plugin_name, filepath)
async def handle_page_api(
self,
plugin_author: str,
plugin_name: str,
page_id: str,
endpoint: str,
method: str,
body: Any = None,
) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
if not self.is_enable_plugin or not self._runtime_available():
return {}
return await self._runtime_handler().get_debug_info()
async def emit_event(
self,
event: events.BaseEventModel,
bound_plugins: list[str] | None = None,
) -> context.EventContext:
query = getattr(event, 'query', None)
if query is not None:
from ..pipeline.pool import get_query_execution_context
await self.require_workspace_context(get_query_execution_context(query))
event_ctx = context.EventContext.from_event(event)
if not self.is_enable_plugin or not self._runtime_available():
event_ctx._emitted_plugins = []
event_ctx._response_sources = []
return event_ctx
runtime_handler = self._runtime_handler()
emitted_plugins: list[Any] = []
response_sources: list[dict[str, Any]] = []
for binding in await self._operation_bindings(include_plugins=bound_plugins):
with runtime_handler.installation_scope(binding):
result = await runtime_handler.emit_event(
event_ctx.model_dump(serialize_as_any=False),
include_plugins=bound_plugins,
)
event_ctx = context.EventContext.model_validate(result['event_context'])
emitted_plugins.extend(result.get('emitted_plugins', []))
response_sources.extend(result.get('response_sources', []))
event_ctx._emitted_plugins = emitted_plugins
event_ctx._response_sources = response_sources
return event_ctx
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None:
"""Best-effort diagnostic forwarding to the plugin runtime."""
if not self.is_enable_plugin or not self._runtime_available():
return
try:
runtime_handler = self._runtime_handler()
plugin_ref = diagnostic.get('plugin') if isinstance(diagnostic, dict) else None
if isinstance(plugin_ref, dict):
author = plugin_ref.get('author') or plugin_ref.get('plugin_author')
name = plugin_ref.get('name') or plugin_ref.get('plugin_name')
if author and name:
binding = await self._target_binding(str(author), str(name))
with runtime_handler.installation_scope(binding):
await runtime_handler.notify_plugin_diagnostic(diagnostic)
return
for binding in await self._operation_bindings():
with runtime_handler.installation_scope(binding):
await runtime_handler.notify_plugin_diagnostic(diagnostic)
except Exception as e:
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin or not self._runtime_available():
return []
runtime_handler = self._runtime_handler()
tools: list[ComponentManifest] = []
seen: set[tuple[str, str]] = set()
for binding in await self._operation_bindings(include_plugins=bound_plugins):
with runtime_handler.installation_scope(binding):
scoped = await runtime_handler.list_tools(include_plugins=bound_plugins)
for raw_tool in scoped:
tool = ComponentManifest.model_validate(raw_tool)
key = (str(tool.owner), tool.metadata.name)
if key not in seen:
seen.add(key)
tools.append(tool)
return tools
async def call_tool(
self,
tool_name: str,
parameters: dict[str, Any],
session: provider_session.Session,
query_id: int,
bound_plugins: list[str] | None = None,
query_uuid: str | None = None,
) -> dict[str, Any]:
if not self.is_enable_plugin:
return {'error': 'Tool not found: plugin system is disabled'}
await self.require_workspace_context(
ExecutionContext(
instance_uuid=session.instance_uuid,
workspace_uuid=session.workspace_uuid,
placement_generation=session.placement_generation,
query_uuid=query_uuid,
bot_uuid=session.bot_uuid,
)
)
binding = await self._target_binding_for_component(
tool_name,
component_kind='tool',
include_plugins=bound_plugins,
)
runtime_handler = self._runtime_handler()
with runtime_handler.installation_scope(binding):
return await runtime_handler.call_tool(
tool_name,
parameters,
session.model_dump(serialize_as_any=True),
query_id,
query_uuid=query_uuid,
include_plugins=bound_plugins,
)
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin or not self._runtime_available():
return []
runtime_handler = self._runtime_handler()
commands: list[ComponentManifest] = []
seen: set[tuple[str, str]] = set()
for binding in await self._operation_bindings(include_plugins=bound_plugins):
with runtime_handler.installation_scope(binding):
scoped = await runtime_handler.list_commands(include_plugins=bound_plugins)
for raw_command in scoped:
command = ComponentManifest.model_validate(raw_command)
key = (str(command.owner), command.metadata.name)
if key not in seen:
seen.add(key)
commands.append(command)
return commands
async def execute_command(
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
if not self.is_enable_plugin or not self._runtime_available():
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
return
await self.require_workspace_context(
ExecutionContext(
instance_uuid=command_ctx.instance_uuid,
workspace_uuid=command_ctx.workspace_uuid,
placement_generation=command_ctx.placement_generation,
query_uuid=command_ctx.query_uuid,
)
)
binding = await self._target_binding_for_component(
command_ctx.command,
component_kind='command',
include_plugins=bound_plugins,
)
runtime_handler = self._runtime_handler()
with runtime_handler.installation_scope(binding):
gen = runtime_handler.execute_command(
command_ctx.model_dump(serialize_as_any=True),
include_plugins=bound_plugins,
)
async for ret in gen:
yield command_context.CommandReturn.model_validate(ret)
async def retrieve_knowledge(
self,
plugin_author: str,
plugin_name: str,
retriever_name: str,
retrieval_context: dict[str, Any],
) -> dict[str, Any]:
"""Retrieve knowledge using a KnowledgeEngine instance."""
if not self.is_enable_plugin or not self._runtime_available():
return {'results': []}
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.retrieve_knowledge(
plugin_author,
plugin_name,
retriever_name,
retrieval_context,
)
def dispose(self):
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
self._closing = True
if self.heartbeat_task is not None:
self.heartbeat_task.cancel()
self.heartbeat_task = None
if self._reconnect_task is not None:
self._reconnect_task.cancel()
self._reconnect_task = None
for task in (
getattr(self, 'handler_task', None),
self._transport_task,
):
if task is not None:
task.cancel()
ctrl = getattr(self, 'ctrl', None)
process = getattr(ctrl, 'process', None)
if process is not None and process.returncode is None:
process.terminate()
self._dispose_subprocess()
@staticmethod
def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:
"""Parse a plugin ID string into (author, name).
Args:
plugin_id: Plugin ID in 'author/name' format.
Returns:
Tuple of (plugin_author, plugin_name).
Raises:
ValueError: If plugin_id is not in the expected 'author/name' format.
"""
segments = plugin_id.split('/')
if len(segments) != 2 or not all(segments):
raise ValueError(
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
)
return segments[0], segments[1]
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
"""Call plugin to ingest document.
Args:
plugin_id: Target plugin ID (author/name).
context_data: IngestionContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.rag_ingest_document(plugin_author, plugin_name, context_data)
async def call_rag_delete_document(self, plugin_id: str, document_id: str, kb_id: str) -> bool:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_rag_creation_schema(plugin_author, plugin_name)
async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_rag_retrieval_schema(plugin_author, plugin_name)
async def rag_on_kb_create(self, plugin_id: str, kb_id: str, config: dict[str, Any]) -> dict[str, Any]:
"""Notify plugin about KB creation."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]:
"""Notify plugin about KB deletion."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.rag_on_kb_delete(plugin_author, plugin_name, kb_id)
async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]:
"""Call plugin to retrieve knowledge.
Args:
plugin_id: Target plugin ID (author/name).
retrieval_context: RetrievalContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
async def list_knowledge_engines(self) -> list[dict[str, Any]]:
"""List all available Knowledge Engines from plugins.
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
"""
if not self.is_enable_plugin or not self._runtime_available():
return []
runtime_handler = self._runtime_handler()
engines: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for binding in await self._operation_bindings():
with runtime_handler.installation_scope(binding):
scoped = await runtime_handler.list_knowledge_engines()
for engine in scoped:
key = (str(engine.get('plugin_id', '')), str(engine.get('name', '')))
if key not in seen:
seen.add(key)
engines.append(engine)
return engines
async def list_parsers(self) -> list[dict[str, Any]]:
"""List all available parsers from plugins."""
if not self.is_enable_plugin or not self._runtime_available():
return []
runtime_handler = self._runtime_handler()
parsers: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for binding in await self._operation_bindings():
with runtime_handler.installation_scope(binding):
scoped = await runtime_handler.list_parsers()
for parser in scoped:
key = (str(parser.get('plugin_id', '')), str(parser.get('name', '')))
if key not in seen:
seen.add(key)
parsers.append(parser)
return parsers
async def call_parser(self, plugin_id: str, context_data: dict[str, Any], file_bytes: bytes) -> dict[str, Any]:
"""Call plugin to parse a document."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
runtime_handler = self._runtime_handler()
binding = await self._target_binding(plugin_author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.parse_document(plugin_author, plugin_name, context_data, file_bytes)