feat(api): provide Workspace and execution API foundations

This commit is contained in:
huanghuoguoguo
2026-10-29 12:45:02 +08:00
parent 31bcd673e7
commit 7295639423
38 changed files with 8099 additions and 1865 deletions
+4
View File
@@ -258,6 +258,10 @@ marked for removal in the next major version. Start LangBot with
## HTTP API, Web UI, and MCP Server
The [Application API foundation](dev-docs/architecture/application-api.md) provides
versioned Workspace and execution-context operations for future consumer adoption.
Existing HTTP/MCP/assistant entrypoints retain their current service paths.
`pkg/api/http/controller/main.py` builds a Quart app, registers route groups, serves the built SPA, and wraps the ASGI app with the MCP dispatcher.
- HTTP route groups live under `pkg/api/http/controller/groups/`.
+117
View File
@@ -0,0 +1,117 @@
# Application API foundation
The application package provides a callable business boundary inside Core. A
consumer supplies a trusted identity and operation arguments, then receives a
validated result or a stable application error. HTTP, MCP, the management
assistant and `lbctl` retain their existing entrypoints in this change; their
adoption of this boundary belongs in separate changes.
## Workspace operations
`ap.application_api.operation_catalog()` returns the explicit operation allowlist,
with version, permission, principal constraints, side effects, execution mode,
and JSON schemas for inputs and results. The catalog covers the existing
Workspace management capabilities:
| Area | Capabilities |
| --- | --- |
| Pipelines and agents | CRUD, metadata, extension configuration, migration preview/execute, diagnostic runs, run history and events |
| Models and providers | LLM/embedding/rerank CRUD and tests, defaults, discovery, provider credential authorization |
| Bots and platforms | CRUD, routes and dry runs, messages, administrators, adapter metadata and credential setup sessions |
| Knowledge | Bases, ingestion tasks, files, retrieval, engine schemas and parser discovery |
| Extensions | Plugin installation/configuration/assets/logs, MCP servers/resources, skills and files, tools |
| Workspace | Current workspace, language, membership, invitations, API keys and webhooks |
| Operations | Monitoring and exports, governance and audit, task status, Box visibility, setup wizard and storage statistics |
| Conversations | Management-assistant conversations/runs and Pipeline chat history, reset, connections and broadcast |
Service-backed operations reuse existing business signatures to build strict
Pydantic inputs. Only methods explicitly listed in `service_operations.py` are
exposed; adding a public service method does not publish an operation. Domain
configuration objects remain dictionaries because their schemas are owned by
providers, plugins or the existing resource services. Those services retain
validation, quotas, secret placeholder restoration and runtime side effects.
```python
# context is established by a trusted authenticator, never from request JSON.
result = await ap.application_api.execute(
'pipeline.get', context, {'pipeline_uuid': pipeline_uuid}
)
async for frame in ap.application_api.stream(
'agent.debug.stream', context,
{'agent_uuid': agent_uuid, 'payload': {'text': 'hello'}},
):
consume(frame)
```
The Workspace comes only from `RequestContext`. Each invocation checks the
operation permission and the current execution binding/generation, then carries
that tenant and its message language through the business call. Additional
identity constraints remain where required by the business service: membership,
interactive provider authorization, assistant conversations and Pipeline
migration require an account. API-key grants are not replaced by the creator's
role. Authentication, key revocation/expiry and current account membership must
be resolved by the trusted caller before constructing the context.
Secret reads require the corresponding secret-management permission. Mutating
operations preserve the domain's existing authorization and resource ownership
checks. Audit recording uses the existing Workspace configuration and bounded
writer, with operation identity and outcome; raw arguments are not copied into
the generic trace. Queued tasks retain their Workspace/generation and revalidate
runtime admission before accessing plugins.
`ApplicationAPIException.error` carries `code`, `message`, `status_code` and
optional structured `details`. Protocol adapters map that object to their own
response shape. Invalid inputs and outputs fail explicitly. Cancellation is not
converted into success, and writes are never automatically retried. Pipeline
run timeouts retain the existing `unknown` outcome: a timeout does not establish
that execution failed or was rolled back.
Streaming scopes live in isolated producer tasks with bounded queues. Closing
an Agent debug stream cancels its execution. Management-assistant streaming
retains its existing detached-run behavior; use the explicit stop operation to
cancel an assistant run. Consumers should close partially consumed iterators
with `contextlib.aclosing`.
## Execution-context operations
`ExecutionAPI` publishes the existing Core business actions available to an
installed plugin or a running processor. Its catalog describes each action's
input/output schema, streaming mode and required plugin/run scope. It includes
model and RAG access, tools, event replies and platform actions, state/history,
run control and interactions, and Box/file operations.
```python
from langbot.pkg.api.application.execution import ExecutionAPI, ExecutionAPIContext
api = ExecutionAPI(ap)
catalog = api.catalog()
context = ExecutionAPIContext(binding=trusted_installation_binding, run_id=run_id)
result = await api.invoke(operation_id, context, arguments)
```
The caller must supply the trusted installation binding established by Core.
The foundation invokes the existing secured action dispatcher, preserving
installation revision/grant checks, active placement and frozen run resources.
A run-bound context also verifies that the run belongs to that installation's
Workspace and plugin. Arguments cannot override the run ID or fall back to the
legacy plugin path by omitting it. Plugin-only operations remain explicit and
cannot be called with a run-bound context. Stream isolation also prevents one
consumer's context from escaping into another consumer's task.
## Scope and adoption
This is the full **existing Workspace and execution business capability**
foundation. Instance administration, account login/bootstrap, public ingress,
OAuth callbacks, transport assets and internal Runtime handshake/chunk/ping
messages remain with their owning boundaries. Composite HTTP response layouts
(such as a combined monitoring page) are consumer projections of these
operations. This does not add new platform APIs or new instance-level powers.
Existing routes do not call the new application dispatcher. No SDK protocol or `lbctl` command is
changed by this foundation.
Consumer adoption should verify the same operation through each selected
transport, including denied access, missing resources, invalid inputs,
cancellation and secret handling. The existing Core smoke exercises current
consumers; it does not by itself prove a new consumer integration.
@@ -0,0 +1,6 @@
"""Public foundation for Workspace and execution-context operations."""
from .api import ApplicationAPI
from .contracts import ApplicationAPIError, ApplicationAPIException, Input, Operation
__all__ = ['ApplicationAPI', 'ApplicationAPIError', 'ApplicationAPIException', 'Input', 'Operation']
+283
View File
@@ -0,0 +1,283 @@
"""Workspace application boundary; protocol consumers opt in separately."""
from __future__ import annotations
from collections.abc import Iterable
from contextlib import asynccontextmanager, aclosing
import time
import asyncio
import logging
from pydantic import TypeAdapter, ValidationError
from ..authz import AuthenticationDeniedError, require_permission
from ..context import PrincipalType, RequestContext
from ...i18n import language_scope
from ...i18n.workspace import get_workspace_language
from ...utils.bounded_executor import blocking_work_scope
from .contracts import ApplicationAPIError, ApplicationAPIException, Input, Operation
from .streaming import isolated_stream
class ApplicationAPI:
def __init__(self, ap, *, operations: Iterable[Operation] | None = None):
self.ap = ap
if operations is None:
from .service_operations import operations as service_operations
from .auxiliary import operations as auxiliary_operations
from .plugin_operations import operations as plugin_operations
from .platform_setup import operations as platform_operations
from .connection_operations import operations as connection_operations
from .workspace_streams import operations as workspace_streams
operations = [
*service_operations(),
*auxiliary_operations(),
*plugin_operations(),
*platform_operations(),
*connection_operations(),
*workspace_streams(),
]
self._operations: dict[str, Operation] = {}
for spec in operations:
if spec.operation_id in self._operations:
raise ValueError(f'Duplicate application operation: {spec.operation_id}')
self._operations[spec.operation_id] = spec
def operation_catalog(self) -> list[dict]:
return [spec.as_dict() for spec in self._operations.values()]
def _operation(self, operation_id, streaming):
spec = self._operations.get(operation_id)
if spec is None:
raise ApplicationAPIException(
ApplicationAPIError('unknown_operation', 'Unknown application operation', 404)
)
if spec.streaming != streaming:
raise ApplicationAPIException(
ApplicationAPIError('invalid_execution_mode', 'Use the operation execution mode', 400)
)
return spec
@asynccontextmanager
async def _scope(self, spec, context, input):
if not isinstance(context, RequestContext) or not context.workspace_uuid.strip():
raise AuthenticationDeniedError('An authenticated Workspace context is required')
if context.principal.principal_type not in {
PrincipalType.ACCOUNT,
PrincipalType.API_KEY,
PrincipalType.SUPPORT_ADMIN,
}:
raise AuthenticationDeniedError('This principal cannot invoke Workspace management operations')
if spec.principals is not None and context.principal.principal_type not in spec.principals:
raise AuthenticationDeniedError('This operation requires a different principal')
require_permission(context, spec.permission)
try:
payload = spec.input_type.model_validate(input)
except ValidationError as exc:
# Validation errors may contain credentials; never include their input values.
fields = ', '.join('.'.join(map(str, e['loc'])) for e in exc.errors(include_input=False))
raise ApplicationAPIException(
ApplicationAPIError('invalid_input', f'Invalid fields: {fields}', 400)
) from exc
binding = await self.ap.workspace_service.get_execution_binding(
context.workspace_uuid,
expected_generation=context.placement_generation,
)
if binding.instance_uuid != context.instance_uuid:
raise AuthenticationDeniedError('Workspace belongs to a different instance')
async with self.ap.persistence_mgr.tenant_scope(context.workspace_uuid):
with blocking_work_scope(context.workspace_uuid):
with language_scope(await get_workspace_language(self.ap, context.workspace_uuid)):
yield payload
@staticmethod
def _validate_result(spec, result):
try:
return TypeAdapter(spec.output_type).validate_python(result, strict=True)
except ValidationError as exc:
raise ApplicationAPIException(
ApplicationAPIError('invalid_output', 'Application operation returned an invalid result', 500)
) from exc
async def execute(self, operation_id: str, context: RequestContext, input: dict | Input):
spec = self._operation(operation_id, streaming=False)
observed_at = time.monotonic()
payload = None
started = False
outcome, status = 'ok', 200
try:
async with self._scope(spec, context, input) as payload:
started = True
result = await spec.handler(self.ap, context, payload)
return self._validate_result(spec, result)
except asyncio.CancelledError:
outcome, status = ('unknown' if started and spec.side_effect else 'error'), 499
raise
except Exception as exc:
from .errors import error_from_exception
error = error_from_exception(exc, operation_id=operation_id)
outcome, status = self._failure_outcome(spec, error, started), error.status_code
if isinstance(exc, ApplicationAPIException):
raise
raise ApplicationAPIException(error) from exc
finally:
await self._record(spec, context, observed_at, payload, outcome=outcome, status=status, started=started)
async def stream(self, operation_id: str, context: RequestContext, input: dict | Input):
spec = self._operation(operation_id, streaming=True)
observed_at = time.monotonic()
async def produce():
payload = None
started = False
outcome, status = 'ok', 200
try:
async with self._scope(spec, context, input) as payload:
started = True
source = await spec.handler(self.ap, context, payload)
async with aclosing(source):
async for item in source:
yield self._validate_result(spec, item)
except (asyncio.CancelledError, GeneratorExit):
outcome, status = ('unknown' if started and spec.side_effect else 'error'), 499
raise
except Exception as exc:
from .errors import error_from_exception
error = error_from_exception(exc, operation_id=operation_id)
outcome, status = self._failure_outcome(spec, error, started), error.status_code
if isinstance(exc, ApplicationAPIException):
raise
raise ApplicationAPIException(error) from exc
finally:
await self._record(spec, context, observed_at, payload, outcome=outcome, status=status, started=started)
async with aclosing(isolated_stream(produce())) as responses:
async for response in responses:
yield response
@staticmethod
def _failure_outcome(spec, error, started):
if error.status_code in {401, 403}:
return 'denied'
if started and spec.side_effect and error.code in {'invalid_output', 'application_error', 'bot_apply_failed'}:
return 'unknown'
return 'error'
async def _record(self, spec, context, observed_at, payload=None, *, outcome='ok', status=200, started=False):
from ..context import PrincipalContext, WorkspaceContext
if not (
isinstance(context, RequestContext)
and isinstance(context.principal, PrincipalContext)
and isinstance(context.workspace, WorkspaceContext)
and isinstance(context.workspace_uuid, str)
and context.workspace_uuid.strip()
and context.instance_uuid
and type(context.placement_generation) is int
and context.placement_generation > 0
and context.principal.principal_type
in {
PrincipalType.ACCOUNT,
PrincipalType.API_KEY,
PrincipalType.SUPPORT_ADMIN,
}
):
return
principal_id = (
context.principal.api_key_uuid
if context.principal.principal_type == PrincipalType.API_KEY
else context.principal.actor_account_uuid
if context.principal.principal_type == PrincipalType.SUPPORT_ADMIN
else context.account_uuid
)
if not isinstance(principal_id, str) or not principal_id.strip():
return
# These successful handlers already emit their richer domain records.
if outcome == 'ok' and spec.operation_id in {
'workspace.member.update',
'workspace.member.remove',
'workspace.invitation.create',
'settings.governance.update',
}:
return
service = getattr(self.ap, 'workspace_settings_service', None)
if service is None:
return
from ...operation_trace.service import ActionRule, bucket_allows
try:
if not started:
# Authorization can fail before the normal execution fence. Never
# publish a trace into an unknown or foreign Workspace.
binding = await self.ap.workspace_service.get_execution_binding(
context.workspace_uuid,
expected_generation=context.placement_generation,
)
if binding.instance_uuid != context.instance_uuid:
return
level = await service.effective_level(context.workspace_uuid, context.workspace.role)
bucket = 'write' if spec.side_effect else 'read'
if not bucket_allows(bucket, level):
return
parts = spec.operation_id.split('.')
if not spec.side_effect:
action = 'export' if 'export' in parts else 'view'
elif any(part in {'delete', 'remove', 'revoke', 'uninstall'} for part in parts):
action = 'delete'
elif any(part in {'create', 'install', 'upload', 'copy', 'add', 'import'} for part in parts):
action = 'create'
else:
action = 'update'
# Only named resource identifiers from validated DTOs enter the trace.
# Config objects, file bytes, URLs, credentials and request text never do.
identity = {}
if isinstance(payload, Input):
for name in type(payload).model_fields:
if name.endswith(('_uuid', '_id')) or name in {
'author',
'plugin_author',
'plugin_name',
'skill_name',
'server_name',
'owner',
'repo',
}:
value = getattr(payload, name)
if type(value) in {str, int} and value != '':
identity[name] = str(value)[:256]
resource_id = None
for names in (('author', 'plugin_name'), ('plugin_author', 'plugin_name'), ('owner', 'repo')):
if all(name in identity for name in names):
resource_id = '/'.join(identity[name] for name in names)
break
if resource_id is None and identity:
resource_id = next(iter(identity.values()))
await service.record(
context.workspace_uuid,
rule=ActionRule(action, 'resource', bucket, parts[0]),
level=level,
actor_account_uuid=context.principal.actor_account_uuid or context.account_uuid,
actor_role=context.workspace.role,
principal_type=context.principal.principal_type.value,
api_key_uuid=context.principal.api_key_uuid,
auth_type=context.auth_type,
request_id=context.request_id,
route=spec.operation_id,
resource_id=resource_id,
outcome=outcome,
status_code=status,
detail={'operation': spec.operation_id, 'version': spec.version},
duration_ms=int((time.monotonic() - observed_at) * 1000),
)
except Exception:
# Audit failures must preserve the original operation result or error.
try:
getattr(self.ap, 'logger', logging.getLogger(__name__)).exception('Application operation trace failed')
except Exception:
pass
async def invoke(self, operation_id: str, context: RequestContext, input: dict | Input):
return await self.execute(operation_id, context, input)
@@ -0,0 +1,694 @@
"""Workspace governance and resource operations without transport dependencies."""
from __future__ import annotations
import datetime
import json
import mimetypes
import uuid
from contextlib import asynccontextmanager
from typing import Literal
import sqlalchemy
from pydantic import Field
from .contracts import Input, Operation
from ..authz import Permission, PermissionDeniedError
from ..context import ExecutionContext, PrincipalType
from ..secrets import redact_secrets
from ...entity.persistence.metadata import WorkspaceMetadata
from ...i18n import SUPPORTED_LANGUAGES
from ...i18n.workspace import get_workspace_language, set_workspace_language
from ...operation_trace import service as tracing
from ...pipeline.extension_preferences import normalize_extension_preferences
from ...utils import constants
from ...workspace.invitation_delivery import InvitationDeliveryService
class Empty(Input):
pass
class Language(Input):
language: str
class Member(Input):
account_uuid: str
class MemberRole(Member):
role: Literal['admin', 'developer', 'operator', 'viewer']
class Invite(Input):
email: str
role: Literal['admin', 'developer', 'operator', 'viewer'] = 'viewer'
class Invitation(Input):
invitation_uuid: str
class Governance(Input):
level: int | None = None
retention_days: int | None = None
max_rows: int | None = None
dedupe_window_seconds: int | None = None
class LogFilters(Input):
action: str | None = None
resource_type: str | None = None
actor_account_uuid: str | None = None
level: int | None = None
since: datetime.datetime | None = None
until: datetime.datetime | None = None
class LogPage(LogFilters):
limit: int = Field(default=100, ge=1)
offset: int = Field(default=0, ge=0)
integrity: str | None = None
search: str | None = None
class TaskList(Input):
task_type: str | None = None
task_kind: str | None = None
class TaskGet(Input):
task_id: int = Field(ge=0)
class Upload(Input):
filename: str = Field(min_length=1)
content: bytes = Field(max_length=10 * 1024 * 1024)
class ImageGet(Input):
file_key: str
class ToolList(Input):
pipeline_uuid: str | None = None
class ToolGet(ToolList):
tool_name: str
class RequesterList(Input):
model_type: str = ''
class RequesterGet(Input):
requester_name: str
class AdapterGet(Input):
adapter_name: str
class WizardStatus(Input):
status: Literal['completed', 'skipped'] = 'completed'
class WizardProgress(Input):
step: int = Field(ge=0)
selected_scenario: str | None = None
selected_adapter: str | None = None
created_bot_uuid: str | None = None
bot_saved: bool = False
selected_runner: str | None = None
def _workspace(row):
return {key: getattr(row, key) for key in ('uuid', 'instance_uuid', 'name', 'slug', 'type', 'status', 'source')}
def _membership(row, *, display_name='', email=''):
value = {key: getattr(row, key) for key in ('uuid', 'workspace_uuid', 'account_uuid', 'role', 'status')}
value.update(display_name=display_name, email=email)
for key in ('joined_at', 'created_at'):
timestamp = getattr(row, key)
value[key] = timestamp.isoformat() if timestamp else None
return value
def _invitation(row):
value = {key: getattr(row, key) for key in ('uuid', 'workspace_uuid', 'normalized_email', 'role', 'status')}
for key in ('expires_at', 'created_at'):
timestamp = getattr(row, key)
value[key] = timestamp.isoformat() if timestamp else None
return value
@asynccontextmanager
async def _collaboration(ap, context):
# Collaboration mutators require a tenant transaction in cloud runtime.
if getattr(getattr(ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
async with ap.persistence_mgr.tenant_uow(context.workspace_uuid):
yield
else:
yield
async def _actor(ap, context):
if context.principal.principal_type != PrincipalType.ACCOUNT or not context.account_uuid:
raise PermissionDeniedError('account_membership_required')
access = await ap.workspace_collaboration_service.resolve_account_workspace(
context.account_uuid, context.workspace_uuid
)
if access.membership.uuid != context.workspace.membership_uuid:
raise PermissionDeniedError('account_membership_required')
return access.membership
async def workspace_get(ap, context, payload):
return _workspace(await ap.workspace_service.get_workspace(context.workspace_uuid))
async def workspace_current(ap, context, payload):
workspace = await ap.workspace_service.get_workspace(context.workspace_uuid)
result = {
'workspace': _workspace(workspace),
'permissions': sorted(context.workspace.permissions),
'placement_generation': context.placement_generation,
'membership': None,
'plan_name': None,
'current_plan': None,
'limitation': None,
}
if context.principal.principal_type == PrincipalType.ACCOUNT:
member = await _actor(ap, context)
account = await ap.user_service.get_user_by_uuid(context.account_uuid)
result['membership'] = _membership(member, display_name=account.user, email=account.normalized_email)
resolver = getattr(ap, 'entitlement_resolver', None)
if workspace.source == 'cloud_projection' and resolver is not None:
from ..http.service.workspace_limits import LIMIT_KEYS
entitlement = await resolver.resolve(workspace.uuid, minimum_revision=context.entitlement_revision)
result['plan_name'] = entitlement.plan_name
result['limitation'] = {key: entitlement.limit(value) for key, value in LIMIT_KEYS.items()}
if entitlement.plan_revision is not None and entitlement.monthly_bonus_credits is not None:
result['current_plan'] = {
'name': entitlement.plan_name,
'subscription_started_at': entitlement.subscription_started_at,
'revision': entitlement.plan_revision,
'monthly_bonus_credits': entitlement.monthly_bonus_credits,
'limits': entitlement.limits,
'entitlement_features': entitlement.features,
}
return result
async def language_get(ap, context, payload):
return {
'language': await get_workspace_language(ap, context.workspace_uuid, refresh=True),
'supported_languages': list(SUPPORTED_LANGUAGES),
}
async def language_update(ap, context, payload):
return {'language': await set_workspace_language(ap, context.workspace_uuid, payload.language)}
async def members_list(ap, context, payload):
async with _collaboration(ap, context):
rows = await ap.workspace_collaboration_service.list_members(context.workspace_uuid, await _actor(ap, context))
return [_membership(row.membership, display_name=row.display_name, email=row.email) for row in rows]
async def _record_change(ap, context, action, changes, resource_id=None):
service = ap.workspace_settings_service
rule = tracing.ACTION_RULES_BY_ACTION[action]
await service.record(
context.workspace_uuid,
rule=rule,
level=max(
await service.effective_level(context.workspace_uuid, context.workspace.role),
tracing.OPERATION_LEVEL_MUTATION,
)
if action == 'settings_update'
else await service.effective_level(context.workspace_uuid, context.workspace.role),
actor_account_uuid=context.account_uuid,
actor_role=context.workspace.role,
principal_type=context.principal.principal_type.value,
api_key_uuid=context.principal.api_key_uuid,
auth_type=context.auth_type,
request_id=context.request_id,
resource_id=resource_id,
outcome='ok',
summary=tracing.build_summary(rule, changes),
changes=changes,
)
async def member_update(ap, context, payload):
async with _collaboration(ap, context):
actor = await _actor(ap, context)
members = await ap.workspace_collaboration_service.list_members(context.workspace_uuid, actor)
before = next(
(row.membership.role for row in members if row.membership.account_uuid == payload.account_uuid), None
)
member = await ap.workspace_collaboration_service.update_member_role(
context.workspace_uuid, payload.account_uuid, payload.role, actor
)
account = await ap.user_service.get_user_by_uuid(member.account_uuid)
result = _membership(
member, display_name=account.user if account else '', email=account.normalized_email if account else ''
)
await _record_change(
ap,
context,
'member_role_update',
[{'field': 'role', 'before': before, 'after': member.role}],
payload.account_uuid,
)
return result
async def member_remove(ap, context, payload):
async with _collaboration(ap, context):
actor = await _actor(ap, context)
member = await ap.workspace_collaboration_service.remove_member(
context.workspace_uuid, payload.account_uuid, actor
)
await _record_change(
ap,
context,
'member_remove',
[{'field': 'member', 'before': f'{member.account_uuid}:{member.role}', 'after': 'removed'}],
member.account_uuid,
)
return {'account_uuid': member.account_uuid}
async def invitations_list(ap, context, payload):
async with _collaboration(ap, context):
rows = await ap.workspace_collaboration_service.list_invitations(
context.workspace_uuid, await _actor(ap, context)
)
return [_invitation(row) for row in rows]
async def invitation_create(ap, context, payload):
async with _collaboration(ap, context):
created = await ap.workspace_collaboration_service.create_invitation(
context.workspace_uuid, await _actor(ap, context), payload.email, payload.role
)
await _record_change(
ap,
context,
'member_invite',
[
{
'field': 'invitation',
'before': None,
'after': f'{created.invitation.normalized_email}:{created.invitation.role}',
}
],
created.invitation.uuid,
)
delivery_service = getattr(ap, 'invitation_delivery_service', None) or InvitationDeliveryService(ap)
link = delivery_service.build_invitation_link(created.token)
workspace = await ap.workspace_service.get_workspace(context.workspace_uuid)
delivery = await delivery_service.deliver_invitation(
recipient_email=created.invitation.normalized_email, workspace_name=workspace.name, invitation_link=link
)
return {
'invitation': _invitation(created.invitation),
'token': created.token,
'link': link,
'delivery': delivery.to_public_dict(),
}
async def invitation_revoke(ap, context, payload):
async with _collaboration(ap, context):
row = await ap.workspace_collaboration_service.revoke_invitation(
context.workspace_uuid, payload.invitation_uuid, await _actor(ap, context)
)
return _invitation(row)
async def governance_get(ap, context, payload):
return await ap.workspace_settings_service.describe_governance(context.workspace_uuid)
async def governance_update(ap, context, payload):
if not tracing.role_may_configure(context.workspace.role):
raise PermissionDeniedError('workspace_owner_or_admin_required')
service = ap.workspace_settings_service
before = await service.describe_governance(context.workspace_uuid)
options = payload.model_dump(exclude={'level'})
result = await service.set_operation_level(
context.workspace_uuid, payload.level if payload.level is not None else before['configured_level'], **options
)
keys = ('configured_level', 'retention_days', 'max_rows', 'dedupe_window_seconds')
changes = tracing.changed_fields({key: before[key] for key in keys}, {key: result[key] for key in keys})
if changes:
await _record_change(ap, context, 'settings_update', changes)
return result
def _log_filters(payload):
values = payload.model_dump()
for key in ('since', 'until'):
if values[key] is not None and values[key].tzinfo is not None:
values[key] = values[key].astimezone(datetime.UTC).replace(tzinfo=None)
return values
async def logs_list(ap, context, payload):
return await ap.workspace_settings_service.query_logs(context.workspace_uuid, **_log_filters(payload))
async def logs_export(ap, context, payload):
return await ap.workspace_settings_service.export_logs(context.workspace_uuid, **_log_filters(payload))
async def logs_filters(ap, context, payload):
options = await ap.workspace_settings_service.list_filter_options(context.workspace_uuid)
options['governance'] = await ap.workspace_settings_service.describe_governance(context.workspace_uuid)
return options
def _task_scope(context):
return {
'instance_uuid': context.instance_uuid,
'workspace_uuid': context.workspace_uuid,
'placement_generation': context.placement_generation,
}
async def tasks_list(ap, context, payload):
return ap.task_mgr.get_tasks_dict(
payload.task_type or None,
payload.task_kind or None,
**_task_scope(context),
public=context.principal.principal_type == PrincipalType.API_KEY,
)
async def task_get(ap, context, payload):
task = ap.task_mgr.get_task_by_id(payload.task_id, **_task_scope(context))
if task is None:
raise FileNotFoundError('Task not found')
return task.to_public_dict() if context.principal.principal_type == PrincipalType.API_KEY else task.to_dict()
async def _upload(ap, context, payload, *, image):
if '/' in payload.filename or '\\' in payload.filename or '\x00' in payload.filename:
raise ValueError('File name contains invalid characters')
extension = payload.filename.rsplit('.', 1)[1] if '.' in payload.filename else ''
if image:
extension = extension.lower()
if extension not in {'jpg', 'jpeg', 'png', 'gif', 'webp'}:
raise ValueError('Invalid image format')
owner = (
f'account:{context.account_uuid}'
if context.account_uuid
else f'api-key:{context.principal.api_key_uuid}'
if context.principal.api_key_uuid
else f'principal:{context.principal.principal_type.value}'
)
key = str(uuid.uuid4()) + (f'.{extension}' if extension else '')
return await ap.storage_mgr.save_scoped(
context, owner_type='upload_image' if image else 'upload_document', owner=owner, key=key, value=payload.content
)
async def image_upload(ap, context, payload):
return {'file_key': await _upload(ap, context, payload, image=True)}
async def document_upload(ap, context, payload):
return {'file_id': await _upload(ap, context, payload, image=False)}
async def image_get(ap, context, payload):
# The foundation reads only this Workspace, including keys otherwise publicly addressable.
storage = ap.storage_mgr
if storage.is_scoped_object_key(payload.file_key, expected_owner_type='upload_image'):
owner_type = 'upload_image'
elif storage.is_scoped_object_key(payload.file_key, expected_owner_type='bot_log'):
owner_type = 'bot_log'
else:
raise FileNotFoundError('Image not found')
content = await storage.load_scoped_object_key(context, payload.file_key, expected_owner_type=owner_type)
return {'content': content, 'media_type': mimetypes.guess_type(payload.file_key)[0] or 'image/jpeg'}
async def box_status(ap, context, payload):
status = await ap.box_service.get_status(context)
return {**status, 'hidden': constants.edition == 'cloud' and status.get('enabled') is False}
async def box_sessions(ap, context, payload):
return await ap.box_service.get_sessions(context)
async def box_errors(ap, context, payload):
if ap.box_service.managed_admission_required:
await ap.box_service.require_workspace_sandbox(context)
return ap.box_service.get_recent_errors(context)
async def tools_list(ap, context, payload):
bound_plugins = bound_mcp_servers = None
if payload.pipeline_uuid:
pipeline = await ap.pipeline_service.get_pipeline(context, payload.pipeline_uuid)
if pipeline is None:
raise FileNotFoundError('Pipeline not found')
prefs = normalize_extension_preferences(pipeline.get('extensions_preferences'))
if not prefs['enable_all_plugins']:
bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in prefs['plugins']]
if not prefs['enable_all_mcp_servers']:
bound_mcp_servers = prefs['mcp_servers']
return await ap.tool_mgr.get_resolved_tool_catalog(
context, bound_plugins, bound_mcp_servers, include_skill_authoring=True
)
async def tool_get(ap, context, payload):
for tool in await tools_list(ap, context, payload):
if tool.get('name') == payload.tool_name:
return tool
raise FileNotFoundError('Tool not found')
async def extensions_list(ap, context, payload):
if ap.plugin_connector.is_enable_plugin:
await ap.plugin_connector.require_workspace_context(context)
# Keep failures visible to programmatic callers instead of returning a partial catalog.
plugins = await ap.plugin_connector.list_plugins()
servers = await ap.mcp_service.get_mcp_servers(context, contain_runtime_info=True)
skills = await ap.skill_service.list_skills(context)
extensions = [{'type': 'plugin', 'plugin': redact_secrets(plugin)} for plugin in plugins]
extensions += [{'type': 'mcp', 'server': server} for server in servers]
extensions += [{'type': 'skill', 'skill': skill} for skill in skills]
return extensions
async def basic_stats(ap, context, payload):
execution = ExecutionContext.from_request(context)
sessions = [
session
for session in ap.sess_mgr.session_list
if all(getattr(session, key, None) == value for key, value in _task_scope(context).items())
]
return {
'active_session_count': len(sessions),
'conversation_count': sum(len(session.conversations or []) for session in sessions),
'query_count': ap.query_pool.get_query_count(execution),
}
async def _metadata_set(ap, context, key, value):
condition = (WorkspaceMetadata.workspace_uuid == context.workspace_uuid, WorkspaceMetadata.key == key)
result = await ap.persistence_mgr.execute_async(sqlalchemy.select(WorkspaceMetadata).where(*condition))
statement = (
sqlalchemy.update(WorkspaceMetadata).where(*condition).values(value=value)
if result.first()
else sqlalchemy.insert(WorkspaceMetadata).values(workspace_uuid=context.workspace_uuid, key=key, value=value)
)
await ap.persistence_mgr.execute_async(statement)
async def wizard_complete(ap, context, payload):
await _metadata_set(ap, context, 'wizard_status', payload.status)
await ap.persistence_mgr.execute_async(
sqlalchemy.delete(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == context.workspace_uuid, WorkspaceMetadata.key == 'wizard_progress'
)
)
return None
async def wizard_progress(ap, context, payload):
await _metadata_set(ap, context, 'wizard_progress', json.dumps(payload.model_dump(), ensure_ascii=False))
return None
async def recommended_model(ap, context, payload):
return await ap.space_service.get_recommended_chat_model(context)
async def model_availability(ap, context, payload):
return [model.model_dump(mode='json') for model in await ap.space_service.get_model_selection()]
async def requester_list(ap, context, payload):
return ap.model_mgr.get_available_requesters_info(payload.model_type)
async def requester_get(ap, context, payload):
result = ap.model_mgr.get_available_requester_info_by_name(payload.requester_name)
if result is None:
raise FileNotFoundError('Requester not found')
return result
async def adapter_list(ap, context, payload):
from ...cloud.plan_policy import workspace_entitlement, platform_feature_names
snapshot = await workspace_entitlement(ap, context.workspace_uuid)
adapters = ap.platform_mgr.get_available_adapters_info()
if snapshot is not None:
adapters = [
adapter
for adapter in adapters
if not any(snapshot.features.get(feature) is False for feature in platform_feature_names(adapter['name']))
]
return adapters
async def adapter_get(ap, context, payload):
from ...cloud.plan_policy import require_workspace_platform
await require_workspace_platform(ap, context.workspace_uuid, payload.adapter_name)
result = ap.platform_mgr.get_available_adapter_info_by_name(payload.adapter_name)
if result is None:
raise FileNotFoundError('Adapter not found')
return result
async def storage_analysis(ap, context, payload):
return await ap.maintenance_service.get_storage_analysis(context)
async def wizard_get(ap, context, payload):
result = await ap.persistence_mgr.execute_async(
sqlalchemy.select(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == context.workspace_uuid,
WorkspaceMetadata.key.in_(['wizard_status', 'wizard_progress']),
)
)
values = {row.key: row.value for row in result.all()}
return {
'status': values.get('wizard_status'),
'progress': json.loads(values['wizard_progress']) if values.get('wizard_progress') else None,
}
async def plugin_status(ap, context, payload):
await ap.plugin_connector.require_workspace_context(context)
try:
await ap.plugin_connector.ping_plugin_runtime()
except Exception as exc:
return {
'is_enable': ap.plugin_connector.is_enable_plugin,
'is_connected': False,
'plugin_connector_error': str(exc),
}
return {'is_enable': ap.plugin_connector.is_enable_plugin, 'is_connected': True, 'plugin_connector_error': 'ok'}
def operations() -> list[Operation]:
view, manage = Permission.RESOURCE_VIEW, Permission.RESOURCE_MANAGE
account = frozenset({PrincipalType.ACCOUNT})
return [
Operation('workspace.get', Permission.WORKSPACE_VIEW, workspace_get, Empty, dict),
Operation('workspace.current', Permission.WORKSPACE_VIEW, workspace_current, Empty, dict),
Operation('workspace.language.get', Permission.WORKSPACE_VIEW, language_get, Empty, dict),
Operation('workspace.language.update', Permission.WORKSPACE_UPDATE, language_update, Language, dict, True),
Operation('workspace.member.list', Permission.MEMBER_VIEW, members_list, Empty, list[dict], principals=account),
Operation(
'workspace.member.update',
Permission.MEMBER_UPDATE_ROLE,
member_update,
MemberRole,
dict,
True,
principals=account,
),
Operation(
'workspace.member.remove', Permission.MEMBER_REMOVE, member_remove, Member, dict, True, principals=account
),
Operation(
'workspace.invitation.list',
Permission.MEMBER_INVITE,
invitations_list,
Empty,
list[dict],
principals=account,
),
Operation(
'workspace.invitation.create',
Permission.MEMBER_INVITE,
invitation_create,
Invite,
dict,
True,
principals=account,
),
Operation(
'workspace.invitation.revoke',
Permission.MEMBER_INVITE,
invitation_revoke,
Invitation,
dict,
True,
principals=account,
),
Operation('settings.governance.get', Permission.AUDIT_VIEW, governance_get, Empty, dict),
Operation('settings.governance.update', Permission.AUDIT_VIEW, governance_update, Governance, dict, True),
Operation('settings.operation_logs.list', Permission.AUDIT_VIEW, logs_list, LogPage, dict),
Operation('settings.operation_logs.filters', Permission.AUDIT_VIEW, logs_filters, Empty, dict),
Operation('settings.operation_logs.export', Permission.AUDIT_VIEW, logs_export, LogFilters, dict),
Operation('task.list', view, tasks_list, TaskList, dict),
Operation('task.get', view, task_get, TaskGet, dict),
Operation('file.image.upload', manage, image_upload, Upload, dict[str, str], True),
Operation('file.document.upload', manage, document_upload, Upload, dict[str, str], True),
Operation('file.image.get', view, image_get, ImageGet, dict[str, bytes | str]),
Operation('box.status', view, box_status, Empty, dict),
Operation('box.sessions.list', Permission.AUDIT_VIEW, box_sessions, Empty, list[dict]),
Operation('box.errors.list', Permission.AUDIT_VIEW, box_errors, Empty, list[dict]),
Operation('tool.list', view, tools_list, ToolList, list[dict]),
Operation('tool.get', view, tool_get, ToolGet, dict),
Operation('extension.list', view, extensions_list, Empty, list[dict]),
Operation('requester.list', view, requester_list, RequesterList, list[dict]),
Operation('requester.get', view, requester_get, RequesterGet, dict),
Operation('adapter.list', view, adapter_list, Empty, list[dict]),
Operation('adapter.get', view, adapter_get, AdapterGet, dict),
Operation('stats.basic', view, basic_stats, Empty, dict[str, int]),
Operation('system.wizard.get', Permission.WORKSPACE_VIEW, wizard_get, Empty, dict),
Operation('system.storage.analysis', Permission.AUDIT_VIEW, storage_analysis, Empty, dict),
Operation(
'system.wizard.complete', Permission.WORKSPACE_UPDATE, wizard_complete, WizardStatus, type(None), True
),
Operation(
'system.wizard.progress.update',
Permission.WORKSPACE_UPDATE,
wizard_progress,
WizardProgress,
type(None),
True,
),
Operation('system.model.recommended', manage, recommended_model, Empty, dict),
Operation('system.model.availability', view, model_availability, Empty, list[dict]),
Operation('system.plugin.status', view, plugin_status, Empty, dict),
]
@@ -0,0 +1,82 @@
"""Workspace management of dashboard chat history and active connections."""
from __future__ import annotations
import datetime
from typing import Literal
from pydantic import Field
from .contracts import Input, Operation
from ..authz import Permission
class Pipeline(Input):
pipeline_uuid: str = Field(min_length=1)
class Session(Pipeline):
session_type: Literal['person', 'group']
class Broadcast(Pipeline):
message: str = Field(min_length=1, max_length=100_000)
async def _require_pipeline(ap, context, pipeline_uuid):
if await ap.pipeline_service.get_pipeline(context, pipeline_uuid) is None:
raise FileNotFoundError('Pipeline not found')
async def history(ap, context, payload):
await _require_pipeline(ap, context, payload.pipeline_uuid)
proxy = await ap.platform_mgr.get_websocket_proxy_bot(context)
return proxy.adapter.get_websocket_messages(payload.pipeline_uuid, payload.session_type)
async def reset(ap, context, payload):
await _require_pipeline(ap, context, payload.pipeline_uuid)
proxy = await ap.platform_mgr.get_websocket_proxy_bot(context)
proxy.adapter.reset_session(payload.pipeline_uuid, payload.session_type)
async def connections(ap, context, payload):
from ...platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
await _require_pipeline(ap, context, payload.pipeline_uuid)
scope = WebSocketScope.from_context(context)
rows = await ws_connection_manager.get_connections_by_pipeline(payload.pipeline_uuid, scope=scope)
return {
'stats': ws_connection_manager.get_stats(scope=scope),
'connections': [
{
'connection_id': row.connection_id,
'session_type': row.session_type,
'created_at': row.created_at.isoformat(),
'last_active': row.last_active.isoformat(),
'is_active': row.is_active,
}
for row in rows
],
}
async def broadcast(ap, context, payload):
from ...platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
await _require_pipeline(ap, context, payload.pipeline_uuid)
await ws_connection_manager.broadcast_to_pipeline(
payload.pipeline_uuid,
{'type': 'broadcast', 'message': payload.message, 'timestamp': datetime.datetime.now().isoformat()},
scope=WebSocketScope.from_context(context),
)
def operations() -> list[Operation]:
permission = Permission.RUNTIME_OPERATE
return [
Operation('pipeline.chat.history', permission, history, Session, list[dict]),
Operation('pipeline.chat.reset', permission, reset, Session, type(None), True),
Operation('pipeline.chat.connections', permission, connections, Pipeline, dict),
Operation('pipeline.chat.broadcast', permission, broadcast, Broadcast, type(None), True),
]
@@ -0,0 +1,56 @@
"""Versioned, transport-independent application operation contracts."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from pydantic import BaseModel, ConfigDict, TypeAdapter
from ..authz import Permission
from ..context import PrincipalType, RequestContext
class Input(BaseModel):
model_config = ConfigDict(strict=True, extra='forbid', revalidate_instances='always')
@dataclass(frozen=True, slots=True)
class ApplicationAPIError:
code: str
message: str
status_code: int = 400
details: dict | None = None
class ApplicationAPIException(Exception):
def __init__(self, error: ApplicationAPIError):
self.error = error
super().__init__(error.message)
@dataclass(frozen=True, slots=True)
class Operation:
operation_id: str
permission: Permission
handler: Callable[[Any, RequestContext, Input], Awaitable[Any]]
input_type: type[Input]
output_type: Any
side_effect: bool = False
version: str = '1'
principals: frozenset[PrincipalType] | None = None
streaming: bool = False
def as_dict(self) -> dict:
return {
'id': self.operation_id,
'version': self.version,
'scope': 'workspace',
'permission': self.permission.value,
'side_effect': self.side_effect,
'streaming': self.streaming,
'principals': sorted(self.principals) if self.principals is not None else None,
'input_schema': self.input_type.model_json_schema(),
'output_schema': TypeAdapter(self.output_type).json_schema(),
}
+70
View File
@@ -0,0 +1,70 @@
"""Translate business failures into stable application errors without HTTP imports."""
from __future__ import annotations
from langbot_plugin.box.errors import BoxAdmissionError
from pydantic import ValidationError
from ...cloud.entitlements import EntitlementUnavailableError
from ...core.errors import TaskCapacityError
from ...utils.bounded_executor import BlockingWorkCapacityError
from ...workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
from ...workspace.errors import WorkspaceExecutionUnavailableError, WorkspaceNotFoundError
from ..authz import AuthorizationError
from ..http.service.bot_errors import BotApplyError
from .contracts import ApplicationAPIError, ApplicationAPIException
def error_from_exception(exc: Exception, *, operation_id: str = '') -> ApplicationAPIError:
from ...agent.runner.errors import (
RunnerError,
RunnerExecutionError,
RunnerNotAuthorizedError,
RunnerNotFoundError,
RunnerProtocolError,
)
if isinstance(exc, ApplicationAPIException):
return exc.error
if isinstance(exc, AuthorizationError):
return ApplicationAPIError(exc.error_code, str(exc), exc.status_code)
if isinstance(exc, (WorkspaceNotFoundError, FileNotFoundError)):
return ApplicationAPIError('resource_not_found', 'Resource not found', 404)
if isinstance(exc, WorkspaceExecutionUnavailableError):
return ApplicationAPIError('workspace_unavailable', 'Workspace execution is unavailable', 409)
if isinstance(exc, MembershipPermissionError):
return ApplicationAPIError(exc.code, str(exc), 403)
if isinstance(exc, WorkspaceCollaborationError):
return ApplicationAPIError(exc.code, str(exc), 400)
if isinstance(exc, BoxAdmissionError):
return ApplicationAPIError('managed_sandbox_unavailable', str(exc), 403)
if isinstance(exc, EntitlementUnavailableError):
code = 'managed_sandbox_unavailable' if operation_id.startswith('box.') else 'entitlement_unavailable'
return ApplicationAPIError(code, str(exc), 403)
if isinstance(exc, TaskCapacityError):
return ApplicationAPIError('task_capacity_exceeded', str(exc), 429)
if isinstance(exc, BlockingWorkCapacityError):
return ApplicationAPIError('blocking_work_capacity_exceeded', str(exc), 429)
if isinstance(exc, BotApplyError):
return ApplicationAPIError('bot_apply_failed', str(exc), 400, details={'uuid': exc.bot_uuid})
if isinstance(exc, RunnerExecutionError):
return ApplicationAPIError(exc.error_code or 'runner_execution_failed', exc.message, 422)
if isinstance(exc, RunnerNotFoundError):
return ApplicationAPIError('runner_not_found', 'The configured Agent runner is unavailable', 409)
if isinstance(exc, RunnerNotAuthorizedError):
return ApplicationAPIError('runner_not_authorized', 'The configured Agent runner is not authorized', 403)
if isinstance(exc, RunnerProtocolError):
return ApplicationAPIError('runner_protocol_error', 'The Agent runner returned an invalid response', 502)
if isinstance(exc, RunnerError):
return ApplicationAPIError('runner_error', 'The Agent runner could not complete this test', 502)
code = getattr(exc, 'error_code', None) or getattr(exc, 'code', None)
status = getattr(exc, 'status_code', None) or getattr(exc, 'status', None)
if isinstance(code, str) and type(status) is int and 400 <= status <= 599:
return ApplicationAPIError(code, str(exc), status)
if isinstance(exc, ValidationError):
# Pydantic's normal text includes raw provider/plugin configuration values.
return ApplicationAPIError('validation_error', 'Invalid operation data', 400)
if isinstance(exc, ValueError):
return ApplicationAPIError('validation_error', str(exc), 400)
return ApplicationAPIError('application_error', 'Application operation failed', 500)
@@ -0,0 +1,759 @@
"""Transport-neutral plugin and run operations over the existing secured Host actions.
Installation contexts are capabilities supplied by trusted Host code, never by a
request body. A run context cannot fall back to legacy plugin-wide privileges.
The existing runtime consumers are deliberately not routed through this facade.
"""
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from typing import Annotated, Any, Literal
from langbot_plugin.entities.io.context import InstallationBinding
from langbot_plugin.entities.io.resp import ActionResponse
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, TypeAdapter, ValidationError, create_model
from .contracts import ApplicationAPIError, ApplicationAPIException, Input
from .streaming import isolated_stream
Text = Annotated[str, Field(min_length=1)]
Positive = Annotated[int, Field(gt=0)]
Nonnegative = Annotated[int, Field(ge=0)]
Object = dict[str, JsonValue]
QueryID = str | int
class JSONOutput(RootModel[Object]):
model_config = ConfigDict(strict=True, allow_inf_nan=False)
class Output(BaseModel):
model_config = ConfigDict(extra='allow', strict=True, allow_inf_nan=False)
__pydantic_extra__: dict[str, JsonValue] = Field(init=False)
def _input(name: str, **fields: Any) -> type[Input]:
return create_model(name, __base__=Input, **fields)
def _output(name: str, **fields: Any) -> type[Output]:
return create_model(name, __base__=Output, **fields)
@dataclass(frozen=True, slots=True)
class ExecutionAPIContext:
binding: InstallationBinding
run_id: str | None = None
@dataclass(frozen=True, slots=True)
class ExecutionOperation:
action: str
input_type: type[Input]
output_type: Any
scope: Literal['plugin', 'run', 'plugin_or_run', 'runner_admin'] = 'plugin'
side_effect: bool = False
streaming: bool = False
@property
def operation_id(self) -> str:
return f'execution.{self.action}'
def as_dict(self) -> dict[str, Any]:
return {
'id': self.operation_id,
'version': '1',
'scope': self.scope,
'authorization': 'installation_binding_and_existing_host_capabilities',
'side_effect': self.side_effect,
'streaming': self.streaming,
'input_schema': self.input_type.model_json_schema(),
'output_schema': TypeAdapter(self.output_type).json_schema(),
}
# Only reviewed business actions are public; runtime setup/chunks/ping stay internal.
Empty = _input('ExecutionEmptyInput')
Query = _input('ExecutionQueryInput', query_id=(QueryID, ...))
QueryKey = _input('ExecutionQueryKeyInput', query_id=(QueryID, ...), key=(Text, ...))
ModelCall = _input(
'ExecutionModelInput',
llm_model_uuid=(Text, ...),
messages=(list[Object], ...),
funcs=(list[Object], []),
extra_args=(Object, {}),
reasoning_level=(str | None, None),
remove_think=(bool, False),
query_id=(QueryID | None, None),
)
Retrieval = _input(
'ExecutionRetrievalInput',
kb_id=(Text, ...),
query_text=(str, ...),
top_k=(Positive, 5),
filters=(Object | None, None),
)
Storage = _input('ExecutionStorageInput', owner_type=(Literal['plugin', 'workspace'], ...), key=(Text, ...))
State = _input('ExecutionStateInput', scope=(Text, ...), key=(Text, ...))
TargetRun = _input('ExecutionTargetRunInput', target_run_id=(Text | None, None))
Page = _input(
'ExecutionPageInput',
conversation_id=(Text | None, None),
before_cursor=(str | None, None),
after_cursor=(str | None, None),
limit=(Positive, 50),
direction=(Literal['forward', 'backward'], 'backward'),
include_attachments=(bool, False),
)
TargetPage = _input(
'ExecutionTargetPageInput',
target_run_id=(Text | None, None),
before_cursor=(str | None, None),
after_cursor=(str | None, None),
limit=(Positive, 50),
direction=(Literal['forward', 'backward'], 'forward'),
)
ClaimFields = {'runtime_id': (Text, ...), 'claim_token': (Text, ...), 'target_run_id': (Text | None, None)}
RuntimeFields = {
'runtime_id': (Text, ...),
'status': (Text | None, None),
'capabilities': (Object | None, None),
'labels': (Object | None, None),
'metadata': (Object | None, None),
'heartbeat_deadline_at': (float | int | None, None),
'heartbeat_ttl_seconds': (Positive, 60),
}
EmptyOutput = _output('ExecutionEmptyOutput')
ValueOutput = _output('ExecutionValueOutput', value=(JsonValue, ...))
ResultOutput = _output('ExecutionResultOutput', result=(JsonValue, ...))
ItemsOutput = _output('ExecutionItemsOutput', items=(list[JsonValue], ...))
ResultsOutput = _output('ExecutionResultsOutput', results=(list[JsonValue], ...))
KeysOutput = _output('ExecutionKeysOutput', keys=(list[str], ...))
PageOutput = _output(
'ExecutionPageOutput',
items=(list[JsonValue], ...),
next_cursor=(str | None, ...),
prev_cursor=(str | None, ...),
has_more=(bool, ...),
)
RunOutput = _output('ExecutionRunOutput', run_id=(str, ...), status=(str, ...))
RuntimeOutput = _output('ExecutionRuntimeOutput', runtime_id=(str, ...))
EventOutput = _output('ExecutionEventOutput', event_id=(str, ...))
SuccessOutput = _output('ExecutionSuccessOutput', success=(bool, ...))
ToolOutput = _output('ExecutionToolResultOutput', tool_response=(JsonValue, ...)) | ResultOutput
StreamOutput = _output('ExecutionModelChunkOutput', chunk=(Object, ...)) | _output(
'ExecutionModelUsageOutput', usage=(Object, ...)
)
EXECUTION_OPERATIONS = (
ExecutionOperation(
'reply_message',
_input(
'ExecutionReplyInput', query_id=(QueryID, ...), message_chain=(list[Object], ...), quote_origin=(bool, ...)
),
EmptyOutput,
side_effect=True,
),
ExecutionOperation('get_bot_uuid', Query, _output('ExecutionBotIDOutput', bot_uuid=(str, ...))),
ExecutionOperation(
'set_query_var',
_input('ExecutionSetQueryVariableInput', query_id=(QueryID, ...), key=(Text, ...), value=(JsonValue, ...)),
EmptyOutput,
side_effect=True,
),
ExecutionOperation('get_query_var', QueryKey, ValueOutput),
ExecutionOperation('get_query_vars', Query, _output('ExecutionVariablesOutput', vars=(Object, ...))),
ExecutionOperation('create_new_conversation', Query, EmptyOutput, side_effect=True),
ExecutionOperation(
'get_langbot_version', Empty, _output('ExecutionVersionOutput', version=(str, ...)), 'plugin_or_run'
),
ExecutionOperation('get_bots', Empty, _output('ExecutionBotsOutput', bots=(list[JsonValue], ...))),
ExecutionOperation(
'get_bot_info',
_input('ExecutionBotInput', bot_uuid=(Text, ...)),
_output('ExecutionBotOutput', bot=(Object, ...)),
),
ExecutionOperation(
'send_message',
_input(
'ExecutionSendInput',
bot_uuid=(Text, ...),
target_type=(Literal['person', 'group'], ...),
target_id=(Text, ...),
message_chain=(list[Object], ...),
),
ResultOutput,
'plugin_or_run',
True,
),
ExecutionOperation(
'call_platform_api',
_input(
'ExecutionPlatformInput',
bot_uuid=(Text | None, None),
action=(Text, ...),
params=(Object, {}),
context_tool=(str | None, None),
file_ids=(list[str] | None, None),
),
ResultOutput,
'plugin_or_run',
True,
),
ExecutionOperation(
'reply_stream',
_input(
'ExecutionReplyStreamInput',
stream_id=(Text, ...),
operation=(Literal['update', 'finish', 'abort'], ...),
text=(str, ...),
),
ResultOutput,
'run',
True,
),
ExecutionOperation('get_llm_models', Empty, _output('ExecutionModelsOutput', llm_models=(list[str], ...))),
ExecutionOperation('count_tokens', ModelCall, _output('ExecutionTokensOutput', tokens=(int, ...)), 'plugin_or_run'),
ExecutionOperation(
'invoke_llm', ModelCall, _output('ExecutionModelOutput', message=(Object, ...)), 'plugin_or_run', True
),
ExecutionOperation('invoke_llm_stream', ModelCall, StreamOutput, 'plugin_or_run', True, True),
ExecutionOperation(
'call_tool',
_input(
'ExecutionCallToolInput',
tool_name=(Text, ...),
parameters=(Object, {}),
tool_parameters=(Object | None, None),
),
ToolOutput,
'plugin_or_run',
True,
),
ExecutionOperation(
'get_tool_detail',
_input('ExecutionToolInput', tool_name=(Text, ...)),
_output('ExecutionToolOutput', tool=(Object, ...)),
'plugin_or_run',
),
ExecutionOperation(
'set_binary_storage',
_input(
'ExecutionSetStorageInput',
owner_type=(Literal['plugin', 'workspace'], ...),
key=(Text, ...),
value_base64=(str, ...),
),
EmptyOutput,
'plugin_or_run',
True,
),
ExecutionOperation(
'get_binary_storage', Storage, _output('ExecutionStorageOutput', value_base64=(str, ...)), 'plugin_or_run'
),
ExecutionOperation('delete_binary_storage', Storage, EmptyOutput, 'plugin_or_run', True),
ExecutionOperation(
'get_binary_storage_keys',
_input('ExecutionStorageKeysInput', owner_type=(Literal['plugin', 'workspace'], ...)),
KeysOutput,
'plugin_or_run',
),
ExecutionOperation(
'get_config_file',
_input('ExecutionConfigFileInput', file_key=(Text, ...)),
_output('ExecutionConfigFileOutput', file_base64=(str, ...)),
),
ExecutionOperation(
'invoke_embedding',
_input('ExecutionEmbeddingInput', embedding_model_uuid=(Text, ...), texts=(list[str], ...)),
_output('ExecutionEmbeddingOutput', vectors=(list[list[float]], ...)),
'plugin_or_run',
True,
),
ExecutionOperation(
'invoke_rerank',
_input(
'ExecutionRerankInput',
rerank_model_uuid=(Text, ...),
query=(str, ...),
documents=(list[str], ...),
top_k=(Positive | None, None),
extra_args=(Object, {}),
),
ResultsOutput,
'plugin_or_run',
True,
),
ExecutionOperation(
'vector_upsert',
_input(
'ExecutionVectorUpsertInput',
collection_id=(Text, ...),
ids=(list[str], ...),
vectors=(list[list[float]], ...),
documents=(list[str] | None, None),
metadata=(list[Object] | None, None),
),
EmptyOutput,
side_effect=True,
),
ExecutionOperation(
'vector_search',
_input(
'ExecutionVectorSearchInput',
collection_id=(Text, ...),
query_vector=(list[float], ...),
top_k=(Positive, ...),
filters=(Object | None, None),
query_text=(str, ''),
search_type=(str, 'vector'),
vector_weight=(float, 0.5),
),
ResultsOutput,
),
ExecutionOperation(
'vector_delete',
_input(
'ExecutionVectorDeleteInput',
collection_id=(Text, ...),
file_ids=(list[str] | None, None),
filters=(Object | None, None),
),
_output('ExecutionVectorDeleteOutput', count=(int, ...)),
side_effect=True,
),
ExecutionOperation(
'vector_list',
_input(
'ExecutionVectorListInput',
collection_id=(Text, ...),
filters=(Object | None, None),
limit=(Positive, 20),
offset=(Nonnegative, 0),
),
_output('ExecutionVectorListOutput', items=(list[JsonValue], ...), total=(int, ...)),
),
ExecutionOperation(
'get_knowledge_file_stream',
_input('ExecutionKnowledgeFileInput', storage_path=(Text, ...)),
_output('ExecutionKnowledgeFileOutput', file_key=(str, ...)),
side_effect=True,
),
ExecutionOperation(
'list_parsers',
_input('ExecutionParsersInput', mime_type=(str | None, None)),
_output('ExecutionParsersOutput', parsers=(list[JsonValue], ...)),
),
ExecutionOperation(
'invoke_parser',
_input(
'ExecutionParserInput',
plugin_author=(Text, ...),
plugin_name=(Text, ...),
storage_path=(Text, ...),
filename=(str, ''),
mime_type=(str, 'application/octet-stream'),
metadata=(Object, {}),
),
JSONOutput,
side_effect=True,
),
ExecutionOperation(
'list_knowledge_bases', Empty, _output('ExecutionKnowledgeBasesOutput', knowledge_bases=(list[JsonValue], ...))
),
ExecutionOperation('retrieve_knowledge', Retrieval, ResultsOutput, 'plugin_or_run'),
ExecutionOperation(
'list_pipeline_knowledge_bases',
Query,
_output('ExecutionPipelineKnowledgeBasesOutput', knowledge_bases=(list[JsonValue], ...)),
),
ExecutionOperation(
'retrieve_knowledge_base',
_input(
'ExecutionPipelineRetrievalInput',
kb_id=(Text, ...),
query_text=(str, ...),
query_id=(QueryID | None, None),
top_k=(Positive, 5),
filters=(Object | None, None),
),
ResultsOutput,
'plugin_or_run',
),
ExecutionOperation('get_prompt', Empty, _output('ExecutionPromptOutput', prompt=(list[Object], ...)), 'run'),
ExecutionOperation('history_page', Page, PageOutput, 'run'),
ExecutionOperation(
'history_search',
_input('ExecutionHistorySearchInput', query=(str, ''), filters=(Object | None, None), top_k=(Positive, 10)),
_output('ExecutionHistorySearchOutput', items=(list[JsonValue], ...), total_count=(int, ...), query=(str, ...)),
'run',
),
ExecutionOperation('event_get', _input('ExecutionEventInput', event_id=(Text, ...)), EventOutput, 'run'),
ExecutionOperation(
'event_page',
_input(
'ExecutionEventPageInput',
conversation_id=(Text | None, None),
before_cursor=(str | None, None),
event_types=(list[str] | None, None),
limit=(Positive, 50),
),
PageOutput,
'run',
),
ExecutionOperation(
'steering_pull',
_input('ExecutionSteeringInput', mode=(str, 'all'), limit=(Positive | None, None)),
ItemsOutput,
'run',
True,
),
ExecutionOperation('state_get', State, ValueOutput, 'run'),
ExecutionOperation(
'state_set',
_input('ExecutionSetStateInput', scope=(Text, ...), key=(Text, ...), value=(JsonValue, ...)),
SuccessOutput,
'run',
True,
),
ExecutionOperation('state_delete', State, SuccessOutput, 'run', True),
ExecutionOperation(
'state_list',
_input('ExecutionStateListInput', scope=(Text, ...), prefix=(str | None, None), limit=(Positive, 100)),
_output('ExecutionStateKeysOutput', keys=(list[str], ...), has_more=(bool, ...)),
'run',
),
ExecutionOperation(
'get_box_status',
Empty,
_output('ExecutionBoxStatusOutput', available=(bool, ...), enabled=(bool, ...)),
'plugin_or_run',
),
ExecutionOperation('list_boxes', Empty, ItemsOutput, 'plugin_or_run'),
ExecutionOperation(
'acquire_box',
_input('ExecutionAcquireBoxInput', reuse_key=(Text, ...), options=(Object, {})),
_output('ExecutionBoxOutput', id=(str, ...), status=(str, ...)),
'plugin_or_run',
True,
),
ExecutionOperation(
'bind_box',
_input('ExecutionBindBoxInput', box_id=(Text, ...)),
_output('ExecutionBoxBindingOutput', box_id=(str, ...), outbox=(str, ...)),
'run',
True,
),
ExecutionOperation(
'import_box_attachments',
_input('ExecutionImportAttachmentsInput', attachment_ids=(list[str] | None, None)),
ItemsOutput,
'run',
True,
),
ExecutionOperation(
'export_box_files', _input('ExecutionExportBoxInput', paths=(list[str] | None, None)), ItemsOutput, 'run', True
),
ExecutionOperation('run_get', TargetRun, RunOutput, 'runner_admin'),
ExecutionOperation(
'run_list',
_input(
'ExecutionRunsInput',
conversation_id=(Text | None, None),
statuses=(list[str] | None, None),
before_cursor=(str | None, None),
limit=(Positive, 50),
),
PageOutput,
'runner_admin',
),
ExecutionOperation('run_events_page', TargetPage, PageOutput, 'runner_admin'),
ExecutionOperation(
'run_cancel',
_input(
'ExecutionCancelRunInput',
target_run_id=(Text | None, None),
reason=(str | None, None),
status_reason=(str | None, None),
),
RunOutput,
'runner_admin',
True,
),
ExecutionOperation(
'run_append_result',
_input(
'ExecutionAppendResultInput',
target_run_id=(Text | None, None),
sequence=(Positive | None, None),
event_type=(str | None, None),
type=(str | None, None),
data=(Object | None, None),
result=(Object | None, None),
usage=(Object | None, None),
metadata=(Object | None, None),
source=(str | None, None),
runtime_id=(str | None, None),
claim_token=(str | None, None),
),
JSONOutput,
'runner_admin',
True,
),
ExecutionOperation(
'run_finalize',
_input(
'ExecutionFinalizeRunInput',
target_run_id=(Text | None, None),
status=(Text, ...),
reason=(str | None, None),
status_reason=(str | None, None),
usage=(Object | None, None),
cost=(Object | None, None),
metadata=(Object | None, None),
runtime_id=(str | None, None),
claim_token=(str | None, None),
),
RunOutput,
'runner_admin',
True,
),
ExecutionOperation(
'runner_list',
_input('ExecutionRunnersInput', include_plugins=(list[str] | None, None), use_cache=(bool, True)),
ItemsOutput,
'runner_admin',
),
ExecutionOperation(
'runtime_register',
_input(
'ExecutionRegisterRuntimeInput',
**RuntimeFields,
display_name=(str | None, None),
endpoint=(str | None, None),
version=(str | None, None),
),
RuntimeOutput,
'runner_admin',
True,
),
ExecutionOperation(
'runtime_heartbeat',
_input('ExecutionRuntimeHeartbeatInput', **RuntimeFields),
RuntimeOutput,
'runner_admin',
True,
),
ExecutionOperation(
'runtime_list',
_input(
'ExecutionRuntimesInput',
statuses=(list[str] | None, None),
labels=(Object | None, None),
limit=(Positive, 50),
),
PageOutput,
'runner_admin',
),
ExecutionOperation(
'runtime_reconcile',
_input('ExecutionRuntimeReconcileInput', stale_after_seconds=(Positive, 60)),
JSONOutput,
'runner_admin',
True,
),
ExecutionOperation(
'run_stats',
_input(
'ExecutionRunStatsInput',
runner_id=(str | None, None),
start_time=(int | float | None, None),
end_time=(int | float | None, None),
),
JSONOutput,
'runner_admin',
),
ExecutionOperation('runtime_stats', Empty, JSONOutput, 'runner_admin'),
ExecutionOperation(
'runner_stats',
_input(
'ExecutionRunnerStatsInput',
start_time=(int | float | None, None),
end_time=(int | float | None, None),
limit=(Positive, 50),
),
ItemsOutput,
'runner_admin',
),
ExecutionOperation(
'run_claim',
_input(
'ExecutionClaimRunInput',
runtime_id=(Text, ...),
lease_seconds=(Positive, 60),
queue_name=(str | None, None),
runner_ids=(list[str] | None, None),
),
JSONOutput,
'runner_admin',
True,
),
ExecutionOperation(
'run_renew_claim',
_input('ExecutionRenewClaimInput', **ClaimFields, lease_seconds=(Positive, 60)),
RunOutput,
'runner_admin',
True,
),
ExecutionOperation(
'run_release_claim',
_input(
'ExecutionReleaseClaimInput',
**ClaimFields,
reason=(str | None, None),
status_reason=(str | None, None),
status=(Text, 'queued'),
),
RunOutput,
'runner_admin',
True,
),
)
_BY_ID = {operation.operation_id: operation for operation in EXECUTION_OPERATIONS}
def _fail(code: str, message: str, status: int = 400) -> None:
raise ApplicationAPIException(ApplicationAPIError(code, message, status))
class ExecutionAPI:
"""Invoke Host APIs with trusted installation/run capabilities, without a transport."""
def __init__(self, ap: Any):
self.ap = ap
def operations(self) -> tuple[ExecutionOperation, ...]:
return EXECUTION_OPERATIONS
def catalog(self) -> list[dict[str, Any]]:
return [operation.as_dict() for operation in self.operations()]
@contextlib.asynccontextmanager
async def _invocation(self, operation_id: str, context: ExecutionAPIContext, payload: dict, *, streaming: bool):
operation = _BY_ID.get(operation_id)
if operation is None:
_fail('unknown_operation', f'Unknown execution operation: {operation_id}', 404)
if operation.streaming != streaming:
_fail('invalid_input', 'Use stream for streaming operations and invoke for unary operations')
if not isinstance(context, ExecutionAPIContext) or not isinstance(context.binding, InstallationBinding):
_fail('invalid_authentication', 'A trusted installation binding is required', 401)
if context.run_id is not None and (not isinstance(context.run_id, str) or not context.run_id.strip()):
_fail('invalid_authentication', 'The trusted run ID is invalid', 401)
if context.run_id is None and operation.scope == 'run':
_fail('permission_denied', 'This operation requires a trusted run context', 403)
if context.run_id is not None and operation.scope == 'plugin':
_fail('permission_denied', 'A run cannot use legacy plugin-wide operations', 403)
try:
data = operation.input_type.model_validate(payload).model_dump(exclude_unset=True)
except ValidationError:
_fail('invalid_input', f'Input does not match {operation.input_type.__name__}')
if operation.action == 'call_platform_api' and context.run_id is None and not data.get('bot_uuid'):
_fail('invalid_input', 'bot_uuid is required outside a run')
if operation.action == 'call_tool':
if context.run_id is not None:
if 'tool_parameters' in data:
_fail('invalid_input', 'Run tool calls use parameters')
data.setdefault('parameters', {})
elif 'parameters' in data:
if 'tool_parameters' in data:
_fail('invalid_input', 'Supply either parameters or tool_parameters')
data['tool_parameters'] = data.pop('parameters')
handler = getattr(getattr(self.ap, 'plugin_connector', None), 'handler', None)
if handler is None or operation.action not in handler.actions:
_fail('application_error', 'The execution Host does not provide this operation', 503)
try:
handler.validate_inbound_action_context(operation.action, context.binding)
except (TypeError, ValueError):
_fail('invalid_authentication', 'Invalid installation binding', 401)
token = handler._current_action_context.set(context.binding)
try:
async with handler._tenant_action_scope(context.binding):
try:
_, identity = await handler._require_plugin_action_context()
await handler._require_active_action_context(context.binding)
except (TypeError, ValueError):
_fail('permission_denied', 'Installation binding is invalid or stale', 403)
if context.run_id is not None:
if 'query_id' in data:
_fail('invalid_input', 'A run context supplies its own query identity')
from ...agent.runner.session_registry import get_session_registry
from ...plugin.agent_run_support import _load_persistent_agent_run_session
session = await get_session_registry().get(context.run_id)
if session is None and operation.scope == 'runner_admin':
session = await _load_persistent_agent_run_session(context.run_id, self.ap, operation.action)
if (
session is None
or session['authorization'].get('workspace_id') != context.binding.workspace_uuid
or session.get('plugin_identity') != f'{identity.plugin_author}/{identity.plugin_name}'
):
_fail('permission_denied', 'Run does not belong to this installation Workspace', 403)
data['run_id'] = context.run_id
yield operation, handler.actions[operation.action], data
finally:
handler._current_action_context.reset(token)
@staticmethod
def _result(operation: ExecutionOperation, response: Any) -> dict:
if not isinstance(response, ActionResponse):
_fail('invalid_output', 'Execution Host returned an invalid response', 500)
if response.code != 0:
# The Host exposes structured Box export failures as a public error contract.
from langbot_plugin.api.entities.builtin.runner.errors import AgentAPIError
details = None
if operation.action == 'export_box_files' and 'error' in response.data:
try:
details = {'error': AgentAPIError.model_validate(response.data['error']).model_dump(mode='json')}
except ValidationError:
_fail('invalid_output', 'Invalid Box export error details', 500)
raise ApplicationAPIException(ApplicationAPIError('application_error', response.message, 400, details))
try:
result = TypeAdapter(operation.output_type).validate_python(response.data, strict=True)
return result.model_dump(mode='json')
except ValidationError:
_fail('invalid_output', f'Output does not match {operation.action}', 500)
async def invoke(self, operation_id: str, context: ExecutionAPIContext, payload: dict | None = None) -> dict:
try:
async with self._invocation(
operation_id, context, {} if payload is None else payload, streaming=False
) as call:
operation, action, data = call
return self._result(operation, await action(data))
except ApplicationAPIException:
raise
except Exception:
_fail('application_error', 'Execution operation failed', 500)
async def stream(self, operation_id: str, context: ExecutionAPIContext, payload: dict | None = None):
async def produce():
try:
async with self._invocation(
operation_id, context, {} if payload is None else payload, streaming=True
) as call:
operation, action, data = call
async with contextlib.aclosing(action(data)) as responses:
async for response in responses:
yield self._result(operation, response)
except ApplicationAPIException:
raise
except Exception:
_fail('application_error', 'Execution stream failed', 500)
async with contextlib.aclosing(isolated_stream(produce())) as responses:
async for response in responses:
yield response
@@ -0,0 +1,129 @@
"""Application operations for principal-bound platform credential setup."""
from __future__ import annotations
from typing import Any
from typing_extensions import NotRequired, TypedDict
from pydantic import Field
from ..authz import Permission
from ..http.service.slack_setup import SlackSetupError, get_slack_setup
from ...platform.credential_setup import CredentialSetupError, get_credential_setup
from .contracts import Input, Operation
class Empty(Input):
pass
class Session(Input):
session_id: str = Field(min_length=1)
class SlackStart(Input):
bot_uuid: str = Field(min_length=1)
access_token: str = Field(min_length=1)
refresh_token: str = Field(min_length=1)
webhook_url: str = ''
name: str = 'LangBot'
socket_mode: bool = False
redirect_url: str = ''
class SessionStarted(TypedDict):
session_id: str
class QRStarted(SessionStarted):
qr_url: str
expire_at: float | None
class WeixinStarted(SessionStarted):
qr_data_url: str
expire_at: float | None
class SessionStatus(TypedDict):
status: str
error: NotRequired[str | None]
app_id: NotRequired[str | None]
app_secret: NotRequired[str | None]
client_id: NotRequired[str | None]
client_secret: NotRequired[str | None]
botid: NotRequired[str | None]
secret: NotRequired[str | None]
appid: NotRequired[str | None]
user_openid: NotRequired[str | None]
qr_data_url: NotRequired[str | None]
expire_at: NotRequired[float | None]
token: NotRequired[str | None]
base_url: NotRequired[str | None]
account_id: NotRequired[str | None]
authorize_url: NotRequired[str | None]
config: NotRequired[dict[str, Any]]
def _credential_operation(method: str):
async def execute(ap, context, payload):
service = get_credential_setup(ap)
return await getattr(service, method)(request_context=context, **payload.model_dump())
return execute
async def slack_start(ap, context, payload: SlackStart):
try:
return await get_slack_setup(ap).start(context, **payload.model_dump())
except SlackSetupError as exc:
raise CredentialSetupError(400, str(exc)) from exc
async def slack_status(ap, context, payload: Session):
try:
return get_slack_setup(ap).status(context, payload.session_id)
except SlackSetupError as exc:
raise CredentialSetupError(404, 'session_not_found') from exc
async def slack_cancel(ap, context, payload: Session):
service = get_slack_setup(ap)
try:
service.owned(context, payload.session_id)
except SlackSetupError as exc:
raise CredentialSetupError(404, 'session_not_found') from exc
service.discard(payload.session_id)
return {}
def operations() -> list[Operation]:
result = []
for platform in ('lark', 'weixin', 'dingtalk', 'wecombot', 'qqofficial'):
for action in ('start', 'status', 'cancel'):
output = (
(WeixinStarted if platform == 'weixin' else QRStarted)
if action == 'start'
else (SessionStatus if action == 'status' else dict)
)
result.append(
Operation(
f'platform.setup.{platform}.{action}',
Permission.RESOURCE_MANAGE,
_credential_operation(f'{platform}_{action}'),
Empty if action == 'start' else Session,
output,
# Polling consumes successful credentials and completed error sessions.
side_effect=True,
)
)
result.extend(
(
Operation(
'platform.setup.slack.start', Permission.RESOURCE_MANAGE, slack_start, SlackStart, SessionStarted, True
),
Operation('platform.setup.slack.status', Permission.RESOURCE_MANAGE, slack_status, Session, SessionStatus),
Operation('platform.setup.slack.cancel', Permission.RESOURCE_MANAGE, slack_cancel, Session, dict, True),
)
)
return result
@@ -0,0 +1,521 @@
"""Workspace plugin management operations independent of HTTP adapters."""
from __future__ import annotations
import asyncio
import base64
import os
import re
import uuid
import zipfile
from typing import Any
from typing_extensions import TypedDict
from urllib.parse import urlparse
import httpx
import sqlalchemy
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
from pydantic import Field
from ...core import taskmgr
from ...entity.persistence import plugin as persistence_plugin
from ...i18n import t
from ...plugin.archive import inspect_plugin_archive_metadata
from ...plugin.github import validate_github_plugin_install_info
from ...plugin.management import (
count_plugin_components,
normalize_plugin_asset_path,
parse_github_repo_url,
redact_plugin_secrets,
restore_plugin_secret_placeholders,
)
from ...utils import httpclient
from ...workspace.errors import WorkspaceNotFoundError
from ..authz import Permission
from ..http.service.workspace_limits import workspace_limits
from .contracts import Input, Operation
class PluginsResult(TypedDict):
plugins: list[dict[str, Any]]
class PluginResult(TypedDict):
plugin: dict[str, Any]
class ConfigResult(TypedDict):
config: dict[str, Any]
class DebugResult(TypedDict):
debug_url: str
plugin_debug_key: str
expires_at: str
class TaskResult(TypedDict):
task_id: int
class BinaryResult(TypedDict):
content: bytes
mime_type: str
class ReadmeResult(TypedDict):
readme: str
class FileResult(TypedDict):
file_key: str
class DeletedResult(TypedDict):
deleted: bool
class PluginInput(Input):
author: str = Field(min_length=1)
plugin_name: str = Field(min_length=1)
class DeleteInput(PluginInput):
delete_data: bool = False
class ConfigInput(PluginInput):
config: dict[str, Any]
class ReadmeInput(PluginInput):
language: str = 'en'
class LogsInput(PluginInput):
limit: int = Field(default=200, ge=1)
level: str | None = None
class AssetInput(PluginInput):
filepath: str
class PageInput(PluginInput):
page_id: str
endpoint: str
method: str = 'POST'
body: Any = None
class GithubRepoInput(Input):
repo_url: str
class GithubAssetsInput(Input):
owner: str = Field(pattern=r'^[A-Za-z0-9][A-Za-z0-9-]{0,38}$')
repo: str = Field(pattern=r'^[A-Za-z0-9._-]{1,100}$')
release_id: int = Field(gt=0)
class GithubInstallInput(Input):
owner: str
repo: str
release_tag: str
asset_url: str | None = None
asset_id: int | None = None
release_id: int | None = None
class MarketplaceInput(Input):
plugin_author: str = Field(min_length=1)
plugin_name: str = Field(min_length=1)
plugin_version: str | None = None
class FileInput(Input):
content: bytes
filename: str = 'config.bin'
class LocalInstallInput(FileInput):
filename: str = 'local plugin'
administrator_force: bool = False
class FileKeyInput(Input):
file_key: str
async def _fence(ap, context):
return await ap.plugin_connector.require_workspace_context(context)
async def _get(ap, context, payload):
await _fence(ap, context)
plugin = await ap.plugin_connector.get_plugin_info(payload.author, payload.plugin_name)
if plugin is None:
raise WorkspaceNotFoundError('Plugin not found')
return plugin
async def _stored_config(ap, context, payload, plugin):
result = await ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_plugin.PluginSetting.config)
.where(persistence_plugin.PluginSetting.workspace_uuid == context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == payload.author)
.where(persistence_plugin.PluginSetting.plugin_name == payload.plugin_name)
)
config = result.scalar_one_or_none()
return config if config is not None else plugin['plugin_config']
async def list_plugins(ap, context, payload):
await _fence(ap, context)
return {'plugins': redact_plugin_secrets(await ap.plugin_connector.list_plugins())}
async def get_plugin(ap, context, payload):
return {'plugin': redact_plugin_secrets(await _get(ap, context, payload))}
async def debug_info(ap, context, payload):
execution = await _fence(ap, context)
info = await ap.plugin_connector.get_debug_info(execution)
url = ap.instance_config.data.get('plugin', {}).get(
'display_plugin_debug_url', 'ws://localhost:5401/plugin/debug/ws'
)
parsed = urlparse(url)
if parsed.scheme in {'http', 'https'}:
url = parsed._replace(
scheme='wss' if parsed.scheme == 'https' else 'ws', path=parsed.path or '/plugin/debug/ws'
).geturl()
return {
'debug_url': url,
'plugin_debug_key': info.get('plugin_debug_key', ''),
'expires_at': info.get('expires_at', ''),
}
async def get_config(ap, context, payload):
plugin = await _get(ap, context, payload)
return {'config': redact_plugin_secrets(await _stored_config(ap, context, payload, plugin))}
async def update_config(ap, context, payload):
plugin = await _get(ap, context, payload)
config = restore_plugin_secret_placeholders(payload.config, await _stored_config(ap, context, payload, plugin))
await _fence(ap, context)
await ap.plugin_connector.set_plugin_config(payload.author, payload.plugin_name, config)
return {}
async def readme(ap, context, payload):
await _fence(ap, context)
return {
'readme': await ap.plugin_connector.get_plugin_readme(
payload.author, payload.plugin_name, language=payload.language
)
}
async def logs(ap, context, payload):
await _fence(ap, context)
return {
'logs': await ap.plugin_connector.get_plugin_logs(
payload.author, payload.plugin_name, limit=payload.limit, level=payload.level
)
}
async def icon(ap, context, payload):
await _fence(ap, context)
try:
result = await ap.plugin_connector.get_plugin_icon(payload.author, payload.plugin_name)
except ValueError as exc:
if 'is not installed in this workspace' in str(exc).lower():
raise WorkspaceNotFoundError('Icon not found') from exc
raise
return {
'content': await asyncio.to_thread(base64.b64decode, result['plugin_icon_base64']),
'mime_type': result['mime_type'],
}
async def asset(ap, context, payload):
await _fence(ap, context)
path = normalize_plugin_asset_path(payload.filepath)
if path is None:
raise WorkspaceNotFoundError('Asset not found')
result = await ap.plugin_connector.get_plugin_assets(payload.author, payload.plugin_name, path)
if not result.get('asset_base64'):
raise WorkspaceNotFoundError('Asset not found')
return {
'content': await asyncio.to_thread(base64.b64decode, result['asset_base64']),
'mime_type': result['mime_type'],
}
async def page_action(ap, context, payload):
await _fence(ap, context)
if not payload.endpoint.startswith('/') or '..' in payload.endpoint:
raise ValueError('Invalid endpoint')
result = await ap.plugin_connector.handle_page_api(
payload.author, payload.plugin_name, payload.page_id, payload.endpoint, payload.method.upper(), payload.body
)
if result.get('error'):
raise ValueError(result['error'])
return result.get('data')
async def _github_json(url):
async with httpx.AsyncClient(
trust_env=True, follow_redirects=True, timeout=10, event_hooks=httpclient.httpx_response_limit_hooks()
) as client:
response = await client.get(url)
response.raise_for_status()
return await httpclient.parse_json_response(response)
async def github_releases(ap, context, payload):
await _fence(ap, context)
repo = parse_github_repo_url(payload.repo_url)
if not repo:
raise ValueError('Invalid GitHub repository URL')
owner, name, ref = repo['owner'], repo['repo'], repo['ref']
# Repository parts become URL path segments, never a caller-selected host/path.
if (
re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9-]{0,38}', owner) is None
or re.fullmatch(r'[A-Za-z0-9._-]{1,100}', name) is None
):
raise ValueError('Invalid GitHub repository URL')
if ref:
releases = [
{
'id': 0,
'tag_name': ref,
'name': ref,
'published_at': '',
'prerelease': False,
'draft': False,
'source_type': 'branch',
'archive_url': f'https://api.github.com/repos/{owner}/{name}/zipball/{ref}',
}
]
else:
data = await _github_json(f'https://api.github.com/repos/{owner}/{name}/releases')
releases = [
{key: release[key] for key in ('id', 'tag_name', 'name', 'published_at', 'prerelease', 'draft')}
for release in data
]
return {'releases': releases, 'owner': owner, 'repo': name, 'source_subdir': repo['subdir']}
async def github_assets(ap, context, payload):
await _fence(ap, context)
release = await _github_json(
f'https://api.github.com/repos/{payload.owner}/{payload.repo}/releases/{payload.release_id}'
)
return {
'assets': [
{
'id': item['id'],
'name': item['name'],
'size': item['size'],
'download_url': item['browser_download_url'],
'content_type': item['content_type'],
}
for item in release.get('assets', [])
]
}
async def _check_limit(ap, context):
await _fence(ap, context)
limits = await workspace_limits(ap, context.workspace_uuid)
limit = limits.get('max_extensions', -1)
if limit >= 0:
plugins = await ap.plugin_connector.list_plugins()
servers = await ap.mcp_service.get_mcp_servers(context)
if len(plugins) + len(servers) >= limit:
raise ValueError(t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=limit))
async def _run_fenced(ap, execution, operation):
async with ap.persistence_mgr.tenant_scope(execution.workspace_uuid):
await _fence(ap, execution)
return await operation()
async def _queue(ap, context, name, label, task, operation):
execution = await _fence(ap, context)
wrapper = ap.task_mgr.create_user_task(
_run_fenced(ap, execution, operation),
kind='plugin-operation',
name=name,
label=label,
context=task,
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
)
return {'task_id': wrapper.id}
async def upgrade(ap, context, payload):
task = taskmgr.TaskContext.new()
task.metadata.update(
{
'plugin_name': f'{payload.author}/{payload.plugin_name}',
'install_source': 'marketplace',
'operation': 'upgrade',
'progress_percent': 3,
}
)
task.set_current_action('checking for latest version')
return await _queue(
ap,
context,
f'plugin-upgrade-{payload.plugin_name}',
f'Upgrading plugin {payload.plugin_name}',
task,
lambda: ap.plugin_connector.upgrade_plugin(payload.author, payload.plugin_name, task_context=task),
)
async def delete(ap, context, payload):
task = taskmgr.TaskContext.new()
return await _queue(
ap,
context,
f'plugin-remove-{payload.plugin_name}',
f'Removing plugin {payload.plugin_name}',
task,
lambda: ap.plugin_connector.delete_plugin(
payload.author, payload.plugin_name, delete_data=payload.delete_data, task_context=task
),
)
async def _install(ap, context, source, data, identity, label):
task = taskmgr.TaskContext.new()
task.metadata.update({'plugin_name': identity, 'install_source': source.value})
return await _queue(
ap,
context,
f'plugin-install-{source.value}',
label,
task,
lambda: ap.plugin_connector.install_plugin(source, data, task_context=task),
)
async def install_github(ap, context, payload):
await _check_limit(ap, context)
data = validate_github_plugin_install_info(payload.model_dump())
identity = f'{data["owner"]}/{data["repo"]}'
return await _install(
ap,
context,
PluginInstallSource.GITHUB,
data,
identity,
f'Installing plugin from GitHub {identity}@{data["release_tag"]}',
)
async def install_marketplace(ap, context, payload):
await _check_limit(ap, context)
identity = f'{payload.plugin_author}/{payload.plugin_name}'
return await _install(
ap,
context,
PluginInstallSource.MARKETPLACE,
payload.model_dump(exclude_none=True),
identity,
f'Installing plugin from marketplace {identity}',
)
async def install_local(ap, context, payload):
await _check_limit(ap, context)
return await _install(
ap,
context,
PluginInstallSource.LOCAL,
{'plugin_file': payload.content, 'administrator_force': payload.administrator_force},
payload.filename,
f'Installing plugin from local {payload.filename}',
)
async def preview_local(ap, context, payload):
await _fence(ap, context)
try:
manifest, requirements, names = await asyncio.to_thread(inspect_plugin_archive_metadata, payload.content)
except zipfile.BadZipFile as exc:
raise ValueError('Invalid .lbpkg file') from exc
counts = count_plugin_components((manifest.get('spec') or {}).get('components') or {}, names)
return {
'filename': payload.filename,
'size': len(payload.content),
'manifest': manifest,
'metadata': manifest.get('metadata') or {},
'component_types': list(counts),
'component_counts': counts,
'requirements': requirements,
'file_count': len(names),
}
async def upload_config_file(ap, context, payload):
await _fence(ap, context)
if len(payload.content) > 10 * 1024 * 1024:
raise ValueError('File size exceeds 10MB limit')
_, ext = os.path.splitext(payload.filename)
key = await ap.storage_mgr.save_scoped(
context,
owner_type='plugin_config',
owner=context.workspace_uuid,
key=f'plugin_config_{uuid.uuid4().hex}{ext}',
value=payload.content,
)
return {'file_key': key}
async def delete_config_file(ap, context, payload):
await _fence(ap, context)
if not ap.storage_mgr.is_scoped_object_key(payload.file_key, expected_owner_type='plugin_config'):
raise ValueError('Invalid file key')
await ap.storage_mgr.delete_scoped_object_key(context, payload.file_key, expected_owner_type='plugin_config')
return {'deleted': True}
def operations() -> list[Operation]:
view, manage = Permission.RESOURCE_VIEW, Permission.RESOURCE_MANAGE
return [
Operation('plugin.list', view, list_plugins, Input, PluginsResult),
Operation('plugin.get', view, get_plugin, PluginInput, PluginResult),
Operation('plugin.debug_info', manage, debug_info, Input, DebugResult),
Operation('plugin.config.get', view, get_config, PluginInput, ConfigResult),
Operation('plugin.config.update', manage, update_config, ConfigInput, dict, side_effect=True),
Operation('plugin.readme', view, readme, ReadmeInput, ReadmeResult),
Operation('plugin.logs', Permission.AUDIT_VIEW, logs, LogsInput, dict),
Operation('plugin.icon', view, icon, PluginInput, BinaryResult),
Operation('plugin.asset', view, asset, AssetInput, BinaryResult),
Operation('plugin.page_action', manage, page_action, PageInput, Any, side_effect=True),
Operation('plugin.github.releases', view, github_releases, GithubRepoInput, dict),
Operation('plugin.github.assets', view, github_assets, GithubAssetsInput, dict),
Operation('plugin.upgrade', manage, upgrade, PluginInput, TaskResult, side_effect=True),
Operation('plugin.delete', manage, delete, DeleteInput, TaskResult, side_effect=True),
Operation('plugin.install.github', manage, install_github, GithubInstallInput, TaskResult, side_effect=True),
Operation(
'plugin.install.marketplace', manage, install_marketplace, MarketplaceInput, TaskResult, side_effect=True
),
Operation('plugin.install.local', manage, install_local, LocalInstallInput, TaskResult, side_effect=True),
Operation('plugin.install.local.preview', manage, preview_local, FileInput, dict),
Operation('plugin.config_file.upload', manage, upload_config_file, FileInput, FileResult, side_effect=True),
Operation(
'plugin.config_file.delete', manage, delete_config_file, FileKeyInput, DeletedResult, side_effect=True
),
]
@@ -0,0 +1,999 @@
"""Explicit service-backed operations; schemas follow their business signatures."""
from __future__ import annotations
import importlib
import inspect
import typing
from functools import lru_cache
from pydantic import Field, create_model
from ..authz import Permission, require_permission
from ..context import PrincipalType
from ...workspace.errors import WorkspaceNotFoundError
from .contracts import Input, Operation
# Only these service methods are public. Adding a service method never exposes it.
_BINDINGS = (
(
'monitoring.session.reset',
'monitoring_service',
'monitoring',
'MonitoringService',
'reset_session_context',
'RESOURCE_MANAGE',
True,
),
('pipeline.list', 'pipeline_service', 'pipeline', 'PipelineService', 'get_pipelines', 'RESOURCE_VIEW', False),
('pipeline.get', 'pipeline_service', 'pipeline', 'PipelineService', 'get_pipeline', 'RESOURCE_VIEW', False),
('pipeline.create', 'pipeline_service', 'pipeline', 'PipelineService', 'create_pipeline', 'RESOURCE_MANAGE', True),
('pipeline.update', 'pipeline_service', 'pipeline', 'PipelineService', 'update_pipeline', 'RESOURCE_MANAGE', True),
('pipeline.delete', 'pipeline_service', 'pipeline', 'PipelineService', 'delete_pipeline', 'RESOURCE_MANAGE', True),
('pipeline.copy', 'pipeline_service', 'pipeline', 'PipelineService', 'copy_pipeline', 'RESOURCE_MANAGE', True),
(
'pipeline.metadata',
'pipeline_service',
'pipeline',
'PipelineService',
'get_pipeline_metadata',
'RESOURCE_VIEW',
False,
),
(
'pipeline.defaults',
'pipeline_service',
'pipeline',
'PipelineService',
'get_default_pipeline_config',
'RESOURCE_VIEW',
False,
),
(
'pipeline.extensions.get',
'pipeline_service',
'pipeline',
'PipelineService',
'get_pipeline_extensions',
'RESOURCE_VIEW',
False,
),
(
'pipeline.extensions.update',
'pipeline_service',
'pipeline',
'PipelineService',
'update_pipeline_extensions',
'RESOURCE_MANAGE',
True,
),
('model.llm.list', 'llm_model_service', 'model', 'LLMModelsService', 'get_llm_models', 'RESOURCE_VIEW', False),
(
'model.llm.list_by_provider',
'llm_model_service',
'model',
'LLMModelsService',
'get_llm_models_by_provider',
'RESOURCE_VIEW',
False,
),
('model.llm.get', 'llm_model_service', 'model', 'LLMModelsService', 'get_llm_model', 'RESOURCE_VIEW', False),
(
'model.llm.create',
'llm_model_service',
'model',
'LLMModelsService',
'create_llm_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.llm.update',
'llm_model_service',
'model',
'LLMModelsService',
'update_llm_model',
'PROVIDER_SECRET_MANAGE',
True,
),
('model.llm.delete', 'llm_model_service', 'model', 'LLMModelsService', 'delete_llm_model', 'RESOURCE_MANAGE', True),
(
'model.llm.test',
'llm_model_service',
'model',
'LLMModelsService',
'test_llm_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.llm.starred.get',
'llm_model_service',
'model',
'LLMModelsService',
'get_starred_model',
'RESOURCE_VIEW',
False,
),
(
'model.llm.starred.set',
'llm_model_service',
'model',
'LLMModelsService',
'set_starred_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.llm.default.get',
'llm_model_service',
'model',
'LLMModelsService',
'get_default_model',
'RESOURCE_MANAGE',
False,
),
(
'model.embedding.list',
'embedding_models_service',
'model',
'EmbeddingModelsService',
'get_embedding_models',
'RESOURCE_VIEW',
False,
),
(
'model.embedding.list_by_provider',
'embedding_models_service',
'model',
'EmbeddingModelsService',
'get_embedding_models_by_provider',
'RESOURCE_VIEW',
False,
),
(
'model.embedding.get',
'embedding_models_service',
'model',
'EmbeddingModelsService',
'get_embedding_model',
'RESOURCE_VIEW',
False,
),
(
'model.embedding.create',
'embedding_models_service',
'model',
'EmbeddingModelsService',
'create_embedding_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.embedding.update',
'embedding_models_service',
'model',
'EmbeddingModelsService',
'update_embedding_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.embedding.delete',
'embedding_models_service',
'model',
'EmbeddingModelsService',
'delete_embedding_model',
'RESOURCE_MANAGE',
True,
),
(
'model.embedding.test',
'embedding_models_service',
'model',
'EmbeddingModelsService',
'test_embedding_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.rerank.list',
'rerank_models_service',
'model',
'RerankModelsService',
'get_rerank_models',
'RESOURCE_VIEW',
False,
),
(
'model.rerank.list_by_provider',
'rerank_models_service',
'model',
'RerankModelsService',
'get_rerank_models_by_provider',
'RESOURCE_VIEW',
False,
),
(
'model.rerank.get',
'rerank_models_service',
'model',
'RerankModelsService',
'get_rerank_model',
'RESOURCE_VIEW',
False,
),
(
'model.rerank.create',
'rerank_models_service',
'model',
'RerankModelsService',
'create_rerank_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.rerank.update',
'rerank_models_service',
'model',
'RerankModelsService',
'update_rerank_model',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'model.rerank.delete',
'rerank_models_service',
'model',
'RerankModelsService',
'delete_rerank_model',
'RESOURCE_MANAGE',
True,
),
(
'model.rerank.test',
'rerank_models_service',
'model',
'RerankModelsService',
'test_rerank_model',
'PROVIDER_SECRET_MANAGE',
True,
),
('provider.list', 'provider_service', 'provider', 'ModelProviderService', 'get_providers', 'RESOURCE_VIEW', False),
('provider.get', 'provider_service', 'provider', 'ModelProviderService', 'get_provider', 'RESOURCE_VIEW', False),
(
'provider.create',
'provider_service',
'provider',
'ModelProviderService',
'create_provider',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'provider.update',
'provider_service',
'provider',
'ModelProviderService',
'update_provider',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'provider.delete',
'provider_service',
'provider',
'ModelProviderService',
'delete_provider',
'RESOURCE_MANAGE',
True,
),
(
'provider.models.scan',
'provider_service',
'provider',
'ModelProviderService',
'scan_provider_models',
'PROVIDER_SECRET_MANAGE',
True,
),
(
'provider.models.count',
'provider_service',
'provider',
'ModelProviderService',
'get_provider_model_counts',
'RESOURCE_VIEW',
False,
),
('agent.list', 'agent_service', 'agent', 'AgentService', 'get_agents', 'RESOURCE_VIEW', False),
('agent.get', 'agent_service', 'agent', 'AgentService', 'get_agent', 'RESOURCE_VIEW', False),
('agent.create', 'agent_service', 'agent', 'AgentService', 'create_agent', 'RESOURCE_MANAGE', True),
('agent.update', 'agent_service', 'agent', 'AgentService', 'update_agent', 'RESOURCE_MANAGE', True),
('agent.delete', 'agent_service', 'agent', 'AgentService', 'delete_agent', 'RESOURCE_MANAGE', True),
('agent.metadata', 'agent_service', 'agent', 'AgentService', 'get_agent_metadata', 'RESOURCE_VIEW', False),
('agent.debug', 'agent_service', 'agent', 'AgentService', 'debug_agent', 'RUNTIME_OPERATE', True),
('agent.runs.list', 'agent_service', 'agent', 'AgentService', 'get_processor_runs', 'RESOURCE_VIEW', False),
('agent.runs.events', 'agent_service', 'agent', 'AgentService', 'get_processor_run_events', 'RESOURCE_VIEW', False),
('bot.list', 'bot_service', 'bot', 'BotService', 'get_bots', 'RESOURCE_VIEW', False),
('bot.get', 'bot_service', 'bot', 'BotService', 'get_bot', 'RESOURCE_VIEW', False),
('bot.runtime.get', 'bot_service', 'bot', 'BotService', 'get_runtime_bot_info', 'RESOURCE_VIEW', False),
('bot.create', 'bot_service', 'bot', 'BotService', 'create_bot', 'RESOURCE_MANAGE', True),
('bot.update', 'bot_service', 'bot', 'BotService', 'update_bot', 'RESOURCE_MANAGE', True),
('bot.delete', 'bot_service', 'bot', 'BotService', 'delete_bot', 'RESOURCE_MANAGE', True),
('bot.logs', 'bot_service', 'bot', 'BotService', 'list_event_logs', 'RESOURCE_VIEW', False),
('bot.routes.status', 'bot_service', 'bot', 'BotService', 'list_event_route_statuses', 'RESOURCE_VIEW', False),
(
'bot.group_filter.preview',
'bot_service',
'bot',
'BotService',
'preview_group_filter_migration',
'RESOURCE_MANAGE',
False,
),
(
'bot.group_filter.apply',
'bot_service',
'bot',
'BotService',
'apply_group_filter_migration',
'RESOURCE_MANAGE',
True,
),
('bot.message.send', 'bot_service', 'bot', 'BotService', 'send_message', 'RUNTIME_OPERATE', True),
('bot.message.test', 'bot_service', 'bot', 'BotService', 'send_http_bot_test_message', 'RESOURCE_MANAGE', True),
('bot.admins.list', 'bot_service', 'bot', 'BotService', 'get_bot_admins', 'RESOURCE_VIEW', False),
('bot.admins.add', 'bot_service', 'bot', 'BotService', 'add_bot_admin', 'RESOURCE_MANAGE', True),
('bot.admins.delete', 'bot_service', 'bot', 'BotService', 'delete_bot_admin', 'RESOURCE_MANAGE', True),
(
'knowledge.base.list',
'knowledge_service',
'knowledge',
'KnowledgeService',
'get_knowledge_bases',
'RESOURCE_VIEW',
False,
),
(
'knowledge.base.get',
'knowledge_service',
'knowledge',
'KnowledgeService',
'get_knowledge_base',
'RESOURCE_VIEW',
False,
),
(
'knowledge.base.create',
'knowledge_service',
'knowledge',
'KnowledgeService',
'create_knowledge_base',
'RESOURCE_MANAGE',
True,
),
(
'knowledge.base.update',
'knowledge_service',
'knowledge',
'KnowledgeService',
'update_knowledge_base',
'RESOURCE_MANAGE',
True,
),
(
'knowledge.base.delete',
'knowledge_service',
'knowledge',
'KnowledgeService',
'delete_knowledge_base',
'RESOURCE_MANAGE',
True,
),
(
'knowledge.base.retrieve',
'knowledge_service',
'knowledge',
'KnowledgeService',
'retrieve_knowledge_base',
'RESOURCE_VIEW',
False,
),
(
'knowledge.file.list',
'knowledge_service',
'knowledge',
'KnowledgeService',
'get_files_by_knowledge_base',
'RESOURCE_VIEW',
False,
),
(
'knowledge.file.store',
'knowledge_service',
'knowledge',
'KnowledgeService',
'store_file',
'RESOURCE_MANAGE',
True,
),
(
'knowledge.file.delete',
'knowledge_service',
'knowledge',
'KnowledgeService',
'delete_file',
'RESOURCE_MANAGE',
True,
),
(
'knowledge.engine.list',
'knowledge_service',
'knowledge',
'KnowledgeService',
'list_knowledge_engines',
'RESOURCE_VIEW',
False,
),
(
'knowledge.engine.creation_schema',
'knowledge_service',
'knowledge',
'KnowledgeService',
'get_engine_creation_schema',
'RESOURCE_VIEW',
False,
),
(
'knowledge.engine.retrieval_schema',
'knowledge_service',
'knowledge',
'KnowledgeService',
'get_engine_retrieval_schema',
'RESOURCE_VIEW',
False,
),
(
'knowledge.parser.list',
'knowledge_service',
'knowledge',
'KnowledgeService',
'list_parsers',
'RESOURCE_VIEW',
False,
),
('mcp.server.list', 'mcp_service', 'mcp', 'MCPService', 'get_mcp_servers', 'RESOURCE_VIEW', False),
('mcp.server.get', 'mcp_service', 'mcp', 'MCPService', 'get_mcp_server_by_name', 'RESOURCE_VIEW', False),
('mcp.server.create', 'mcp_service', 'mcp', 'MCPService', 'create_mcp_server', 'RESOURCE_MANAGE', True),
('mcp.server.update', 'mcp_service', 'mcp', 'MCPService', 'update_mcp_server', 'RESOURCE_MANAGE', True),
('mcp.server.delete', 'mcp_service', 'mcp', 'MCPService', 'delete_mcp_server', 'RESOURCE_MANAGE', True),
('mcp.server.test', 'mcp_service', 'mcp', 'MCPService', 'test_mcp_server', 'RESOURCE_MANAGE', True),
('mcp.server.logs', 'mcp_service', 'mcp', 'MCPService', 'get_mcp_server_logs', 'AUDIT_VIEW', False),
('mcp.server.runtime', 'mcp_service', 'mcp', 'MCPService', 'get_runtime_info', 'RESOURCE_VIEW', False),
('mcp.resource.list', 'mcp_service', 'mcp', 'MCPService', 'get_mcp_server_resources', 'RESOURCE_VIEW', False),
(
'mcp.resource.templates',
'mcp_service',
'mcp',
'MCPService',
'get_mcp_server_resource_templates',
'RESOURCE_VIEW',
False,
),
(
'mcp.resource.read',
'mcp_service',
'mcp',
'MCPService',
'read_mcp_server_resource_envelope',
'RESOURCE_VIEW',
False,
),
('skill.list', 'skill_service', 'skill', 'SkillService', 'list_skills', 'RESOURCE_VIEW', False),
('skill.get', 'skill_service', 'skill', 'SkillService', 'get_skill', 'RESOURCE_VIEW', False),
('skill.create', 'skill_service', 'skill', 'SkillService', 'create_skill', 'RESOURCE_MANAGE', True),
('skill.update', 'skill_service', 'skill', 'SkillService', 'update_skill', 'RESOURCE_MANAGE', True),
('skill.delete', 'skill_service', 'skill', 'SkillService', 'delete_skill', 'RESOURCE_MANAGE', True),
('skill.file.list', 'skill_service', 'skill', 'SkillService', 'list_skill_files', 'RESOURCE_VIEW', False),
('skill.file.read', 'skill_service', 'skill', 'SkillService', 'read_skill_file', 'RESOURCE_VIEW', False),
('skill.file.write', 'skill_service', 'skill', 'SkillService', 'write_skill_file', 'RESOURCE_MANAGE', True),
('skill.github.install', 'skill_service', 'skill', 'SkillService', 'install_from_github', 'RESOURCE_MANAGE', True),
(
'skill.github.preview',
'skill_service',
'skill',
'SkillService',
'preview_install_from_github',
'RESOURCE_MANAGE',
False,
),
('skill.zip.install', 'skill_service', 'skill', 'SkillService', 'install_from_zip_upload', 'RESOURCE_MANAGE', True),
(
'skill.zip.preview',
'skill_service',
'skill',
'SkillService',
'preview_install_from_zip_upload',
'RESOURCE_MANAGE',
False,
),
(
'skill.directory.scan',
'skill_service',
'skill',
'SkillService',
'scan_directory_async',
'RESOURCE_MANAGE',
False,
),
(
'skill.directory.import',
'skill_service',
'skill',
'SkillService',
'import_skill_directory',
'RESOURCE_MANAGE',
True,
),
('skill.reload', 'skill_service', 'skill', 'SkillService', 'reload_skills', 'RESOURCE_MANAGE', True),
('api_key.list', 'apikey_service', 'apikey', 'ApiKeyService', 'get_api_keys', 'API_KEY_MANAGE', False),
('api_key.get', 'apikey_service', 'apikey', 'ApiKeyService', 'get_api_key', 'API_KEY_MANAGE', False),
('api_key.create', 'apikey_service', 'apikey', 'ApiKeyService', 'create_api_key', 'API_KEY_MANAGE', True),
('api_key.update', 'apikey_service', 'apikey', 'ApiKeyService', 'update_api_key', 'API_KEY_MANAGE', True),
('api_key.delete', 'apikey_service', 'apikey', 'ApiKeyService', 'delete_api_key', 'API_KEY_MANAGE', True),
('webhook.list', 'webhook_service', 'webhook', 'WebhookService', 'get_webhooks', 'RESOURCE_VIEW', False),
('webhook.get', 'webhook_service', 'webhook', 'WebhookService', 'get_webhook', 'RESOURCE_VIEW', False),
('webhook.create', 'webhook_service', 'webhook', 'WebhookService', 'create_webhook', 'RESOURCE_MANAGE', True),
('webhook.update', 'webhook_service', 'webhook', 'WebhookService', 'update_webhook', 'RESOURCE_MANAGE', True),
('webhook.delete', 'webhook_service', 'webhook', 'WebhookService', 'delete_webhook', 'RESOURCE_MANAGE', True),
(
'monitoring.overview',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_overview_metrics',
'RESOURCE_VIEW',
False,
),
(
'monitoring.executions',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_executions',
'RESOURCE_VIEW',
False,
),
(
'monitoring.inflight',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_inflight_snapshot',
'RESOURCE_VIEW',
False,
),
(
'monitoring.tokens',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_token_statistics',
'RESOURCE_VIEW',
False,
),
(
'monitoring.messages',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_messages',
'RESOURCE_VIEW',
False,
),
(
'monitoring.llm_calls',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_llm_calls',
'RESOURCE_VIEW',
False,
),
(
'monitoring.tool_calls',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_tool_calls',
'RESOURCE_VIEW',
False,
),
(
'monitoring.embedding_calls',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_embedding_calls',
'RESOURCE_VIEW',
False,
),
(
'monitoring.sessions',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_sessions',
'RESOURCE_VIEW',
False,
),
(
'monitoring.errors',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_errors',
'RESOURCE_VIEW',
False,
),
(
'monitoring.session.analysis',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_session_analysis',
'RESOURCE_VIEW',
False,
),
(
'monitoring.message.detail',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_message_details',
'RESOURCE_VIEW',
False,
),
(
'monitoring.feedback.stats',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_feedback_stats',
'RESOURCE_VIEW',
False,
),
(
'monitoring.feedback.list',
'monitoring_service',
'monitoring',
'MonitoringService',
'get_feedback_list',
'RESOURCE_VIEW',
False,
),
(
'monitoring.export.messages',
'monitoring_service',
'monitoring',
'MonitoringService',
'export_messages',
'DATA_EXPORT',
False,
),
(
'monitoring.export.llm_calls',
'monitoring_service',
'monitoring',
'MonitoringService',
'export_llm_calls',
'DATA_EXPORT',
False,
),
(
'monitoring.export.embedding_calls',
'monitoring_service',
'monitoring',
'MonitoringService',
'export_embedding_calls',
'DATA_EXPORT',
False,
),
(
'monitoring.export.errors',
'monitoring_service',
'monitoring',
'MonitoringService',
'export_errors',
'DATA_EXPORT',
False,
),
(
'monitoring.export.sessions',
'monitoring_service',
'monitoring',
'MonitoringService',
'export_sessions',
'DATA_EXPORT',
False,
),
(
'monitoring.export.feedback',
'monitoring_service',
'monitoring',
'MonitoringService',
'export_feedback',
'DATA_EXPORT',
False,
),
(
'monitoring.execution.detail',
'monitoring_service',
'monitoring_execution_details',
'ExecutionDetailsMixin',
'get_execution_detail',
'RESOURCE_VIEW',
False,
),
)
# These methods predate annotated service contracts. Specify their public types explicitly.
_FIELD_TYPES = {
'processor_id': str,
'run_id': str,
'before_id': int | None,
'after_sequence': int | None,
'source': str,
'execution_id': str,
'section': str | None,
'scopes': list[str] | None,
}
_OUTPUT_TYPES = {
'get_processor_runs': dict,
'get_processor_run_events': dict,
'get_execution_detail': dict,
'get_engine_creation_schema': dict | list[dict],
'get_engine_retrieval_schema': dict | list[dict],
}
_INTERNAL_PARAMETERS = {'preserve_uuid', 'default', 'on_result', 'for_inflight'}
def _contract(operation_id, method):
signature = inspect.signature(method)
hints = typing.get_type_hints(method)
fields = {}
for name, parameter in signature.parameters.items():
if name in {'self', 'context', 'ctx'} | _INTERNAL_PARAMETERS:
continue
annotation = _FIELD_TYPES.get(name, hints.get(name))
default = parameter.default
if annotation is None:
if default is inspect.Parameter.empty or default is None:
raise TypeError(f'Missing application input type: {operation_id}.{name}')
annotation = type(default)
if default is None:
annotation = annotation | None
default = ... if default is inspect.Parameter.empty else default
constraints = {}
if name in {'limit', 'max_count', 'max_entries'}:
constraints = {'ge': 1, 'le': 100_000 if '.export.' in operation_id else 1000}
elif name in {'offset', 'from_index', 'after_sequence', 'before_id'}:
constraints = {'ge': 0}
elif annotation is str and (name.endswith(('_uuid', '_id')) or name in {'query', 'uri'}):
constraints = {'min_length': 1}
fields[name] = (annotation, Field(default=default, **constraints))
model_name = ''.join(part.title() for part in operation_id.split('.')) + 'Input'
model = create_model(model_name, __base__=Input, **fields)
output = _OUTPUT_TYPES.get(method.__name__, hints.get('return', inspect.Parameter.empty))
if output is inspect.Parameter.empty:
raise TypeError(f'Missing application output type: {operation_id}')
return model, output
def _operation(operation_id, service_name, method, permission, side_effect):
input_type, output_type = _contract(operation_id, method)
async def invoke(ap, context, payload):
arguments = payload.model_dump()
if arguments.get('include_secret'):
secret_permission = (
Permission.PROVIDER_SECRET_MANAGE
if operation_id.startswith(('model.', 'provider.'))
else Permission.RESOURCE_MANAGE
)
require_permission(context, secret_permission)
service = getattr(ap, service_name)
result = await getattr(service, method.__name__)(context, **arguments)
if result is None and operation_id.endswith('.get') and output_type != type(None):
# Optional defaults (e.g. starred model) are intentionally nullable.
if operation_id not in {'model.llm.starred.get', 'model.llm.default.get'}:
raise WorkspaceNotFoundError('Resource not found')
return result
return Operation(operation_id, Permission[permission], invoke, input_type, output_type, side_effect)
@lru_cache(maxsize=1)
def operations() -> tuple[Operation, ...]:
result = []
for operation_id, service_name, module, cls, method_name, permission, side_effect in _BINDINGS:
service_module = importlib.import_module('langbot.pkg.api.http.service.' + module)
method = getattr(getattr(service_module, cls), method_name)
result.append(_operation(operation_id, service_name, method, permission, side_effect))
result.extend(_special_operations())
return tuple(result)
class PipelineRunInput(Input):
pipeline_uuid: str = Field(min_length=1)
text: str = Field(min_length=1, max_length=100_000)
timeout: float = Field(default=60.0, gt=0, le=300)
async def _pipeline_run(ap, context, payload):
from ..http.service.pipeline_run import run_pipeline
return await run_pipeline(ap, context, **payload.model_dump())
class CodexProviderInput(Input):
provider: str = Field(min_length=1)
class CodexAttemptInput(CodexProviderInput):
authorization_id: str = Field(min_length=1)
class ConversationInput(Input):
conversation_id: str = Field(min_length=1)
class StopInput(ConversationInput):
revision: int = Field(ge=0)
class TurnInput(StopInput):
text: str | None = Field(default=None, min_length=1, max_length=8000)
approved: bool | None = None
model_uuid: str | None = None
def _assistant_service(ap):
from ..http.service.assistant import AssistantService
service = getattr(ap, '_application_assistant_service', None)
if service is None:
service = ap._application_assistant_service = AssistantService(ap)
return service
def _validate_turn(payload):
if (payload.text is None) == (payload.approved is None) or (payload.text is not None and not payload.text.strip()):
raise ValueError('Provide text or an approval decision')
async def _assistant(ap, context, payload, method):
service = _assistant_service(ap)
arguments = payload.model_dump()
if method == 'turn':
_validate_turn(payload)
result = await getattr(service, method)(context, **arguments)
return service.public_view(result) if method in {'create', 'turn'} else result
class RoutePreviewInput(Input):
bot_uuid: str = Field(min_length=1)
event_type: str = Field(min_length=1)
event_data: dict | None = None
event_context: dict | None = None
event_bindings: list[dict] | None = None
async def _route_preview(ap, context, payload):
return await ap.bot_service.dry_run_event_route(
context,
payload.bot_uuid,
payload.event_type,
event_data=payload.event_data,
context=payload.event_context,
event_bindings=payload.event_bindings,
)
class MigrationInput(Input):
body: dict
def _migration_service(ap):
from ..http.service.pipeline_migration import PipelineMigrationService
service = getattr(ap, '_application_pipeline_migration_service', None)
if service is None:
service = ap._application_pipeline_migration_service = PipelineMigrationService(ap)
return service
async def _migration_preview(ap, context, payload):
return await _migration_service(ap).preview(context)
async def _migration_execute(ap, context, payload):
return await _migration_service(ap).execute(context, payload.body)
async def _assistant_start(ap, context, payload):
_validate_turn(payload)
service = _assistant_service(ap)
await service.get(context, payload.conversation_id)
service.start_run(context, payload.conversation_id, payload)
return {'conversation_id': payload.conversation_id, 'revision': payload.revision + 1}
def _special_operations():
account = frozenset({PrincipalType.ACCOUNT})
result = [
Operation('pipeline.run', Permission.RUNTIME_OPERATE, _pipeline_run, PipelineRunInput, dict, True),
Operation('bot.routes.preview', Permission.RESOURCE_MANAGE, _route_preview, RoutePreviewInput, dict),
Operation(
'pipeline.migration.preview', Permission.RESOURCE_VIEW, _migration_preview, Input, dict, principals=account
),
Operation(
'pipeline.migration.execute',
Permission.RESOURCE_MANAGE,
_migration_execute,
MigrationInput,
dict,
True,
principals=account,
),
Operation(
'assistant.run.start',
Permission.RUNTIME_OPERATE,
_assistant_start,
TurnInput,
dict,
True,
principals=account,
),
]
for method in ('status', 'start', 'poll', 'disconnect', 'cancel'):
async def invoke(ap, context, payload, method=method):
return await getattr(ap.provider_service.codex_auth, method)(context, **payload.model_dump())
result.append(
Operation(
'provider.codex.' + method,
Permission.PROVIDER_SECRET_MANAGE,
invoke,
CodexAttemptInput if method in {'poll', 'cancel'} else CodexProviderInput,
type(None) if method in {'disconnect', 'cancel'} else dict,
method != 'status',
principals=frozenset({PrincipalType.ACCOUNT}),
)
)
for name, method, model, output, permission, write in (
('list', 'list_conversations', Input, list[dict], Permission.RESOURCE_VIEW, False),
('get', 'progress', ConversationInput, dict, Permission.RESOURCE_VIEW, False),
('create', 'create', Input, dict, Permission.RUNTIME_OPERATE, True),
('stop', 'stop', StopInput, dict, Permission.RUNTIME_OPERATE, True),
('turn', 'turn', TurnInput, dict, Permission.RUNTIME_OPERATE, True),
('recommended_model', 'recommended_model', Input, dict, Permission.RUNTIME_OPERATE, False),
):
async def invoke(ap, context, payload, method=method):
return await _assistant(ap, context, payload, method)
result.append(
Operation(
'assistant.' + name,
permission,
invoke,
model,
output,
write,
principals=frozenset({PrincipalType.ACCOUNT}),
)
)
return result
@@ -0,0 +1,49 @@
"""Bounded streaming that keeps producer authorization scopes out of the consumer."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import aclosing, suppress
from typing import TypeVar
T = TypeVar('T')
async def isolated_stream(source: AsyncIterator[T]) -> AsyncIterator[T]:
queue: asyncio.Queue[tuple[bool, T | Exception | None]] = asyncio.Queue(maxsize=1)
async def produce():
try:
async with aclosing(source):
async for item in source:
await queue.put((True, item))
except Exception as exc:
await queue.put((False, exc))
else:
await queue.put((False, None))
producer = asyncio.create_task(produce())
try:
while True:
receive = asyncio.create_task(queue.get())
try:
done, _ = await asyncio.wait({receive, producer}, return_when=asyncio.FIRST_COMPLETED)
if receive not in done and queue.empty():
# Cancellation inside a provider must also wake the consumer.
await producer
available, value = await receive
finally:
receive.cancel()
with suppress(asyncio.CancelledError):
await receive
if available:
yield value
elif value is None:
return
else:
raise value
finally:
producer.cancel()
with suppress(asyncio.CancelledError):
await producer
@@ -0,0 +1,105 @@
"""Business streams with the lifetime rules of Agent debug and Assistant runs."""
from __future__ import annotations
import asyncio
from contextlib import suppress
from pydantic import Field
from .contracts import Input, Operation
from .service_operations import TurnInput
from ..authz import Permission
from ..context import PrincipalType
class AgentDebugInput(Input):
agent_uuid: str = Field(min_length=1)
payload: dict
async def agent_debug(ap, context, payload):
async def frames():
queue: asyncio.Queue[dict | Exception] = asyncio.Queue(maxsize=32)
async def on_result(result):
await queue.put({'kind': 'result', 'data': result})
async def execute():
try:
# The callback producer is a different task and needs its own DB scope.
async with ap.persistence_mgr.tenant_scope(context.workspace_uuid):
result = await ap.agent_service.debug_agent(
context, payload.agent_uuid, payload.payload, on_result=on_result
)
await queue.put({'kind': 'completed', 'data': result})
except Exception as exc:
await queue.put(exc)
task = asyncio.create_task(execute())
try:
while True:
receive = asyncio.create_task(queue.get())
try:
done, _ = await asyncio.wait({receive, task}, return_when=asyncio.FIRST_COMPLETED)
if receive not in done and queue.empty():
# A provider cancellation has no queued terminal frame.
await task
frame = await receive
finally:
receive.cancel()
with suppress(asyncio.CancelledError):
await receive
if isinstance(frame, Exception):
raise frame
yield frame
if frame['kind'] == 'completed':
return
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
return frames()
async def assistant_turn(ap, context, payload):
from .service_operations import _assistant_service, _validate_turn
_validate_turn(payload)
service = _assistant_service(ap)
await service.get(context, payload.conversation_id)
queue = service.start_run(context, payload.conversation_id, payload)
async def frames():
# start_run owns a bounded queue and the run survives a subscriber disconnect.
while True:
frame = await queue.get()
yield frame
if frame['kind'] in {'completed', 'error'}:
return
return frames()
def operations() -> list[Operation]:
return [
Operation(
'agent.debug.stream',
Permission.RUNTIME_OPERATE,
agent_debug,
AgentDebugInput,
dict,
side_effect=True,
streaming=True,
),
Operation(
'assistant.stream',
Permission.RUNTIME_OPERATE,
assistant_turn,
TurnInput,
dict,
side_effect=True,
principals=frozenset({PrincipalType.ACCOUNT}),
streaming=True,
),
]
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import enum
import types
import typing
from .context import RequestContext
class WorkspaceRole(enum.StrEnum):
OWNER = 'owner'
ADMIN = 'admin'
DEVELOPER = 'developer'
OPERATOR = 'operator'
VIEWER = 'viewer'
class Permission(enum.StrEnum):
WORKSPACE_VIEW = 'workspace.view'
WORKSPACE_UPDATE = 'workspace.update'
WORKSPACE_DELETE = 'workspace.delete'
MEMBER_VIEW = 'member.view'
MEMBER_INVITE = 'member.invite'
MEMBER_UPDATE_ROLE = 'member.update_role'
MEMBER_REMOVE = 'member.remove'
RESOURCE_VIEW = 'resource.view'
RESOURCE_MANAGE = 'resource.manage'
RUNTIME_OPERATE = 'runtime.operate'
PROVIDER_SECRET_MANAGE = 'provider_secret.manage'
API_KEY_MANAGE = 'api_key.manage'
AUDIT_VIEW = 'audit.view'
DATA_EXPORT = 'data.export'
BILLING_LINK_MANAGE = 'billing_link.manage'
_VIEW_PERMISSIONS = {
Permission.WORKSPACE_VIEW,
Permission.MEMBER_VIEW,
Permission.RESOURCE_VIEW,
}
_ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
{
WorkspaceRole.OWNER: frozenset(Permission),
WorkspaceRole.ADMIN: frozenset(
permission
for permission in Permission
if permission
not in {
Permission.WORKSPACE_DELETE,
Permission.BILLING_LINK_MANAGE,
}
),
WorkspaceRole.DEVELOPER: frozenset(
_VIEW_PERMISSIONS
| {
Permission.RESOURCE_MANAGE,
Permission.RUNTIME_OPERATE,
Permission.PROVIDER_SECRET_MANAGE,
}
),
WorkspaceRole.OPERATOR: frozenset(_VIEW_PERMISSIONS | {Permission.RUNTIME_OPERATE}),
WorkspaceRole.VIEWER: frozenset(_VIEW_PERMISSIONS),
}
)
class AuthorizationError(Exception):
"""Base class for errors that map to an HTTP authorization response."""
status_code = 403
error_code = 'forbidden'
class AuthenticationDeniedError(AuthorizationError):
status_code = 401
error_code = 'invalid_authentication'
class WorkspaceRequiredError(AuthorizationError):
status_code = 400
error_code = 'workspace_required'
class PermissionDeniedError(AuthorizationError):
error_code = 'permission_denied'
def __init__(self, permission: str) -> None:
super().__init__(f'Missing Workspace permission: {permission}')
self.permission = permission
class EditionLimitError(AuthorizationError):
error_code = 'edition_limit'
def permissions_for_role(role: str | WorkspaceRole) -> frozenset[str]:
"""Return the canonical fixed permissions for a Workspace role."""
try:
parsed_role = WorkspaceRole(role)
except ValueError:
return frozenset()
return frozenset(permission.value for permission in _ROLE_PERMISSIONS[parsed_role])
def has_permission(ctx: RequestContext, permission: str | Permission) -> bool:
"""Return whether the context contains one effective permission."""
permission_value = permission.value if isinstance(permission, Permission) else permission
return permission_value in ctx.workspace.permissions
def require_permission(ctx: RequestContext, permission: str | Permission) -> None:
"""Raise a stable authorization error when a permission is missing."""
permission_value = permission.value if isinstance(permission, Permission) else permission
if not has_permission(ctx, permission_value):
raise PermissionDeniedError(permission_value)
__all__ = [
'AuthenticationDeniedError',
'AuthorizationError',
'EditionLimitError',
'Permission',
'PermissionDeniedError',
'WorkspaceRequiredError',
'WorkspaceRole',
'has_permission',
'permissions_for_role',
'require_permission',
]
+113
View File
@@ -0,0 +1,113 @@
"""Transport-neutral identity and Workspace execution context types.
The application operation layer uses these immutable values as its trusted
authorization boundary. HTTP, MCP and other protocol adapters may construct
or carry them, but the types themselves do not depend on any transport.
"""
from __future__ import annotations
import dataclasses
import enum
class PrincipalType(enum.StrEnum):
"""Kinds of authenticated principals accepted by LangBot."""
ACCOUNT = 'account'
API_KEY = 'api_key'
SUPPORT_ADMIN = 'support_admin'
SYSTEM = 'system'
PUBLIC_BOT = 'public_bot'
@dataclasses.dataclass(frozen=True, slots=True)
class PrincipalContext:
"""Authenticated identity before Workspace authorization is applied."""
principal_type: PrincipalType
account_uuid: str | None = None
actor_account_uuid: str | None = None
api_key_uuid: str | None = None
support_session_id: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceContext:
"""Workspace membership and effective permissions for one request."""
workspace_uuid: str
membership_uuid: str | None
role: str | None
permissions: frozenset[str]
membership_revision: int = 0
@dataclasses.dataclass(frozen=True, slots=True)
class RequestContext:
"""Trusted authorization context passed to application operations."""
instance_uuid: str
placement_generation: int
request_id: str
auth_type: str
principal: PrincipalContext
workspace: WorkspaceContext
entitlement_revision: int = 0
@property
def workspace_uuid(self) -> str:
"""Return the selected Workspace UUID."""
return self.workspace.workspace_uuid
@property
def account_uuid(self) -> str | None:
"""Return the Account UUID when the principal is an Account."""
return self.principal.account_uuid
@dataclasses.dataclass(frozen=True, slots=True)
class ExecutionContext:
"""Workspace context propagated to asynchronous and runtime work."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
bot_uuid: str | None = None
pipeline_uuid: str | None = None
query_uuid: str | None = None
trigger_principal: PrincipalContext | None = None
entitlement_revision: int = 0
@classmethod
def from_request(
cls,
ctx: RequestContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Create a runtime context without losing the tenant generation."""
return cls(
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
trigger_principal=ctx.principal,
entitlement_revision=ctx.entitlement_revision,
)
__all__ = [
'ExecutionContext',
'PrincipalContext',
'PrincipalType',
'RequestContext',
'WorkspaceContext',
]
+7 -117
View File
@@ -1,119 +1,9 @@
from __future__ import annotations
"""HTTP compatibility exports for transport-neutral API authorization.
import enum
import types
import typing
Authorization primitives live in :mod:`langbot.pkg.api.authz` so application
operations can be used by HTTP, MCP, assistant and CLI adapters without
importing a transport package. Keep this module as a compatibility re-export
for existing HTTP service/controller imports.
"""
from .context import RequestContext
class WorkspaceRole(enum.StrEnum):
OWNER = 'owner'
ADMIN = 'admin'
DEVELOPER = 'developer'
OPERATOR = 'operator'
VIEWER = 'viewer'
class Permission(enum.StrEnum):
WORKSPACE_VIEW = 'workspace.view'
WORKSPACE_UPDATE = 'workspace.update'
WORKSPACE_DELETE = 'workspace.delete'
MEMBER_VIEW = 'member.view'
MEMBER_INVITE = 'member.invite'
MEMBER_UPDATE_ROLE = 'member.update_role'
MEMBER_REMOVE = 'member.remove'
RESOURCE_VIEW = 'resource.view'
RESOURCE_MANAGE = 'resource.manage'
RUNTIME_OPERATE = 'runtime.operate'
PROVIDER_SECRET_MANAGE = 'provider_secret.manage'
API_KEY_MANAGE = 'api_key.manage'
AUDIT_VIEW = 'audit.view'
DATA_EXPORT = 'data.export'
BILLING_LINK_MANAGE = 'billing_link.manage'
_VIEW_PERMISSIONS = {
Permission.WORKSPACE_VIEW,
Permission.MEMBER_VIEW,
Permission.RESOURCE_VIEW,
}
_ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
{
WorkspaceRole.OWNER: frozenset(Permission),
WorkspaceRole.ADMIN: frozenset(
permission
for permission in Permission
if permission
not in {
Permission.WORKSPACE_DELETE,
Permission.BILLING_LINK_MANAGE,
}
),
WorkspaceRole.DEVELOPER: frozenset(
_VIEW_PERMISSIONS
| {
Permission.RESOURCE_MANAGE,
Permission.RUNTIME_OPERATE,
Permission.PROVIDER_SECRET_MANAGE,
}
),
WorkspaceRole.OPERATOR: frozenset(_VIEW_PERMISSIONS | {Permission.RUNTIME_OPERATE}),
WorkspaceRole.VIEWER: frozenset(_VIEW_PERMISSIONS),
}
)
class AuthorizationError(Exception):
"""Base class for errors that map to an HTTP authorization response."""
status_code = 403
error_code = 'forbidden'
class AuthenticationDeniedError(AuthorizationError):
status_code = 401
error_code = 'invalid_authentication'
class WorkspaceRequiredError(AuthorizationError):
status_code = 400
error_code = 'workspace_required'
class PermissionDeniedError(AuthorizationError):
error_code = 'permission_denied'
def __init__(self, permission: str) -> None:
super().__init__(f'Missing Workspace permission: {permission}')
self.permission = permission
class EditionLimitError(AuthorizationError):
error_code = 'edition_limit'
def permissions_for_role(role: str | WorkspaceRole) -> frozenset[str]:
"""Return the canonical fixed permissions for a Workspace role."""
try:
parsed_role = WorkspaceRole(role)
except ValueError:
return frozenset()
return frozenset(permission.value for permission in _ROLE_PERMISSIONS[parsed_role])
def has_permission(ctx: RequestContext, permission: str | Permission) -> bool:
"""Return whether the context contains one effective permission."""
permission_value = permission.value if isinstance(permission, Permission) else permission
return permission_value in ctx.workspace.permissions
def require_permission(ctx: RequestContext, permission: str | Permission) -> None:
"""Raise a stable authorization error when a permission is missing."""
permission_value = permission.value if isinstance(permission, Permission) else permission
if not has_permission(ctx, permission_value):
raise PermissionDeniedError(permission_value)
from ..authz import * # noqa: F401,F403
+15 -95
View File
@@ -1,97 +1,17 @@
from __future__ import annotations
"""Backward-compatible HTTP import surface for transport-neutral contexts."""
import dataclasses
import enum
from ..context import (
ExecutionContext,
PrincipalContext,
PrincipalType,
RequestContext,
WorkspaceContext,
)
class PrincipalType(enum.StrEnum):
"""Kinds of authenticated principals accepted by LangBot."""
ACCOUNT = 'account'
API_KEY = 'api_key'
SUPPORT_ADMIN = 'support_admin'
SYSTEM = 'system'
PUBLIC_BOT = 'public_bot'
@dataclasses.dataclass(frozen=True, slots=True)
class PrincipalContext:
"""Authenticated identity before Workspace authorization is applied."""
principal_type: PrincipalType
account_uuid: str | None = None
actor_account_uuid: str | None = None
api_key_uuid: str | None = None
support_session_id: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceContext:
"""Workspace membership and effective permissions for one request."""
workspace_uuid: str
membership_uuid: str | None
role: str | None
permissions: frozenset[str]
membership_revision: int = 0
@dataclasses.dataclass(frozen=True, slots=True)
class RequestContext:
"""Trusted authorization context passed to HTTP services."""
instance_uuid: str
placement_generation: int
request_id: str
auth_type: str
principal: PrincipalContext
workspace: WorkspaceContext
entitlement_revision: int = 0
@property
def workspace_uuid(self) -> str:
"""Return the selected Workspace UUID."""
return self.workspace.workspace_uuid
@property
def account_uuid(self) -> str | None:
"""Return the Account UUID when the principal is an Account."""
return self.principal.account_uuid
@dataclasses.dataclass(frozen=True, slots=True)
class ExecutionContext:
"""Workspace context propagated to asynchronous and runtime work."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
bot_uuid: str | None = None
pipeline_uuid: str | None = None
query_uuid: str | None = None
trigger_principal: PrincipalContext | None = None
entitlement_revision: int = 0
@classmethod
def from_request(
cls,
ctx: RequestContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Create a runtime context without losing the tenant generation."""
return cls(
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
trigger_principal=ctx.principal,
entitlement_revision=ctx.entitlement_revision,
)
__all__ = [
'ExecutionContext',
'PrincipalContext',
'PrincipalType',
'RequestContext',
'WorkspaceContext',
]
File diff suppressed because it is too large Load Diff
@@ -5,15 +5,12 @@ from .....i18n import t
import asyncio
import base64
import collections.abc
import copy
import quart
import re
import httpx
import uuid
import os
import zipfile
from urllib.parse import urlparse
import posixpath
import sqlalchemy
from .....core import taskmgr
@@ -30,130 +27,13 @@ from .....utils import httpclient
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
_SECRET_MASK = '***'
_MISSING_SECRET = object()
_SENSITIVE_CONFIG_NAMES = frozenset(
{
'api_key',
'apikey',
'auth',
'authorization',
'cookie',
'credentials',
'database_url',
'dsn',
'key',
'proxy_authorization',
'set_cookie',
}
from .....plugin.management import (
count_plugin_components,
parse_github_repo_url,
normalize_plugin_asset_path as _normalize_plugin_asset_path,
redact_plugin_secrets,
restore_plugin_secret_placeholders,
)
_SENSITIVE_CONFIG_TOKENS = frozenset(
{
'credential',
'credentials',
'passwd',
'password',
'secret',
'token',
}
)
_SENSITIVE_KEY_QUALIFIERS = frozenset(
{
'access',
'api',
'auth',
'bearer',
'client',
'debug',
'encryption',
'private',
'signing',
}
)
def _normalize_config_key(key: object) -> str:
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
def _is_sensitive_config_key(key: object) -> bool:
normalized = _normalize_config_key(key)
if normalized in _SENSITIVE_CONFIG_NAMES:
return True
tokens = frozenset(token for token in normalized.split('_') if token)
if tokens & _SENSITIVE_CONFIG_TOKENS:
return True
return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
def _mask_secret_structure(value):
"""Mask every non-empty leaf while preserving container structure."""
if isinstance(value, dict):
return {key: _mask_secret_structure(item) for key, item in value.items()}
if isinstance(value, list):
return [_mask_secret_structure(item) for item in value]
if isinstance(value, tuple):
return tuple(_mask_secret_structure(item) for item in value)
if value is None or value == '':
return value
return _SECRET_MASK
def redact_plugin_secrets(value):
"""Return a recursively redacted copy of plugin-facing data."""
if isinstance(value, dict):
return {
key: (_mask_secret_structure(item) if _is_sensitive_config_key(key) else redact_plugin_secrets(item))
for key, item in value.items()
}
if isinstance(value, list):
return [redact_plugin_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_plugin_secrets(item) for item in value)
return value
def restore_plugin_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
"""Restore masked leaves from the current config before a management write."""
if sensitive and value == _SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked plugin secret has no existing value')
return copy.deepcopy(current_value)
if isinstance(value, dict):
current_mapping = current_value if isinstance(current_value, dict) else {}
return {
key: restore_plugin_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
sensitive=sensitive or _is_sensitive_config_key(key),
)
for key, item in value.items()
}
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_plugin_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_plugin_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
)
return value
# Resolve the built-in page SDK JS from the langbot_plugin package
@@ -178,21 +58,6 @@ def _is_plugin_not_installed(exc: BaseException) -> bool:
return 'is not installed in this workspace' in str(exc).lower()
def _normalize_plugin_asset_path(filepath: str) -> str | None:
filepath = filepath.replace('\\', '/')
if filepath.startswith('/'):
return None
normalized = posixpath.normpath(filepath)
if normalized == '.' or normalized.startswith('../') or normalized == '..':
return None
if normalized.startswith('components/pages/'):
return normalized
return f'assets/{normalized}'
def _get_request_origin() -> str:
"""Return the public request origin, respecting reverse-proxy headers."""
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
@@ -205,96 +70,8 @@ def _get_request_origin() -> str:
@group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup):
@staticmethod
def _normalize_archive_path(path: str) -> str:
normalized = str(path or '').replace('\\', '/').strip('/')
return posixpath.normpath(normalized) if normalized else ''
@classmethod
def _component_source_path(cls, entry) -> str:
if isinstance(entry, dict):
return cls._normalize_archive_path(entry.get('path') or '')
return cls._normalize_archive_path(str(entry or ''))
@classmethod
def _count_component_configs(cls, component_config, archive_names: list[str]) -> int:
normalized_names = [cls._normalize_archive_path(name) for name in archive_names]
component_files: set[str] = set()
if isinstance(component_config, list):
return len(component_config)
if not isinstance(component_config, dict):
return 1 if component_config else 0
for entry in component_config.get('fromFiles') or []:
source_path = cls._component_source_path(entry)
if source_path and source_path in normalized_names:
component_files.add(source_path)
for entry in component_config.get('fromDirs') or []:
source_dir = cls._component_source_path(entry).rstrip('/')
if not source_dir:
continue
prefix = f'{source_dir}/'
for archive_name in normalized_names:
if not archive_name.startswith(prefix):
continue
if archive_name.lower().endswith(('.yaml', '.yml')):
component_files.add(archive_name)
if component_files:
return len(component_files)
return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0
@classmethod
def _count_plugin_components(cls, components, archive_names: list[str]) -> dict[str, int]:
if not isinstance(components, dict):
return {}
component_counts: dict[str, int] = {}
for kind, component_config in components.items():
count = cls._count_component_configs(component_config, archive_names)
if count > 0:
component_counts[str(kind)] = count
return component_counts
@staticmethod
def _parse_github_repo_url(repo_url: str) -> dict | None:
raw_url = str(repo_url or '').strip()
if not raw_url:
return None
if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url):
raw_url = f'https://{raw_url}'
parsed = urlparse(raw_url)
if parsed.netloc.lower() not in ('github.com', 'www.github.com'):
return None
parts = [part for part in parsed.path.strip('/').split('/') if part]
if len(parts) < 2:
return None
owner = parts[0]
repo = parts[1]
if repo.endswith('.git'):
repo = repo[:-4]
if not owner or not repo:
return None
ref = ''
subdir = ''
if len(parts) >= 4 and parts[2] in ('tree', 'blob'):
ref = parts[3]
subdir = '/'.join(parts[4:]).strip('/')
return {
'owner': owner,
'repo': repo,
'ref': ref,
'subdir': subdir,
}
_count_plugin_components = staticmethod(count_plugin_components)
_parse_github_repo_url = staticmethod(parse_github_repo_url)
async def _check_extensions_limit(self, request_context: RequestContext) -> str | None:
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
+2 -335
View File
@@ -1,336 +1,3 @@
from __future__ import annotations
"""HTTP compatibility exports for transport-neutral secret helpers."""
import copy
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
SECRET_MASK = '***'
_MISSING_SECRET = object()
_SENSITIVE_NAMES = frozenset(
{
'api_key',
'api_keys',
'apikey',
'apikeys',
'auth',
'authorization',
'cookie',
'credentials',
'database_url',
'dsn',
'header_value',
'key',
'proxy_authorization',
'set_cookie',
'webhook_url',
}
)
_SENSITIVE_TOKENS = frozenset(
{
'apikey',
'credential',
'credentials',
'passwd',
'password',
'secret',
'token',
}
)
_KEY_QUALIFIERS = frozenset(
{
'access',
'api',
'auth',
'bearer',
'client',
'debug',
'encryption',
'private',
'signing',
}
)
_SENSITIVE_URL_QUERY_NAMES = frozenset(
{
'code',
'credential',
'credentials',
'password',
'passwd',
'sig',
'signature',
}
)
def _normalize_key(key: object) -> str:
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
def is_sensitive_key(key: object) -> bool:
"""Return whether a configuration key conventionally carries a secret."""
normalized = _normalize_key(key)
if normalized in _SENSITIVE_NAMES:
return True
tokens = frozenset(token for token in normalized.split('_') if token)
if tokens & _SENSITIVE_TOKENS:
return True
return bool(tokens & {'key', 'keys'}) and bool(tokens & _KEY_QUALIFIERS)
def is_url_key(key: object) -> bool:
"""Return whether a configuration field conventionally carries a URL."""
normalized = _normalize_key(key)
return normalized == 'url' or normalized.endswith('_url')
def _is_sensitive_url_query_key(key: object) -> bool:
normalized = _normalize_key(key)
return (
is_sensitive_key(key) or normalized in _SENSITIVE_URL_QUERY_NAMES or normalized.endswith(('_sig', '_signature'))
)
def _redact_url_string(value: str) -> str:
if not value:
return value
try:
parsed = urlsplit(value)
netloc = parsed.netloc
if '@' in netloc:
_, host = netloc.rsplit('@', 1)
netloc = f'{SECRET_MASK}@{host}'
query = urlencode(
[
(key, SECRET_MASK if _is_sensitive_url_query_key(key) and item else item)
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
],
doseq=True,
safe='*',
)
return urlunsplit((parsed.scheme, netloc, parsed.path, query, parsed.fragment))
except (TypeError, ValueError):
# A malformed URL cannot be safely decomposed, so fail closed.
return SECRET_MASK
def redact_url_secrets(value):
"""Redact URL userinfo and credential-like query values."""
if isinstance(value, str):
return _redact_url_string(value)
if isinstance(value, list):
return [redact_url_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_url_secrets(item) for item in value)
return copy.deepcopy(value)
def _contains_url_secret_placeholder(value) -> bool:
if isinstance(value, str):
if value == SECRET_MASK:
return True
try:
parsed = urlsplit(value)
if '@' in parsed.netloc and SECRET_MASK in parsed.netloc.rsplit('@', 1)[0]:
return True
return any(
item == SECRET_MASK and _is_sensitive_url_query_key(key)
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
)
except (TypeError, ValueError):
return False
if isinstance(value, (list, tuple)):
return any(_contains_url_secret_placeholder(item) for item in value)
return False
def _restore_url_string(value: str, current_value) -> str:
if value == SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked URL secret has no existing value')
return copy.deepcopy(current_value)
try:
submitted = urlsplit(value)
except (TypeError, ValueError):
return value
current = None
if isinstance(current_value, str):
try:
current = urlsplit(current_value)
except (TypeError, ValueError):
current = None
netloc = submitted.netloc
if '@' in netloc:
submitted_userinfo, host = netloc.rsplit('@', 1)
if SECRET_MASK in submitted_userinfo:
if current is None or '@' not in current.netloc:
raise ValueError('Masked URL userinfo has no existing value')
current_userinfo, _ = current.netloc.rsplit('@', 1)
netloc = f'{current_userinfo}@{host}'
current_query: dict[str, list[str]] = {}
if current is not None:
for key, item in parse_qsl(current.query, keep_blank_values=True):
current_query.setdefault(_normalize_key(key), []).append(item)
consumed: dict[str, int] = {}
restored_query: list[tuple[str, str]] = []
for key, item in parse_qsl(submitted.query, keep_blank_values=True):
normalized = _normalize_key(key)
if item == SECRET_MASK and _is_sensitive_url_query_key(key):
index = consumed.get(normalized, 0)
candidates = current_query.get(normalized, [])
if index >= len(candidates):
raise ValueError('Masked URL query secret has no existing value')
item = candidates[index]
consumed[normalized] = index + 1
restored_query.append((key, item))
return urlunsplit(
(
submitted.scheme,
netloc,
submitted.path,
urlencode(restored_query, doseq=True, safe='*'),
submitted.fragment,
)
)
def restore_url_secret_placeholders(value, current_value=_MISSING_SECRET):
"""Restore URL placeholders from the corresponding persisted URL."""
if isinstance(value, str):
return _restore_url_string(value, current_value)
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_url_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_url_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
)
for index, item in enumerate(value)
)
return copy.deepcopy(value)
def mask_secret_value(value):
"""Return a shape-preserving copy whose non-empty leaves are masked."""
if isinstance(value, dict):
return {key: mask_secret_value(item) for key, item in value.items()}
if isinstance(value, list):
return [mask_secret_value(item) for item in value]
if isinstance(value, tuple):
return tuple(mask_secret_value(item) for item in value)
if value is None or value == '':
return value
return SECRET_MASK
def redact_secrets(value):
"""Return a recursively redacted copy without mutating the source value."""
if isinstance(value, dict):
return {
key: (
mask_secret_value(item)
if is_sensitive_key(key)
else redact_url_secrets(item)
if is_url_key(key)
else redact_secrets(item)
)
for key, item in value.items()
}
if isinstance(value, list):
return [redact_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_secrets(item) for item in value)
return copy.deepcopy(value)
def restore_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
"""Restore masked leaves from existing data before a management write.
``***`` is a reserved placeholder only inside a sensitive field. A masked
leaf without an existing counterpart is rejected so it can never become a
persisted credential. Empty values and explicit replacements pass through.
"""
if sensitive and value == SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked secret has no existing value')
return copy.deepcopy(current_value)
if isinstance(value, dict):
current_mapping = current_value if isinstance(current_value, dict) else {}
return {
key: (
restore_url_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
)
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
else restore_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
sensitive=sensitive or is_sensitive_key(key),
)
)
for key, item in value.items()
}
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
)
return copy.deepcopy(value)
def contains_secret_placeholder(value, *, sensitive: bool = False) -> bool:
"""Return whether ``value`` contains a meaningful masked secret leaf."""
if sensitive and value == SECRET_MASK:
return True
if isinstance(value, dict):
return any(
(
_contains_url_secret_placeholder(item)
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
else contains_secret_placeholder(item, sensitive=sensitive or is_sensitive_key(key))
)
for key, item in value.items()
)
if isinstance(value, (list, tuple)):
return any(contains_secret_placeholder(item, sensitive=sensitive) for item in value)
return False
from ...secrets import * # noqa: F401,F403
+349
View File
@@ -0,0 +1,349 @@
from __future__ import annotations
import copy
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
SECRET_MASK = '***'
_MISSING_SECRET = object()
_SENSITIVE_NAMES = frozenset(
{
'api_key',
'api_keys',
'apikey',
'apikeys',
'auth',
'authorization',
'cookie',
'credentials',
'database_url',
'dsn',
'header_value',
'key',
'proxy_authorization',
'set_cookie',
'webhook_url',
}
)
_SENSITIVE_TOKENS = frozenset(
{
'apikey',
'credential',
'credentials',
'passwd',
'password',
'secret',
'token',
}
)
_KEY_QUALIFIERS = frozenset(
{
'access',
'api',
'auth',
'bearer',
'client',
'debug',
'encryption',
'private',
'signing',
}
)
_SENSITIVE_URL_QUERY_NAMES = frozenset(
{
'code',
'credential',
'credentials',
'password',
'passwd',
'sig',
'signature',
}
)
def _normalize_key(key: object) -> str:
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
def is_sensitive_key(key: object) -> bool:
"""Return whether a configuration key conventionally carries a secret."""
normalized = _normalize_key(key)
if normalized in _SENSITIVE_NAMES:
return True
tokens = frozenset(token for token in normalized.split('_') if token)
if tokens & _SENSITIVE_TOKENS:
return True
return bool(tokens & {'key', 'keys'}) and bool(tokens & _KEY_QUALIFIERS)
def is_url_key(key: object) -> bool:
"""Return whether a configuration field conventionally carries a URL."""
normalized = _normalize_key(key)
return normalized == 'url' or normalized.endswith('_url')
def _is_sensitive_url_query_key(key: object) -> bool:
normalized = _normalize_key(key)
return (
is_sensitive_key(key) or normalized in _SENSITIVE_URL_QUERY_NAMES or normalized.endswith(('_sig', '_signature'))
)
def _redact_url_string(value: str) -> str:
if not value:
return value
try:
parsed = urlsplit(value)
netloc = parsed.netloc
if '@' in netloc:
_, host = netloc.rsplit('@', 1)
netloc = f'{SECRET_MASK}@{host}'
query = urlencode(
[
(key, SECRET_MASK if _is_sensitive_url_query_key(key) and item else item)
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
],
doseq=True,
safe='*',
)
return urlunsplit((parsed.scheme, netloc, parsed.path, query, parsed.fragment))
except (TypeError, ValueError):
# A malformed URL cannot be safely decomposed, so fail closed.
return SECRET_MASK
def redact_url_secrets(value):
"""Redact URL userinfo and credential-like query values."""
if isinstance(value, str):
return _redact_url_string(value)
if isinstance(value, list):
return [redact_url_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_url_secrets(item) for item in value)
return copy.deepcopy(value)
def _contains_url_secret_placeholder(value) -> bool:
if isinstance(value, str):
if value == SECRET_MASK:
return True
try:
parsed = urlsplit(value)
if '@' in parsed.netloc and SECRET_MASK in parsed.netloc.rsplit('@', 1)[0]:
return True
return any(
item == SECRET_MASK and _is_sensitive_url_query_key(key)
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
)
except (TypeError, ValueError):
return False
if isinstance(value, (list, tuple)):
return any(_contains_url_secret_placeholder(item) for item in value)
return False
def _restore_url_string(value: str, current_value) -> str:
if value == SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked URL secret has no existing value')
return copy.deepcopy(current_value)
try:
submitted = urlsplit(value)
except (TypeError, ValueError):
return value
current = None
if isinstance(current_value, str):
try:
current = urlsplit(current_value)
except (TypeError, ValueError):
current = None
netloc = submitted.netloc
if '@' in netloc:
submitted_userinfo, host = netloc.rsplit('@', 1)
if SECRET_MASK in submitted_userinfo:
if current is None or '@' not in current.netloc:
raise ValueError('Masked URL userinfo has no existing value')
current_userinfo, _ = current.netloc.rsplit('@', 1)
netloc = f'{current_userinfo}@{host}'
current_query: dict[str, list[str]] = {}
if current is not None:
for key, item in parse_qsl(current.query, keep_blank_values=True):
current_query.setdefault(_normalize_key(key), []).append(item)
consumed: dict[str, int] = {}
restored_query: list[tuple[str, str]] = []
for key, item in parse_qsl(submitted.query, keep_blank_values=True):
normalized = _normalize_key(key)
if item == SECRET_MASK and _is_sensitive_url_query_key(key):
index = consumed.get(normalized, 0)
candidates = current_query.get(normalized, [])
if index >= len(candidates):
raise ValueError('Masked URL query secret has no existing value')
item = candidates[index]
consumed[normalized] = index + 1
restored_query.append((key, item))
return urlunsplit(
(
submitted.scheme,
netloc,
submitted.path,
urlencode(restored_query, doseq=True, safe='*'),
submitted.fragment,
)
)
def restore_url_secret_placeholders(value, current_value=_MISSING_SECRET):
"""Restore URL placeholders from the corresponding persisted URL."""
if isinstance(value, str):
return _restore_url_string(value, current_value)
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_url_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_url_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
)
for index, item in enumerate(value)
)
return copy.deepcopy(value)
def mask_secret_value(value):
"""Return a shape-preserving copy whose non-empty leaves are masked."""
if isinstance(value, dict):
return {key: mask_secret_value(item) for key, item in value.items()}
if isinstance(value, list):
return [mask_secret_value(item) for item in value]
if isinstance(value, tuple):
return tuple(mask_secret_value(item) for item in value)
if value is None or value == '':
return value
return SECRET_MASK
def redact_secrets(value):
"""Return a recursively redacted copy without mutating the source value."""
if isinstance(value, dict):
return {
key: (
mask_secret_value(item)
if is_sensitive_key(key)
else redact_url_secrets(item)
if is_url_key(key)
else redact_secrets(item)
)
for key, item in value.items()
}
if isinstance(value, list):
return [redact_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_secrets(item) for item in value)
return copy.deepcopy(value)
def restore_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
"""Restore masked leaves from existing data before a management write.
``***`` is a reserved placeholder only inside a sensitive field. A masked
leaf without an existing counterpart is rejected so it can never become a
persisted credential. Empty values and explicit replacements pass through.
"""
if sensitive and value == SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked secret has no existing value')
return copy.deepcopy(current_value)
if isinstance(value, dict):
current_mapping = current_value if isinstance(current_value, dict) else {}
return {
key: (
restore_url_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
)
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
else restore_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
sensitive=sensitive or is_sensitive_key(key),
)
)
for key, item in value.items()
}
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
)
return copy.deepcopy(value)
def contains_secret_placeholder(value, *, sensitive: bool = False) -> bool:
"""Return whether ``value`` contains a meaningful masked secret leaf."""
if sensitive and value == SECRET_MASK:
return True
if isinstance(value, dict):
return any(
(
_contains_url_secret_placeholder(item)
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
else contains_secret_placeholder(item, sensitive=sensitive or is_sensitive_key(key))
)
for key, item in value.items()
)
if isinstance(value, (list, tuple)):
return any(contains_secret_placeholder(item, sensitive=sensitive) for item in value)
return False
__all__ = [
'SECRET_MASK',
'contains_secret_placeholder',
'is_sensitive_key',
'is_url_key',
'mask_secret_value',
'redact_secrets',
'redact_url_secrets',
'restore_secret_placeholders',
'restore_url_secret_placeholders',
]
+3
View File
@@ -61,6 +61,7 @@ from ..cloud import directory_projection as cloud_directory_projection_module
from ..cloud import entitlements as cloud_entitlements_module
from ..cloud import model_catalog as cloud_model_catalog_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ..api.application import ApplicationAPI
if TYPE_CHECKING:
from ..agent.runner import RunnerRegistry, AgentRunOrchestrator, RunnerDefaultConfigService
@@ -183,6 +184,8 @@ class Application:
pipeline_service: pipeline_service.PipelineService = None
application_api: ApplicationAPI = None
agent_service: agent_service.AgentService = None
bot_service: bot_service.BotService = None
+2
View File
@@ -53,6 +53,7 @@ from ...cloud.directory_projection import DirectoryProjectionService
from ...cloud.entitlements import EntitlementResolver
from ...cloud.model_catalog import CloudModelCatalogSyncService
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ...api.application import ApplicationAPI
from ...api.http.authz import WorkspaceRequiredError
@@ -141,6 +142,7 @@ class BuildAppStage(stage.BootingStage):
ap.skill_repository = skill_repository.SkillRepository(ap)
skill_service_inst = skill_service.SkillService(ap)
ap.skill_service = skill_service_inst
ap.application_api = ApplicationAPI(ap)
proxy_mgr = proxy.ProxyManager(ap)
await proxy_mgr.initialize()
+3 -1
View File
@@ -8,11 +8,13 @@ import typing
import datetime
import time
from . import app
from . import entities as core_entities
from .errors import TaskCapacityError
from .task_boundary import create_detached_task
if typing.TYPE_CHECKING:
from . import app
class TaskContext:
"""Task tracking context"""
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
"""Shared plugin management validation and secret handling."""
from __future__ import annotations
import copy
import posixpath
import re
from urllib.parse import urlparse
_SECRET_MASK = '***'
_MISSING_SECRET = object()
_SENSITIVE_CONFIG_NAMES = frozenset(
{
'api_key',
'apikey',
'auth',
'authorization',
'cookie',
'credentials',
'database_url',
'dsn',
'key',
'proxy_authorization',
'set_cookie',
}
)
_SENSITIVE_CONFIG_TOKENS = frozenset(
{
'credential',
'credentials',
'passwd',
'password',
'secret',
'token',
}
)
_SENSITIVE_KEY_QUALIFIERS = frozenset(
{
'access',
'api',
'auth',
'bearer',
'client',
'debug',
'encryption',
'private',
'signing',
}
)
def _normalize_config_key(key: object) -> str:
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
def _is_sensitive_config_key(key: object) -> bool:
normalized = _normalize_config_key(key)
if normalized in _SENSITIVE_CONFIG_NAMES:
return True
tokens = frozenset(token for token in normalized.split('_') if token)
if tokens & _SENSITIVE_CONFIG_TOKENS:
return True
return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
def _mask_secret_structure(value):
"""Mask every non-empty leaf while preserving container structure."""
if isinstance(value, dict):
return {key: _mask_secret_structure(item) for key, item in value.items()}
if isinstance(value, list):
return [_mask_secret_structure(item) for item in value]
if isinstance(value, tuple):
return tuple(_mask_secret_structure(item) for item in value)
if value is None or value == '':
return value
return _SECRET_MASK
def redact_plugin_secrets(value):
"""Return a recursively redacted copy of plugin-facing data."""
if isinstance(value, dict):
return {
key: (_mask_secret_structure(item) if _is_sensitive_config_key(key) else redact_plugin_secrets(item))
for key, item in value.items()
}
if isinstance(value, list):
return [redact_plugin_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_plugin_secrets(item) for item in value)
return value
def restore_plugin_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
"""Restore masked leaves from the current config before a management write."""
if sensitive and value == _SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked plugin secret has no existing value')
return copy.deepcopy(current_value)
if isinstance(value, dict):
current_mapping = current_value if isinstance(current_value, dict) else {}
return {
key: restore_plugin_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
sensitive=sensitive or _is_sensitive_config_key(key),
)
for key, item in value.items()
}
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_plugin_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_plugin_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
)
return value
def normalize_plugin_asset_path(filepath: str) -> str | None:
filepath = filepath.replace('\\', '/')
if filepath.startswith('/'):
return None
normalized = posixpath.normpath(filepath)
if normalized == '.' or normalized.startswith('../') or normalized == '..':
return None
if normalized.startswith('components/pages/'):
return normalized
return f'assets/{normalized}'
def _normalize_archive_path(path: str) -> str:
normalized = str(path or '').replace('\\', '/').strip('/')
return posixpath.normpath(normalized) if normalized else ''
def _component_source_path(entry) -> str:
if isinstance(entry, dict):
return _normalize_archive_path(entry.get('path') or '')
return _normalize_archive_path(str(entry or ''))
def _count_component_configs(component_config, archive_names: list[str]) -> int:
normalized_names = [_normalize_archive_path(name) for name in archive_names]
component_files: set[str] = set()
if isinstance(component_config, list):
return len(component_config)
if not isinstance(component_config, dict):
return 1 if component_config else 0
for entry in component_config.get('fromFiles') or []:
source_path = _component_source_path(entry)
if source_path and source_path in normalized_names:
component_files.add(source_path)
for entry in component_config.get('fromDirs') or []:
source_dir = _component_source_path(entry).rstrip('/')
if not source_dir:
continue
prefix = f'{source_dir}/'
for archive_name in normalized_names:
if not archive_name.startswith(prefix):
continue
if archive_name.lower().endswith(('.yaml', '.yml')):
component_files.add(archive_name)
if component_files:
return len(component_files)
return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0
def count_plugin_components(components, archive_names: list[str]) -> dict[str, int]:
if not isinstance(components, dict):
return {}
component_counts: dict[str, int] = {}
for kind, component_config in components.items():
count = _count_component_configs(component_config, archive_names)
if count > 0:
component_counts[str(kind)] = count
return component_counts
def parse_github_repo_url(repo_url: str) -> dict | None:
raw_url = str(repo_url or '').strip()
if not raw_url:
return None
if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url):
raw_url = f'https://{raw_url}'
parsed = urlparse(raw_url)
if parsed.netloc.lower() not in ('github.com', 'www.github.com'):
return None
parts = [part for part in parsed.path.strip('/').split('/') if part]
if len(parts) < 2:
return None
owner = parts[0]
repo = parts[1]
if repo.endswith('.git'):
repo = repo[:-4]
if not owner or not repo:
return None
ref = ''
subdir = ''
if len(parts) >= 4 and parts[2] in ('tree', 'blob'):
ref = parts[3]
subdir = '/'.join(parts[4:]).strip('/')
return {
'owner': owner,
'repo': repo,
'ref': ref,
'subdir': subdir,
}
@@ -0,0 +1,177 @@
"""Exercise the foundation without HTTP, with independent SQLite readback."""
import dataclasses
import hashlib
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.application import ApplicationAPI, ApplicationAPIException, Input, Operation
from langbot.pkg.api.authz import Permission
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.api.http.service.webhook import WebhookService
from langbot.pkg.entity.persistence.apikey import ApiKey
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.webhook import Webhook
from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceExecutionState
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.workspace.service import WorkspaceService
def context(workspace='workspace-a', permissions=frozenset(Permission), generation=1):
return RequestContext(
instance_uuid='foundation-instance',
placement_generation=generation,
request_id='test-request',
auth_type='api-key',
principal=PrincipalContext(PrincipalType.API_KEY, api_key_uuid='test-key'),
workspace=WorkspaceContext(workspace, None, None, frozenset(permissions)),
)
@pytest.fixture
async def foundation(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "foundation.db"}')
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(
sa.insert(Workspace),
[
{
'uuid': name,
'instance_uuid': 'foundation-instance',
'name': name,
'slug': name,
'source': 'cloud_projection',
}
for name in ('workspace-a', 'workspace-b')
],
)
await connection.execute(
sa.insert(WorkspaceExecutionState),
[
{
'workspace_uuid': name,
'instance_uuid': 'foundation-instance',
'active_generation': 1,
'source': 'cloud',
}
for name in ('workspace-a', 'workspace-b')
],
)
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}), logger=logging.getLogger('foundation'))
ap.persistence_mgr = PersistenceManager(ap)
ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
ap.workspace_service = WorkspaceService(ap, instance_uuid='foundation-instance')
ap.webhook_service = WebhookService(ap)
ap.apikey_service = ApiKeyService(ap)
api = ApplicationAPI(ap)
yield api, ap, engine
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_webhook_lifecycle_secrets_and_workspace_isolation(foundation):
api, _, engine = foundation
created = await api.execute(
'webhook.create', context(), {'name': 'same-name', 'url': 'https://example.test/?token=private'}
)
other = await api.execute(
'webhook.create', context('workspace-b'), {'name': 'same-name', 'url': 'https://other.test/'}
)
assert created['id'] != other['id']
viewer = context(permissions={Permission.RESOURCE_VIEW})
assert (await api.execute('webhook.list', viewer, {}))[0]['url'] == '***'
with pytest.raises(ApplicationAPIException) as denied:
await api.execute('webhook.get', viewer, {'webhook_id': created['id'], 'include_secret': True})
assert denied.value.error.code == 'permission_denied'
with pytest.raises(ApplicationAPIException) as missing:
await api.execute('webhook.get', context('workspace-b'), {'webhook_id': created['id']})
assert missing.value.error.code == 'resource_not_found'
await api.execute('webhook.update', context(), {'webhook_id': created['id'], 'name': 'renamed', 'url': '***'})
async with engine.connect() as connection:
row = (await connection.execute(sa.select(Webhook).where(Webhook.id == created['id']))).mappings().one()
assert row['workspace_uuid'] == 'workspace-a'
assert row['name'] == 'renamed'
assert row['url'] == 'https://example.test/?token=private'
assert await api.execute('webhook.delete', context(), {'webhook_id': created['id']}) is True
assert await api.execute('webhook.list', context(), {}) == []
assert len(await api.execute('webhook.list', context('workspace-b'), {})) == 1
@pytest.mark.asyncio
async def test_key_grants_are_persisted_without_secret_or_escalation(foundation):
api, _, engine = foundation
limited = context(permissions={Permission.API_KEY_MANAGE, Permission.RESOURCE_VIEW})
with pytest.raises(ApplicationAPIException) as error:
await api.execute('api_key.create', limited, {'name': 'escalation', 'scopes': ['resource.manage']})
assert error.value.error.code == 'permission_denied'
created = await api.execute('api_key.create', limited, {'name': 'reader', 'scopes': ['resource.view']})
empty = await api.execute('api_key.create', limited, {'name': 'empty', 'scopes': []})
async with engine.connect() as connection:
rows = (await connection.execute(sa.select(ApiKey))).mappings().all()
assert len(rows) == 2
assert rows[0]['key_hash'] == hashlib.sha256(created['key'].encode()).hexdigest()
assert rows[0]['scopes'] == ['resource.view']
assert rows[1]['scopes'] == []
listed = await api.execute('api_key.list', limited, {})
assert all('key' not in row and 'key_hash' not in row for row in listed)
assert empty['key'] != created['key']
@pytest.mark.asyncio
async def test_untrusted_fields_stale_placement_and_wrong_instance_fail_before_write(foundation):
api, _, engine = foundation
for payload in (
{'name': 'bad', 'url': 'secret://private', 'workspace_uuid': 'workspace-b'},
{'name': 'bad', 'url': 'secret://private', 'enabled': 'false'},
):
with pytest.raises(ApplicationAPIException) as error:
await api.execute('webhook.create', context(), payload)
assert error.value.error.code == 'invalid_input'
assert 'secret://private' not in str(error.value)
with pytest.raises(ApplicationAPIException) as stale:
await api.execute('webhook.create', context(generation=2), {'name': 'stale', 'url': 'https://example.test/'})
assert stale.value.error.code == 'workspace_unavailable'
with pytest.raises(ApplicationAPIException) as wrong_instance:
await api.execute('webhook.list', dataclasses.replace(context(), instance_uuid='other-instance'), {})
assert wrong_instance.value.error.code == 'invalid_authentication'
async with engine.connect() as connection:
assert (await connection.execute(sa.select(sa.func.count()).select_from(Webhook))).scalar_one() == 0
@pytest.mark.asyncio
async def test_every_registered_operation_denies_missing_permission():
api = ApplicationAPI(SimpleNamespace())
for spec in api._operations.values():
principal = PrincipalContext(PrincipalType.ACCOUNT, account_uuid='user')
denied_context = dataclasses.replace(context(permissions=set()), principal=principal)
with pytest.raises(ApplicationAPIException) as error:
if spec.streaming:
await anext(api.stream(spec.operation_id, denied_context, {}))
else:
await api.execute(spec.operation_id, denied_context, {})
assert error.value.error.code == 'permission_denied', spec.operation_id
spec.as_dict()
@pytest.mark.asyncio
async def test_invalid_output_fails_and_cancellation_propagates(foundation):
import asyncio
_, ap, _ = foundation
handler = AsyncMock(return_value='not-an-object')
api = ApplicationAPI(ap, operations=[Operation('example', Permission.RESOURCE_VIEW, handler, Input, dict)])
with pytest.raises(ApplicationAPIException) as error:
await api.execute('example', context(), {})
assert error.value.error.code == 'invalid_output'
handler.side_effect = asyncio.CancelledError()
with pytest.raises(asyncio.CancelledError):
await api.execute('example', context(), {})
@@ -0,0 +1,257 @@
"""Real resource services and SQLite; only external runtime caches are stubbed."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy as sa
from langbot.pkg.api.application import ApplicationAPIException
from langbot.pkg.api.authz import Permission
from langbot.pkg.api.http.service.knowledge import KnowledgeService
from langbot.pkg.api.http.service.model import EmbeddingModelsService, LLMModelsService, RerankModelsService
from langbot.pkg.api.http.service.pipeline import PipelineService
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider, RerankModel
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
from langbot.pkg.entity.persistence.rag import KnowledgeBase
from langbot.pkg.rag.knowledge.kbmgr import RAGManager
from tests.integration.api.test_application_foundation import context, foundation # noqa: F401
@pytest.fixture
async def resources(foundation): # noqa: F811 - pytest resolves the imported fixture by name.
api, ap, engine = foundation
runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester='test-requester'))
ap.model_mgr = SimpleNamespace(
load_provider=AsyncMock(return_value=runtime_provider),
cache_provider=AsyncMock(),
reload_provider=AsyncMock(),
get_provider_by_uuid=AsyncMock(return_value=runtime_provider),
get_available_requester_manifest_by_name=lambda name: SimpleNamespace(
spec={'support_type': ['llm', 'text-embedding', 'rerank']}
),
llm_model_dict={},
)
for family in ('llm', 'embedding', 'rerank'):
setattr(ap.model_mgr, f'load_{family}_model_with_provider', AsyncMock())
setattr(ap.model_mgr, f'cache_{family}_model', AsyncMock())
setattr(ap.model_mgr, f'remove_{family}_model', AsyncMock())
ap.provider_service = ModelProviderService(ap)
ap.llm_model_service = LLMModelsService(ap)
ap.embedding_models_service = EmbeddingModelsService(ap)
ap.rerank_models_service = RerankModelsService(ap)
ap.pipeline_service = PipelineService(ap)
ap.pipeline_mgr = SimpleNamespace(load_pipeline=AsyncMock(), remove_pipeline=AsyncMock())
ap.ver_mgr = SimpleNamespace(get_current_version=lambda: 'test-foundation')
ap.sess_mgr = SimpleNamespace(session_list=[])
ap.plugin_connector = SimpleNamespace(is_enable_plugin=False)
ap.rag_mgr = RAGManager(ap)
ap.knowledge_service = KnowledgeService(ap)
return api, ap, engine
@pytest.mark.asyncio
@pytest.mark.parametrize(
('family', 'table'), [('llm', LLMModel), ('embedding', EmbeddingModel), ('rerank', RerankModel)]
)
async def test_provider_models_sql_ownership_and_secret_round_trip(resources, family, table):
api, _, engine = resources
provider = await api.execute(
'provider.create',
context(),
{
'provider_data': {
'name': 'provider',
'requester': 'test-requester',
'base_url': 'https://example.test/v1',
'api_keys': ['secret-key'],
'workspace_uuid': 'workspace-b',
}
},
)
model = await api.execute(
f'model.{family}.create',
context(),
{
'model_data': {
'name': 'model',
'provider_uuid': provider,
'extra_args': {'api_key': 'model-secret'},
'workspace_uuid': 'workspace-b',
}
},
)
reader = context(permissions={Permission.RESOURCE_VIEW})
visible = await api.execute(f'model.{family}.get', reader, {'model_uuid': model})
assert visible['extra_args']['api_key'] == '***'
assert visible['provider']['api_keys'] == ['***']
with pytest.raises(ApplicationAPIException) as denied:
await api.execute(f'model.{family}.get', reader, {'model_uuid': model, 'include_secret': True})
assert denied.value.error.code == 'permission_denied'
for operation, payload in (
(f'model.{family}.get', {'model_uuid': model}),
(f'model.{family}.update', {'model_uuid': model, 'model_data': {'name': 'stolen'}}),
(f'model.{family}.create', {'model_data': {'name': 'foreign-provider', 'provider_uuid': provider}}),
('provider.update', {'provider_uuid': provider, 'provider_data': {'name': 'stolen'}}),
):
with pytest.raises(ApplicationAPIException) as missing:
await api.execute(operation, context('workspace-b'), payload)
assert missing.value.error.code == 'resource_not_found'
await api.execute(
'provider.update',
context(),
{'provider_uuid': provider, 'provider_data': {'name': 'renamed', 'api_keys': ['***']}},
)
await api.execute(
f'model.{family}.update',
context(),
{'model_uuid': model, 'model_data': {'name': 'renamed-model', 'extra_args': {'api_key': '***'}}},
)
async with engine.connect() as connection:
stored_provider = (
(await connection.execute(sa.select(ModelProvider).where(ModelProvider.uuid == provider))).mappings().one()
)
stored_model = (await connection.execute(sa.select(table).where(table.uuid == model))).mappings().one()
assert stored_provider['workspace_uuid'] == stored_model['workspace_uuid'] == 'workspace-a'
assert stored_provider['api_keys'] == ['secret-key']
assert stored_provider['name'] == 'renamed'
assert stored_model['extra_args']['api_key'] == 'model-secret'
assert stored_model['name'] == 'renamed-model'
assert (await connection.execute(sa.select(sa.func.count()).select_from(table))).scalar_one() == 1
await api.execute(f'model.{family}.delete', context(), {'model_uuid': model})
assert await api.execute(f'model.{family}.list', context(), {}) == []
@pytest.mark.asyncio
async def test_pipeline_copy_update_and_isolation_use_real_persistence(resources):
api, _, engine = resources
config = {'ai': {'api_key': 'pipeline-secret'}}
pipeline = await api.execute(
'pipeline.create', context(), {'pipeline_data': {'name': 'pipeline', 'config': config}}
)
copied = await api.execute('pipeline.copy', context(), {'pipeline_uuid': pipeline})
assert copied != pipeline
await api.execute(
'pipeline.update',
context(),
{
'pipeline_uuid': pipeline,
'pipeline_data': {
'name': 'updated',
'workspace_uuid': 'workspace-b',
'config': {'ai': {'api_key': '***'}},
},
},
)
viewer = context(permissions={Permission.RESOURCE_VIEW})
assert (await api.execute('pipeline.get', viewer, {'pipeline_uuid': pipeline}))['config']['ai']['api_key'] == '***'
with pytest.raises(ApplicationAPIException):
await api.execute('pipeline.copy', context('workspace-b'), {'pipeline_uuid': pipeline})
with pytest.raises(ApplicationAPIException):
await api.execute('pipeline.delete', context('workspace-b'), {'pipeline_uuid': pipeline})
async with engine.connect() as connection:
rows = (await connection.execute(sa.select(LegacyPipeline))).mappings().all()
assert len(rows) == 2
assert {row['workspace_uuid'] for row in rows} == {'workspace-a'}
assert {row['config']['ai']['api_key'] for row in rows} == {'pipeline-secret'}
assert {row['name'] for row in rows} == {'updated', 'pipeline (Copy)'}
await api.execute('pipeline.delete', context(), {'pipeline_uuid': copied})
assert len(await api.execute('pipeline.list', context(), {})) == 1
@pytest.mark.asyncio
async def test_knowledge_draft_crud_and_secret_projection_with_real_rag_manager(resources):
api, _, engine = resources
kb = await api.execute(
'knowledge.base.create',
context(),
{
'kb_data': {
'name': 'draft',
'knowledge_engine_plugin_id': 'test/engine',
'defer_initialization': True,
'creation_settings': {'api_key': 'knowledge-secret'},
'workspace_uuid': 'workspace-b',
}
},
)
visible = await api.execute('knowledge.base.get', context(permissions={Permission.RESOURCE_VIEW}), {'kb_uuid': kb})
assert visible['creation_settings']['api_key'] == '***'
assert visible['initialized'] is False
await api.execute(
'knowledge.base.update',
context(),
{'kb_uuid': kb, 'kb_data': {'name': 'updated', 'workspace_uuid': 'workspace-b'}},
)
for operation, payload in (
('knowledge.base.get', {'kb_uuid': kb}),
('knowledge.base.update', {'kb_uuid': kb, 'kb_data': {'name': 'stolen'}}),
('knowledge.base.delete', {'kb_uuid': kb}),
):
with pytest.raises(ApplicationAPIException) as missing:
await api.execute(operation, context('workspace-b'), payload)
assert missing.value.error.code == 'resource_not_found'
async with engine.connect() as connection:
row = (await connection.execute(sa.select(KnowledgeBase).where(KnowledgeBase.uuid == kb))).mappings().one()
assert row['workspace_uuid'] == 'workspace-a'
assert row['name'] == 'updated'
assert row['creation_settings']['api_key'] == 'knowledge-secret'
await api.execute('knowledge.base.delete', context(), {'kb_uuid': kb})
async with engine.connect() as connection:
assert (await connection.execute(sa.select(sa.func.count()).select_from(KnowledgeBase))).scalar_one() == 0
@pytest.mark.asyncio
async def test_chat_management_uses_scoped_history_and_real_connection_queues(resources, monkeypatch):
from langbot.pkg.api.application import ApplicationAPI
from langbot.pkg.api.application.connection_operations import operations
from langbot.pkg.platform.sources import websocket_manager
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
api, ap, _ = resources
pipeline = await api.execute(
'pipeline.create', context(), {'pipeline_data': {'name': 'chat', 'config': {'ai': {}}}}
)
api = ApplicationAPI(ap, operations=operations())
manager = websocket_manager.WebSocketConnectionManager()
monkeypatch.setattr(websocket_manager, 'ws_connection_manager', manager)
owned = await manager.add_connection(
None, websocket_manager.WebSocketScope.from_context(context()), pipeline, 'person'
)
foreign = await manager.add_connection(
None, websocket_manager.WebSocketScope.from_context(context('workspace-b')), pipeline, 'person'
)
stale = await manager.add_connection(
None, websocket_manager.WebSocketScope('foundation-instance', 'workspace-a', 2), pipeline, 'person'
)
visible = await api.execute('pipeline.chat.connections', context(), {'pipeline_uuid': pipeline})
assert [row['connection_id'] for row in visible['connections']] == [owned.connection_id]
await api.execute('pipeline.chat.broadcast', context(), {'pipeline_uuid': pipeline, 'message': '你好'})
assert owned.send_queue.get_nowait()['message'] == '你好'
assert foreign.send_queue.empty()
assert stale.send_queue.empty()
with pytest.raises(ApplicationAPIException):
await api.execute(
'pipeline.chat.broadcast', context('workspace-b'), {'pipeline_uuid': pipeline, 'message': 'stolen'}
)
assert owned.send_queue.empty()
adapter = WebSocketAdapter.model_construct(
ap=ap,
websocket_person_session=WebSocketSession(id='person'),
websocket_group_session=WebSocketSession(id='group'),
)
message = WebSocketMessage(id=1, role='user', content='history', message_chain=[], timestamp='2026-10-24')
adapter.websocket_person_session.append_message(pipeline, message)
adapter.websocket_group_session.append_message(pipeline, message)
ap.platform_mgr = SimpleNamespace(get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
assert (
await api.execute('pipeline.chat.history', context(), {'pipeline_uuid': pipeline, 'session_type': 'person'})
)[0]['content'] == 'history'
await api.execute('pipeline.chat.reset', context(), {'pipeline_uuid': pipeline, 'session_type': 'person'})
assert (
await api.execute('pipeline.chat.history', context(), {'pipeline_uuid': pipeline, 'session_type': 'person'})
== []
)
assert len(adapter.websocket_group_session.message_lists[pipeline]) == 1
@@ -0,0 +1,176 @@
"""Trace operation outcomes without recording input values or changing failures."""
import asyncio
from contextlib import asynccontextmanager
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.application import ApplicationAPI, ApplicationAPIException, Input, Operation
from langbot.pkg.api.authz import Permission
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from langbot.pkg.operation_trace.service import OPERATION_LEVEL_MUTATION, OPERATION_LEVEL_READ
class Payload(Input):
provider_uuid: str = 'provider-a'
config: dict = {}
def context(allowed=True):
return RequestContext(
'instance-a',
1,
'request-a',
'user-token',
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'),
WorkspaceContext('workspace-a', 'member-a', 'developer', frozenset(Permission) if allowed else frozenset()),
)
@pytest.fixture
def host(monkeypatch):
@asynccontextmanager
async def scope(workspace):
yield
monkeypatch.setattr('langbot.pkg.api.application.api.get_workspace_language', AsyncMock(return_value='en_US'))
return SimpleNamespace(
persistence_mgr=SimpleNamespace(tenant_scope=scope),
workspace_service=SimpleNamespace(
get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid='instance-a'))
),
workspace_settings_service=SimpleNamespace(
effective_level=AsyncMock(return_value=OPERATION_LEVEL_READ), record=AsyncMock()
),
logger=Mock(),
)
def operation_api(host, handler, name='provider.update', *, write=True, streaming=False):
return ApplicationAPI(
host,
operations=[
Operation(name, Permission.RESOURCE_MANAGE, handler, Payload, dict, side_effect=write, streaming=streaming)
],
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
('name', 'write', 'action'),
[
('provider.create', True, 'create'),
('provider.delete', True, 'delete'),
('provider.get', False, 'view'),
],
)
async def test_success_classifies_actions_and_excludes_configuration(host, name, write, action):
await operation_api(host, AsyncMock(return_value={}), name, write=write).execute(
name,
context(),
{
'provider_uuid': 'provider-a',
'config': {'api_key': 'private-secret', 'url': 'private-url'},
},
)
call = host.workspace_settings_service.record.await_args
assert call.args == ('workspace-a',)
assert call.kwargs['rule'].action == action
assert call.kwargs['rule'].bucket == ('write' if write else 'read')
assert call.kwargs['outcome'] == 'ok'
assert call.kwargs['resource_id'] == 'provider-a'
assert 'private' not in repr(call)
host.workspace_settings_service.record.assert_awaited_once()
@pytest.mark.asyncio
async def test_read_capture_honors_mutation_only_level(host):
host.workspace_settings_service.effective_level.return_value = OPERATION_LEVEL_MUTATION
await operation_api(host, AsyncMock(return_value={}), 'provider.get', write=False).execute(
'provider.get', context(), {}
)
host.workspace_settings_service.record.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('error', 'outcome', 'status'),
[(ValueError('invalid secret'), 'error', 400), (RuntimeError('private error'), 'unknown', 500)],
)
async def test_failed_mutation_records_once_without_error_payload(host, error, outcome, status):
with pytest.raises(ApplicationAPIException):
await operation_api(host, AsyncMock(side_effect=error)).execute(
'provider.update', context(), {'config': {'api_key': 'private'}}
)
host.workspace_settings_service.record.assert_awaited_once()
data = host.workspace_settings_service.record.await_args.kwargs
assert (data['outcome'], data['status_code']) == (outcome, status)
assert 'private' not in repr(data) and 'invalid secret' not in repr(data)
@pytest.mark.asyncio
async def test_denied_records_once_but_unknown_workspace_and_malformed_context_do_not(host):
handler = AsyncMock(return_value={})
api = operation_api(host, handler)
with pytest.raises(ApplicationAPIException) as denied:
await api.execute('provider.update', context(False), {})
assert denied.value.error.code == 'permission_denied'
assert host.workspace_settings_service.record.await_args.kwargs['outcome'] == 'denied'
host.workspace_settings_service.record.assert_awaited_once()
handler.assert_not_awaited()
host.workspace_settings_service.record.reset_mock()
for invalid in (None, replace(context(), instance_uuid='foreign')):
with pytest.raises(ApplicationAPIException):
await api.execute('provider.update', invalid, {})
host.workspace_settings_service.record.assert_not_awaited()
@pytest.mark.asyncio
async def test_invalid_result_and_cancellation_record_unknown_and_keep_original_failure(host):
api = operation_api(host, AsyncMock(return_value='invalid'))
with pytest.raises(ApplicationAPIException) as error:
await api.execute('provider.update', context(), {})
assert error.value.error.code == 'invalid_output'
assert host.workspace_settings_service.record.await_args.kwargs['outcome'] == 'unknown'
api = operation_api(host, AsyncMock(side_effect=asyncio.CancelledError()))
with pytest.raises(asyncio.CancelledError):
await api.execute('provider.update', context(), {})
assert host.workspace_settings_service.record.await_args.kwargs['status_code'] == 499
host.workspace_settings_service.record.side_effect = RuntimeError('audit failed')
host.logger.exception.side_effect = RuntimeError('logger failed')
with pytest.raises(ApplicationAPIException) as original:
await operation_api(host, AsyncMock(side_effect=ValueError('original'))).execute(
'provider.update', context(), {}
)
assert original.value.error.message == 'original'
@pytest.mark.asyncio
async def test_late_stream_failure_is_recorded_once(host):
async def handler(ap, ctx, payload):
async def source():
yield {}
raise RuntimeError('private stream error')
return source()
api = operation_api(host, handler, streaming=True)
with pytest.raises(ApplicationAPIException):
async for _ in api.stream('provider.update', context(), {}):
pass
host.workspace_settings_service.record.assert_awaited_once()
assert host.workspace_settings_service.record.await_args.kwargs['outcome'] == 'unknown'
@pytest.mark.asyncio
async def test_domain_audit_exclusion_applies_only_to_success(host):
name = 'workspace.member.update'
await operation_api(host, AsyncMock(return_value={}), name).execute(name, context(), {})
host.workspace_settings_service.record.assert_not_awaited()
with pytest.raises(ApplicationAPIException):
await operation_api(host, AsyncMock(side_effect=ValueError('last owner')), name).execute(name, context(), {})
host.workspace_settings_service.record.assert_awaited_once()
assert host.workspace_settings_service.record.await_args.kwargs['outcome'] == 'error'
@@ -0,0 +1,212 @@
"""Application operations exercised with SQLite, local files and real task ownership."""
from __future__ import annotations
import asyncio
import contextvars
import dataclasses
import logging
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
import sqlalchemy
from langbot.pkg.api.application import ApplicationAPI, ApplicationAPIException
from langbot.pkg.api.application.auxiliary import operations
from langbot.pkg.api.authz import Permission, permissions_for_role
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from langbot.pkg.core.taskmgr import AsyncTaskManager
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import WorkspaceInvitation, WorkspaceMembership
from langbot.pkg.operation_trace.service import WorkspaceSettingsService
from langbot.pkg.storage.mgr import StorageMgr
from langbot.pkg.storage.providers import localstorage
pytest_plugins = ['tests.unit_tests.workspace.test_workspace_collaboration']
@pytest.fixture
async def foundation(collaboration_context, tmp_path, monkeypatch):
collaboration, workspace_service, session_factory, owner, workspace, membership = collaboration_context
ap = collaboration.ap
ap.workspace_service = workspace_service
ap.workspace_collaboration_service = collaboration
ap.instance_config = SimpleNamespace(data={})
ap.logger = logging.getLogger('application-auxiliary-test')
ap.event_loop = asyncio.get_running_loop()
manager = ap.persistence_mgr
current_session = contextvars.ContextVar('auxiliary_session', default=None)
@asynccontextmanager
async def tenant_scope(workspace_uuid):
yield
@asynccontextmanager
async def tenant_uow(workspace_uuid):
if current_session.get() is not None:
yield SimpleNamespace(session=current_session.get(), execute=current_session.get().execute)
return
async with session_factory.begin() as session:
token = current_session.set(session)
try:
yield SimpleNamespace(session=session, execute=session.execute)
finally:
current_session.reset(token)
async def execute_async(statement):
if current_session.get() is not None:
connection = await current_session.get().connection()
return await connection.execute(statement)
async with manager.get_db_engine().begin() as connection:
return await connection.execute(statement)
async def get_user(account_uuid):
async with session_factory() as session:
return await session.get(User, account_uuid)
manager.tenant_scope = tenant_scope
manager.tenant_uow = tenant_uow
manager.execute_async = execute_async
manager.current_session = current_session.get
ap.user_service = SimpleNamespace(get_user_by_uuid=get_user)
ap.workspace_settings_service = WorkspaceSettingsService(ap)
ap.task_mgr = AsyncTaskManager(ap)
ap.storage_mgr = StorageMgr(ap)
monkeypatch.setattr(localstorage, 'LOCAL_STORAGE_PATH', str(tmp_path / 'objects'))
ap.storage_mgr.storage_provider = localstorage.LocalStorageProvider(ap)
context = RequestContext(
instance_uuid=workspace.instance_uuid,
placement_generation=1,
request_id='auxiliary-check',
auth_type='user_token',
principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid=owner.uuid),
workspace=WorkspaceContext(workspace.uuid, membership.uuid, 'owner', permissions_for_role('owner')),
)
api = ApplicationAPI(ap, operations=operations())
yield api, ap, context, session_factory
await ap.workspace_settings_service.flush_pending()
if ap.workspace_settings_service._writer_task is not None:
ap.workspace_settings_service._writer_task.cancel()
await asyncio.gather(ap.workspace_settings_service._writer_task, return_exceptions=True)
for task in ap.task_mgr.tasks:
if not task.task.done():
task.task.cancel()
await asyncio.gather(*(task.task for task in ap.task_mgr.tasks), return_exceptions=True)
@pytest.mark.asyncio
async def test_file_upload_round_trip_and_workspace_isolation(foundation):
api, ap, context, _ = foundation
content = b'\x89PNG\r\n\x1a\n' + '中文图片'.encode()
uploaded = await api.execute('file.image.upload', context, {'filename': 'image.PNG', 'content': content})
returned = await api.execute('file.image.get', context, uploaded)
assert returned == {'content': content, 'media_type': 'image/png'}
key = uploaded['file_key']
assert localstorage._safe_resolve(localstorage.LOCAL_STORAGE_PATH, key)
foreign_key = key.replace(context.workspace_uuid, '00000000-0000-0000-0000-000000000000')
with pytest.raises(ApplicationAPIException):
await api.execute('file.image.get', context, {'file_key': foreign_key})
viewer = dataclasses.replace(
context,
workspace=dataclasses.replace(context.workspace, role='viewer', permissions=permissions_for_role('viewer')),
)
with pytest.raises(ApplicationAPIException) as denied:
await api.execute('file.image.upload', viewer, {'filename': 'image.png', 'content': content})
assert denied.value.error.status_code == 403
for filename in ('../a.png', 'a\\b.png', 'a.svg', 'bad\x00.png'):
with pytest.raises(ApplicationAPIException):
await api.execute('file.image.upload', context, {'filename': filename, 'content': content})
@pytest.mark.asyncio
async def test_member_mutations_preserve_owner_and_invitation_secret(foundation):
api, ap, context, sessions = foundation
invite = await api.execute(
'workspace.invitation.create', context, {'email': '[email protected]', 'role': 'viewer'}
)
assert invite['delivery']['status'] == 'link_only'
assert 'token_hash' not in invite['invitation']
async with sessions.begin() as session:
stored = await session.get(WorkspaceInvitation, invite['invitation']['uuid'])
assert stored.token_hash != invite['token']
new_user = User(
uuid='joined-account',
user='joined',
normalized_email='[email protected]',
password='test',
account_type='local',
)
session.add(new_user)
await ap.workspace_collaboration_service.accept_invitation(invite['token'], new_user.uuid)
changed = await api.execute(
'workspace.member.update', context, {'account_uuid': new_user.uuid, 'role': 'developer'}
)
assert changed['role'] == 'developer'
members = await api.execute('workspace.member.list', context, {})
assert {row['account_uuid'] for row in members} == {context.account_uuid, new_user.uuid}
with pytest.raises(ApplicationAPIException):
await api.execute('workspace.member.remove', context, {'account_uuid': context.account_uuid})
await api.execute('workspace.member.remove', context, {'account_uuid': new_user.uuid})
async with sessions() as session:
row = await session.scalar(
sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.account_uuid == new_user.uuid)
)
assert row.status == 'removed'
key_context = dataclasses.replace(
context, principal=PrincipalContext(PrincipalType.API_KEY, api_key_uuid='key'), auth_type='api_key'
)
with pytest.raises(ApplicationAPIException):
await api.execute('workspace.member.list', key_context, {})
@pytest.mark.asyncio
async def test_language_wizard_and_governance_persist_without_http(foundation):
api, ap, context, _ = foundation
assert (await api.execute('workspace.language.update', context, {'language': 'zh_Hans'}))['language'] == 'zh_Hans'
assert (await api.execute('workspace.language.get', context, {}))['language'] == 'zh_Hans'
await api.execute('system.wizard.progress.update', context, {'step': 2, 'bot_saved': True})
assert (await api.execute('system.wizard.get', context, {}))['progress']['step'] == 2
await api.execute('system.wizard.complete', context, {})
assert await api.execute('system.wizard.get', context, {}) == {'status': 'completed', 'progress': None}
updated = await api.execute('settings.governance.update', context, {'level': 1, 'retention_days': 30})
assert updated['configured_level'] == 1
assert updated['retention_days'] == 30
await ap.workspace_settings_service.flush_pending()
logs = await api.execute('settings.operation_logs.list', context, {})
assert any(row['action'] == 'settings_update' for row in logs['records'])
unauthorized_role = dataclasses.replace(
context,
workspace=dataclasses.replace(
context.workspace, role='viewer', permissions=frozenset({Permission.AUDIT_VIEW.value})
),
)
with pytest.raises(ApplicationAPIException) as denied:
await api.execute('settings.governance.update', unauthorized_role, {'level': 0})
assert denied.value.error.status_code == 403
@pytest.mark.asyncio
async def test_task_selection_excludes_other_workspace_and_hides_api_key_logs(foundation):
api, ap, context, _ = foundation
async def work():
return None
owned = ap.task_mgr.create_user_task(
work(), instance_uuid=context.instance_uuid, workspace_uuid=context.workspace_uuid, placement_generation=1
)
owned.task_context.trace('secret internal diagnostic')
other = ap.task_mgr.create_user_task(
work(), instance_uuid=context.instance_uuid, workspace_uuid='other-workspace', placement_generation=1
)
await asyncio.gather(owned.task, other.task)
result = await api.execute('task.list', context, {})
assert [task['id'] for task in result['tasks']] == [owned.id]
key_context = dataclasses.replace(
context, principal=PrincipalContext(PrincipalType.API_KEY, api_key_uuid='key'), auth_type='api_key'
)
public = await api.execute('task.get', key_context, {'task_id': owned.id})
assert 'secret internal diagnostic' not in str(public)
with pytest.raises(ApplicationAPIException):
await api.execute('task.get', context, {'task_id': other.id})
@@ -0,0 +1,9 @@
from langbot.pkg.api.context import ExecutionContext, RequestContext
from langbot.pkg.api.http.context import ExecutionContext as HTTPExecutionContext
from langbot.pkg.api.http.context import RequestContext as HTTPRequestContext
def test_application_context_types_are_http_independent_and_compatibly_reexported():
assert RequestContext is HTTPRequestContext
assert ExecutionContext is HTTPExecutionContext
assert RequestContext.__module__ == 'langbot.pkg.api.context'
@@ -0,0 +1,69 @@
"""Application errors preserve business meaning and hide internal failure data."""
import pytest
from langbot_plugin.box.errors import BoxAdmissionError
from pydantic import BaseModel, ValidationError
from langbot.pkg.agent.runner.errors import (
RunnerExecutionError,
RunnerNotAuthorizedError,
RunnerNotFoundError,
RunnerProtocolError,
)
from langbot.pkg.api.application.errors import error_from_exception
from langbot.pkg.api.http.service.bot_errors import BotApplyError
from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
from langbot.pkg.core.errors import TaskCapacityError
from langbot.pkg.provider.modelmgr.codex_errors import CodexProviderError
from langbot.pkg.utils.bounded_executor import BlockingWorkCapacityError
from langbot.pkg.workspace.collaboration import LastOwnerError, MembershipPermissionError
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
@pytest.mark.parametrize(
('exc', 'code', 'status'),
[
(RunnerExecutionError('runner', 'failed', error_code='tool_failed'), 'tool_failed', 422),
(RunnerNotAuthorizedError('runner', []), 'runner_not_authorized', 403),
(RunnerNotFoundError('runner'), 'runner_not_found', 409),
(RunnerProtocolError('runner', 'wrong protocol'), 'runner_protocol_error', 502),
(MembershipPermissionError('denied'), 'permission_denied', 403),
(LastOwnerError('last owner'), 'last_owner_required', 400),
(BoxAdmissionError('no grant'), 'managed_sandbox_unavailable', 403),
(EntitlementUnavailableError('expired'), 'entitlement_unavailable', 403),
(TaskCapacityError('full'), 'task_capacity_exceeded', 429),
(BlockingWorkCapacityError('full'), 'blocking_work_capacity_exceeded', 429),
(CodexProviderError('known safe error', 429, 'rate_limited'), 'rate_limited', 429),
(FileNotFoundError('/private/file'), 'resource_not_found', 404),
(WorkspaceGenerationMismatchError('stale placement'), 'workspace_unavailable', 409),
],
)
def test_business_error_mapping(exc, code, status):
result = error_from_exception(exc)
assert (result.code, result.status_code) == (code, status)
def test_box_entitlement_and_saved_bot_identity_are_preserved():
result = error_from_exception(EntitlementUnavailableError('expired'), operation_id='box.status')
assert (result.code, result.status_code) == ('managed_sandbox_unavailable', 403)
result = error_from_exception(BotApplyError('Saved configuration could not be applied', bot_uuid='bot-a'))
assert (result.code, result.status_code, result.details) == ('bot_apply_failed', 400, {'uuid': 'bot-a'})
def test_unknown_and_pydantic_errors_do_not_expose_raw_configuration():
class CredentialInput(BaseModel):
token: int
with pytest.raises(ValidationError) as failure:
CredentialInput(token='sensitive-value')
assert 'sensitive-value' not in error_from_exception(failure.value).message
assert error_from_exception(RuntimeError('raw sensitive backend error')).message == 'Application operation failed'
def test_typed_assistant_and_migration_errors_keep_original_status():
class ServiceError(ValueError):
code = 'stale_turn'
status = 409
result = error_from_exception(ServiceError('stale_turn'))
assert (result.code, result.status_code) == ('stale_turn', 409)
@@ -0,0 +1,182 @@
from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import inspect
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import lark_oapi
import pytest
from pydantic import TypeAdapter
from langbot.pkg.api.application.api import ApplicationAPI
from langbot.pkg.api.application.contracts import ApplicationAPIException
from langbot.pkg.api.application.platform_setup import operations
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from langbot.pkg.core.errors import TaskCapacityError
from langbot.pkg.platform.credential_setup import CredentialSetupError, _bind_session_scope, get_credential_setup
def context(**changes):
return RequestContext(
instance_uuid='instance-a',
workspace=WorkspaceContext('workspace-a', 'membership-a', 'developer', frozenset({'resource.manage'})),
placement_generation=1,
request_id='request-a',
auth_type='user-token',
principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'),
**changes,
)
@pytest.fixture
def ap(monkeypatch):
@contextlib.asynccontextmanager
async def tenant_scope(workspace):
yield
def create_user_task(coro, **kwargs):
return SimpleNamespace(task=asyncio.create_task(coro))
application = SimpleNamespace(
task_mgr=SimpleNamespace(create_user_task=Mock(side_effect=create_user_task)),
workspace_service=SimpleNamespace(
get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid='instance-a'))
),
persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope),
)
monkeypatch.setattr('langbot.pkg.api.application.api.get_workspace_language', AsyncMock(return_value='en_US'))
monkeypatch.setattr(ApplicationAPI, '_record', AsyncMock())
return application
@pytest.mark.asyncio
async def test_catalog_covers_all_credential_flows_and_checks_permission_before_start(ap):
api = ApplicationAPI(ap, operations=operations())
assert len(api.operation_catalog()) == 18
assert {op['permission'] for op in api.operation_catalog()} == {'resource.manage'}
viewer = dataclasses.replace(
context(), workspace=WorkspaceContext('workspace-a', 'membership-a', 'viewer', frozenset())
)
with pytest.raises(ApplicationAPIException) as exc:
await api.execute('platform.setup.lark.start', viewer, {})
assert exc.value.error.code == 'permission_denied'
ap.task_mgr.create_user_task.assert_not_called()
assert not hasattr(ap, 'credential_setup_service')
@pytest.mark.asyncio
async def test_setup_service_is_shared_with_transport_and_does_not_need_request_globals(ap, monkeypatch):
release = asyncio.Event()
completed = asyncio.Event()
async def register(*, on_qr_code, source):
on_qr_code({'url': 'https://example.test/qr'})
await release.wait()
completed.set()
return {'client_id': 'app-a', 'client_secret': 'secret-a'}
monkeypatch.setattr(lark_oapi, 'aregister_app', register)
api = ApplicationAPI(ap, operations=operations())
ctx = context()
started = await api.execute('platform.setup.lark.start', ctx, {})
service = get_credential_setup(ap)
assert get_credential_setup(ap) is service
assert await service.lark_status(started['session_id'], ctx) == {'status': 'waiting'}
release.set()
await completed.wait()
result = await api.execute('platform.setup.lark.status', ctx, {'session_id': started['session_id']})
assert result == {'status': 'success', 'app_id': 'app-a', 'app_secret': 'secret-a'}
with pytest.raises(CredentialSetupError) as exc:
await service.lark_status(started['session_id'], ctx)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_task_admission_error_is_preserved_as_domain_status(ap):
ap.task_mgr.create_user_task.side_effect = TaskCapacityError('capacity exhausted')
with pytest.raises(CredentialSetupError) as exc:
await get_credential_setup(ap).lark_start(context())
assert exc.value.status_code == 429
assert 'Too many active' in str(exc.value)
_CASES = [
('lark', '_create_app_sessions', {'app_id': 'id', 'app_secret': 'secret'}),
(
'weixin',
'_weixin_login_sessions',
{
'qr_data_url': 'data:qr',
'expire_at': 1.0,
'token': 'token',
'base_url': 'https://example.test',
'account_id': 'id',
},
),
('dingtalk', '_dingtalk_sessions', {'client_id': 'id', 'client_secret': 'secret'}),
('wecombot', '_wecombot_sessions', {'botid': 'id', 'secret': 'secret'}),
('qqofficial', '_qqofficial_sessions', {'appid': 'id', 'secret': 'secret', 'user_openid': 'user'}),
]
@pytest.mark.asyncio
@pytest.mark.parametrize('platform,store_name,credentials', _CASES)
async def test_each_platform_preserves_exact_owner_scoping_and_credentials(ap, platform, store_name, credentials):
service = get_credential_setup(ap)
status = getattr(service, f'{platform}_status')
cancel = getattr(service, f'{platform}_cancel')
store = inspect.getclosurevars(status).nonlocals[store_name]
session = {'status': 'success', 'created_at': time.time(), **credentials}
owner = context()
_bind_session_scope(session, owner)
store['session-a'] = session
foreign = dataclasses.replace(owner, principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-b'))
for ctx in (
foreign,
dataclasses.replace(owner, placement_generation=2),
dataclasses.replace(owner, workspace=dataclasses.replace(owner.workspace, workspace_uuid='workspace-b')),
):
with pytest.raises(CredentialSetupError) as exc:
await status('session-a', ctx)
assert exc.value.status_code == 404
with pytest.raises(CredentialSetupError):
await cancel('session-a', ctx)
assert store['session-a'] is session
result = await status('session-a', owner)
spec = next(op for op in operations() if op.operation_id == f'platform.setup.{platform}.status')
assert TypeAdapter(spec.output_type).validate_python(result, strict=True) == result
assert result == {'status': 'success', **credentials}
assert 'session-a' not in store
@pytest.mark.asyncio
@pytest.mark.parametrize('platform,store_name,credentials', _CASES)
async def test_each_platform_cancel_stops_its_owned_task(ap, platform, store_name, credentials):
service = get_credential_setup(ap)
status = getattr(service, f'{platform}_status')
store = inspect.getclosurevars(status).nonlocals[store_name]
task = SimpleNamespace(done=Mock(return_value=False), cancel=Mock())
session = {'status': 'waiting', 'created_at': time.time(), 'task': task, **credentials}
_bind_session_scope(session, context())
store['session-a'] = session
assert await getattr(service, f'{platform}_cancel')('session-a', context()) == {}
task.cancel.assert_called_once_with()
assert not store
@pytest.mark.asyncio
async def test_slack_cancel_checks_ownership_before_discard(ap):
from langbot.pkg.api.http.service.slack_setup import SlackSetupError
ap.slack_setup_service = SimpleNamespace(
owned=Mock(side_effect=SlackSetupError('session_not_found')), discard=Mock()
)
api = ApplicationAPI(ap, operations=operations())
with pytest.raises(ApplicationAPIException) as exc:
await api.execute('platform.setup.slack.cancel', context(), {'session_id': 'foreign-session'})
assert exc.value.error.status_code == 404
ap.slack_setup_service.discard.assert_not_called()
@@ -0,0 +1,167 @@
"""Plugin foundation contracts and Workspace isolation without a transport."""
from contextlib import asynccontextmanager
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from zipfile import ZipFile
import pytest
from pydantic import TypeAdapter, ValidationError
from langbot.pkg.api.application import plugin_operations as ops
from langbot.pkg.api.context import ExecutionContext
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError, WorkspaceNotFoundError
CONTEXT = SimpleNamespace(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=3)
def app():
execution = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=3)
return SimpleNamespace(
plugin_connector=SimpleNamespace(require_workspace_context=AsyncMock(return_value=execution)),
instance_config=SimpleNamespace(data={'system': {'limitation': {}}}),
)
@pytest.mark.asyncio
async def test_plugin_reads_mask_secrets_and_config_write_restores_persisted_values():
ap = app()
raw = {'plugin_config': {'apiKey': 'runtime-secret'}, 'debug': {'plugin_debug_key': 'debug-secret'}}
ap.plugin_connector.get_plugin_info = AsyncMock(return_value=raw)
ap.plugin_connector.set_plugin_config = AsyncMock()
ap.persistence_mgr = SimpleNamespace(
execute_async=AsyncMock(
return_value=SimpleNamespace(
scalar_one_or_none=lambda: {
'apiKey': 'stored-secret',
'nested': {'token': 'nested-secret'},
'enabled': False,
}
)
)
)
key = ops.PluginInput(author='a', plugin_name='b')
assert (await ops.get_plugin(ap, CONTEXT, key))['plugin']['plugin_config']['apiKey'] == '***'
assert raw['plugin_config']['apiKey'] == 'runtime-secret'
assert (await ops.get_config(ap, CONTEXT, key))['config']['nested']['token'] == '***'
await ops.update_config(
ap,
CONTEXT,
ops.ConfigInput(
author='a', plugin_name='b', config={'apiKey': '***', 'nested': {'token': '***'}, 'enabled': True}
),
)
assert ap.plugin_connector.set_plugin_config.await_args.args == (
'a',
'b',
{'apiKey': 'stored-secret', 'nested': {'token': 'nested-secret'}, 'enabled': True},
)
with pytest.raises(ValueError, match='no existing value'):
await ops.update_config(ap, CONTEXT, ops.ConfigInput(author='a', plugin_name='b', config={'password': '***'}))
@pytest.mark.asyncio
async def test_queued_plugin_task_revalidates_generation_before_runtime_io():
ap = app()
ap.plugin_connector.delete_plugin = AsyncMock()
queued = []
scopes = []
@asynccontextmanager
async def tenant_scope(workspace):
scopes.append(workspace)
yield
def create_task(coro, **metadata):
queued.append((coro, metadata))
return SimpleNamespace(id=17)
ap.persistence_mgr = SimpleNamespace(tenant_scope=tenant_scope)
ap.task_mgr = SimpleNamespace(create_user_task=create_task)
assert await ops.delete(ap, CONTEXT, ops.DeleteInput(author='a', plugin_name='b')) == {'task_id': 17}
assert TypeAdapter(ops.TaskResult).validate_python({'task_id': 17}, strict=True) == {'task_id': 17}
assert queued[0][1]['workspace_uuid'] == 'workspace-a'
assert queued[0][1]['placement_generation'] == 3
ap.plugin_connector.require_workspace_context.side_effect = WorkspaceGenerationMismatchError('stale placement')
with pytest.raises(WorkspaceGenerationMismatchError):
await queued[0][0]
assert scopes == ['workspace-a']
ap.plugin_connector.delete_plugin.assert_not_awaited()
@pytest.mark.asyncio
async def test_install_quota_counts_plugins_and_mcp_before_queueing():
ap = app()
ap.instance_config.data['system']['limitation']['max_extensions'] = 2
ap.plugin_connector.list_plugins = AsyncMock(return_value=[{}])
ap.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[{}]))
ap.task_mgr = SimpleNamespace(create_user_task=Mock())
with pytest.raises(ValueError, match='Maximum number'):
await ops.install_marketplace(ap, CONTEXT, ops.MarketplaceInput(plugin_author='a', plugin_name='b'))
ap.task_mgr.create_user_task.assert_not_called()
@pytest.mark.asyncio
async def test_asset_path_rejects_escape_before_runtime_read_and_returns_exact_bytes():
ap = app()
ap.plugin_connector.get_plugin_assets = AsyncMock(return_value={'asset_base64': 'AP8K', 'mime_type': 'image/png'})
for path in ('../secret', '/etc/passwd', '..\\secret'):
with pytest.raises(WorkspaceNotFoundError):
await ops.asset(ap, CONTEXT, ops.AssetInput(author='a', plugin_name='b', filepath=path))
ap.plugin_connector.get_plugin_assets.assert_not_awaited()
result = await ops.asset(ap, CONTEXT, ops.AssetInput(author='a', plugin_name='b', filepath='image.png'))
assert result == {'content': b'\x00\xff\n', 'mime_type': 'image/png'}
assert ap.plugin_connector.get_plugin_assets.await_args.args == ('a', 'b', 'assets/image.png')
@pytest.mark.asyncio
async def test_config_files_are_workspace_scoped_and_size_checked():
ap = app()
ap.storage_mgr = SimpleNamespace(
save_scoped=AsyncMock(return_value='scoped-key'),
is_scoped_object_key=Mock(return_value=False),
delete_scoped_object_key=AsyncMock(),
)
assert await ops.upload_config_file(ap, CONTEXT, ops.FileInput(content=b'payload', filename='data.txt')) == {
'file_key': 'scoped-key'
}
args = ap.storage_mgr.save_scoped.await_args
assert args.args == (CONTEXT,)
assert args.kwargs['owner'] == 'workspace-a'
assert args.kwargs['owner_type'] == 'plugin_config'
assert args.kwargs['value'] == b'payload'
with pytest.raises(ValueError, match='10MB'):
await ops.upload_config_file(ap, CONTEXT, ops.FileInput(content=b'x' * (10 * 1024 * 1024 + 1)))
with pytest.raises(ValueError, match='Invalid file key'):
await ops.delete_config_file(ap, CONTEXT, ops.FileKeyInput(file_key='foreign/raw'))
ap.storage_mgr.delete_scoped_object_key.assert_not_awaited()
@pytest.mark.asyncio
async def test_archive_preview_uses_archive_admission_and_component_metadata():
ap = app()
archive = BytesIO()
with ZipFile(archive, 'w') as output:
output.writestr(
'manifest.yaml',
'metadata:\n author: example\n name: sample\nspec:\n components:\n tools:\n fromDirs: [components/tools]\n',
)
output.writestr('components/tools/tool.yaml', 'name: test')
output.writestr('requirements.txt', 'httpx\n')
result = await ops.preview_local(ap, CONTEXT, ops.FileInput(content=archive.getvalue(), filename='sample.lbpkg'))
assert result['metadata'] == {'author': 'example', 'name': 'sample'}
assert result['component_counts'] == {'tools': 1}
assert result['requirements'] == ['httpx']
with pytest.raises(ValueError):
await ops.preview_local(ap, CONTEXT, ops.FileInput(content=b'invalid'))
def test_install_inputs_do_not_coerce_force_flags_or_github_path_segments():
with pytest.raises(ValidationError):
ops.LocalInstallInput(content=b'zip', administrator_force='true')
with pytest.raises(ValidationError):
ops.GithubAssetsInput(owner='a/b', repo='repo', release_id=1)
with pytest.raises(ValidationError):
ops.DeleteInput(author='a', plugin_name='b', delete_data='false')
@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
from contextlib import aclosing, asynccontextmanager
from contextvars import ContextVar
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.application.api import ApplicationAPI
from langbot.pkg.api.application.contracts import ApplicationAPIException, Input, Operation
from langbot.pkg.api.authz import Permission
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from langbot.pkg.i18n import language
from langbot.pkg.utils.bounded_executor import current_blocking_work_scope
class Empty(Input):
pass
def context(workspace):
return RequestContext(
'instance-a',
1,
'request-a',
'user-token',
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'),
WorkspaceContext(workspace, 'membership-a', 'developer', frozenset({'resource.view'})),
)
@pytest.fixture
def host(monkeypatch):
tenant = ContextVar('test_stream_tenant', default=None)
@asynccontextmanager
async def tenant_scope(workspace):
token = tenant.set(workspace)
try:
yield
finally:
tenant.reset(token)
ap = SimpleNamespace(
persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope),
tenant=tenant,
workspace_service=SimpleNamespace(
get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid='instance-a'))
),
logger=Mock(),
)
monkeypatch.setattr(
'langbot.pkg.api.application.api.get_workspace_language',
AsyncMock(side_effect=lambda ap, ws: 'zh_Hans' if ws == 'workspace-a' else 'ja_JP'),
)
return ap
def api(host, handler):
return ApplicationAPI(
host,
operations=[Operation('test.stream', Permission.RESOURCE_VIEW, handler, Empty, dict[str, str], streaming=True)],
)
def assert_consumer_unscoped(host):
assert host.tenant.get() is None
assert current_blocking_work_scope() is None
assert language.get() == 'en_US'
@pytest.mark.asyncio
async def test_interleaved_streams_isolate_tenant_language_and_executor_scope(host):
closed = set()
async def handler(ap, ctx, payload):
async def source():
try:
for _ in range(2):
assert host.tenant.get() == ctx.workspace_uuid
assert current_blocking_work_scope() == ctx.workspace_uuid
yield {'workspace': ctx.workspace_uuid, 'language': language.get()}
finally:
assert host.tenant.get() == ctx.workspace_uuid
closed.add(ctx.workspace_uuid)
return source()
application = api(host, handler)
first = application.stream('test.stream', context('workspace-a'), {})
second = application.stream('test.stream', context('workspace-b'), {})
async with aclosing(first), aclosing(second):
for _ in range(2):
assert await anext(first) == {'workspace': 'workspace-a', 'language': 'zh_Hans'}
assert_consumer_unscoped(host)
assert await anext(second) == {'workspace': 'workspace-b', 'language': 'ja_JP'}
assert_consumer_unscoped(host)
assert closed == {'workspace-a', 'workspace-b'}
@pytest.mark.asyncio
async def test_stream_can_be_closed_from_another_task_without_resetting_foreign_context(host):
closed = asyncio.Event()
async def handler(ap, ctx, payload):
async def source():
try:
yield {'item': 'first'}
await asyncio.Event().wait()
finally:
assert host.tenant.get() == ctx.workspace_uuid
closed.set()
return source()
stream = api(host, handler).stream('test.stream', context('workspace-a'), {})
assert await anext(stream) == {'item': 'first'}
assert_consumer_unscoped(host)
await asyncio.create_task(stream.aclose())
assert closed.is_set()
assert_consumer_unscoped(host)
@pytest.mark.asyncio
async def test_consumer_cancel_closes_source_and_restores_scopes(host):
started, closed = asyncio.Event(), asyncio.Event()
async def handler(ap, ctx, payload):
async def source():
try:
started.set()
await asyncio.Event().wait()
yield {}
finally:
closed.set()
return source()
stream = api(host, handler).stream('test.stream', context('workspace-a'), {})
consumer = asyncio.create_task(anext(stream))
await asyncio.wait_for(started.wait(), 2)
consumer.cancel()
with pytest.raises(asyncio.CancelledError):
await consumer
assert closed.is_set()
assert_consumer_unscoped(host)
@pytest.mark.asyncio
async def test_late_stream_failure_is_mapped_without_private_provider_text(host):
closed = asyncio.Event()
async def handler(ap, ctx, payload):
async def source():
try:
yield {'item': 'first'}
raise RuntimeError('private provider credential')
finally:
closed.set()
return source()
stream = api(host, handler).stream('test.stream', context('workspace-a'), {})
async with aclosing(stream):
assert await anext(stream) == {'item': 'first'}
with pytest.raises(ApplicationAPIException) as exc:
await anext(stream)
assert exc.value.error.code == 'application_error'
assert 'private' not in exc.value.error.message
assert closed.is_set()
assert_consumer_unscoped(host)
@@ -0,0 +1,158 @@
"""Streaming lifetimes: debug cancellation versus durable Assistant execution."""
from __future__ import annotations
import asyncio
import contextvars
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
from langbot.pkg.api.application.service_operations import TurnInput, _assistant_service
from langbot.pkg.api.application.workspace_streams import AgentDebugInput, agent_debug, assistant_turn
from langbot.pkg.api.http.service.assistant import AssistantError
from tests.unit_tests.api.test_management_assistant import context
pytest_plugins = ['tests.unit_tests.api.test_management_assistant']
def scoped_app(debug):
active = contextvars.ContextVar('debug_stream_scope', default=None)
@asynccontextmanager
async def tenant_scope(workspace_uuid):
token = active.set((workspace_uuid, asyncio.current_task()))
try:
yield
finally:
active.reset(token)
return SimpleNamespace(
persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope),
agent_service=SimpleNamespace(debug_agent=debug),
), active
@pytest.mark.asyncio
async def test_debug_stream_delivers_callbacks_before_completion_and_propagates_failure():
release = asyncio.Event()
async def debug(ctx, agent_uuid, payload, on_result):
assert active.get() == (ctx.workspace_uuid, asyncio.current_task())
await on_result({'text': payload['text']})
await release.wait()
return {'agent_uuid': agent_uuid, 'status': 'completed'}
ap, active = scoped_app(debug)
stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={'text': 'partial'}))
assert await anext(stream) == {'kind': 'result', 'data': {'text': 'partial'}}
assert active.get() is None
release.set()
assert await anext(stream) == {'kind': 'completed', 'data': {'agent_uuid': 'agent', 'status': 'completed'}}
await stream.aclose()
async def failed(*args, **kwargs):
raise ValueError('invalid debug input')
ap.agent_service.debug_agent = failed
failed_stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={}))
with pytest.raises(ValueError, match='invalid debug input'):
await anext(failed_stream)
@pytest.mark.asyncio
async def test_debug_stream_applies_backpressure_and_close_cancels_producer():
completed_callbacks = 0
saturated = asyncio.Event()
cancelled = asyncio.Event()
async def debug(ctx, agent_uuid, payload, on_result):
nonlocal completed_callbacks
try:
for index in range(100):
if index == 33:
saturated.set()
await on_result({'index': index})
completed_callbacks += 1
finally:
cancelled.set()
ap, _ = scoped_app(debug)
stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={}))
assert (await anext(stream))['data']['index'] == 0
await asyncio.wait_for(saturated.wait(), 1)
assert completed_callbacks == 33
assert not cancelled.is_set()
await stream.aclose()
assert cancelled.is_set()
@pytest.mark.asyncio
async def test_assistant_stream_disconnect_preserves_run_and_bounded_queue(assistant):
_, ap, provider = assistant
service = _assistant_service(ap)
release = asyncio.Event()
async def model_stream(**kwargs):
yield MessageChunk(role='assistant', content='first')
await release.wait()
for _ in range(100):
yield MessageChunk(role='assistant', content='x')
yield MessageChunk(role='assistant', content=' done', is_final=True)
provider.invoke_llm_stream = model_stream
ctx = context()
conversation_id = (await service.create(ctx))['uuid']
stream = await assistant_turn(ap, ctx, TurnInput(conversation_id=conversation_id, revision=0, text='Hi'))
try:
while (await asyncio.wait_for(anext(stream), 2))['kind'] != 'text':
pass
task = service._runs[conversation_id]['task']
queue = service._runs[conversation_id]['queue']
assert queue.maxsize == 32
await stream.aclose()
assert not task.done()
assert _assistant_service(ap) is service
assert (await service.progress(ctx, conversation_id))['progress']['text'] == 'first'
release.set()
await asyncio.wait_for(task, 3)
completed = await service.get(ctx, conversation_id)
assert completed['status'] == 'ready'
assert completed['messages'][-1]['content'] == 'first' + 'x' * 100 + ' done'
assert queue.qsize() == 32
frames = [queue.get_nowait() for _ in range(queue.qsize())]
assert frames[-1]['kind'] == 'completed'
with pytest.raises(AssistantError, match='conversation_not_found'):
await assistant_turn(
ap, context(account='other'), TurnInput(conversation_id=conversation_id, revision=1, text='steal')
)
with pytest.raises(ValueError, match='Provide text or an approval decision'):
await assistant_turn(
ap, ctx, TurnInput(conversation_id=conversation_id, revision=1, text='both', approved=True)
)
finally:
release.set()
await stream.aclose()
for run in list(service._runs.values()):
await asyncio.wait_for(run['task'], 3)
@pytest.mark.asyncio
async def test_debug_provider_cancellation_wakes_subscriber_without_timeout():
finished = asyncio.Event()
async def cancelled(*args, **kwargs):
try:
raise asyncio.CancelledError()
finally:
finished.set()
ap, active = scoped_app(cancelled)
stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={}))
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(anext(stream), 0.5)
assert finished.is_set()
assert active.get() is None
await stream.aclose()
@@ -0,0 +1,524 @@
from __future__ import annotations
import asyncio
import contextlib
import contextvars
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
from langbot_plugin.entities.io.resp import ActionResponse
from langbot.pkg.api.application.contracts import ApplicationAPIException
from langbot.pkg.api.application.execution import ExecutionAPI, ExecutionAPIContext
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot.pkg.agent.runner.session_registry import get_session_registry
@pytest.fixture
def host():
binding = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
tenant = contextvars.ContextVar('execution_test_tenant', default=None)
class Persistence:
execute_async = AsyncMock()
@contextlib.asynccontextmanager
async def tenant_scope(self, workspace):
token = tenant.set(workspace)
try:
yield
finally:
tenant.reset(token)
ap = SimpleNamespace(
deployment=SimpleNamespace(mode='cloud'),
persistence_mgr=Persistence(),
logger=Mock(),
workspace_service=SimpleNamespace(get_execution_binding=AsyncMock(return_value=binding)),
instance_config=SimpleNamespace(data={}),
)
handler = RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), ap)
handler.register_installation_binding(binding, plugin_author='author-a', plugin_name='plugin-a')
ap.plugin_connector = SimpleNamespace(handler=handler)
return SimpleNamespace(ap=ap, handler=handler, binding=binding, api=ExecutionAPI(ap), tenant=tenant)
def run_session(host, **changes):
result = {
'run_id': 'run-a',
'plugin_identity': 'author-a/plugin-a',
'authorization': {
'workspace_id': host.binding.workspace_uuid,
'available_apis': {},
'resources': {},
'authorized_ids': {},
},
}
result.update(changes)
return result
def stub_run(monkeypatch, session):
monkeypatch.setattr(get_session_registry(), 'get', AsyncMock(return_value=session))
def install_action(host, name, action):
# The production constructor applies this wrapper after registration.
host.handler.actions[name] = action
host.handler._secure_plugin_actions()
@pytest.mark.asyncio
async def test_catalog_covers_all_core_business_actions_and_keeps_transport_internal(host):
excluded = {'initialize_plugin_settings', 'get_plugin_settings'}
expected = {name for name in host.handler.actions if not name.startswith('__') and name not in excluded}
assert {op.action for op in host.api.operations()} == expected
assert len(host.api.catalog()) == len(expected)
for operation in host.api.catalog():
assert operation['input_schema']['additionalProperties'] is False
assert operation['output_schema']
result = await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(host.binding))
assert result['version']
assert host.handler.current_action_context is None
assert host.tenant.get() is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'name', ['__ping', '__file_chunk', 'initialize_plugin_settings', 'get_plugin_settings', 'missing']
)
async def test_runtime_control_actions_are_not_exposed(host, name):
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(f'execution.{name}', ExecutionAPIContext(host.binding))
assert exc.value.error.code == 'unknown_operation'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'payload',
[
{'run_id': None},
{'run_id': 'another-run'},
{'workspace_uuid': 'workspace-b'},
{'caller_plugin_identity': 'other/plugin'},
{'action_context': {}},
{'placement_generation': 8},
],
)
async def test_payload_cannot_supply_authentication_fields(host, payload):
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(host.binding), payload)
assert exc.value.error.code == 'invalid_input'
assert host.handler.current_action_context is None
@pytest.mark.asyncio
@pytest.mark.parametrize('field,value', [('runtime_revision', 2), ('artifact_digest', 'b' * 64)])
async def test_stale_installation_is_rejected_before_business_dispatch(host, field, value):
altered = host.binding.model_copy(update={field: value})
business = AsyncMock(return_value=ActionResponse.success({'version': 'test'}))
install_action(host, 'get_langbot_version', business)
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(altered))
assert exc.value.error.code == 'permission_denied'
business.assert_not_called()
assert host.handler.current_action_context is None
assert host.tenant.get() is None
@pytest.mark.asyncio
async def test_stale_generation_and_incomplete_binding_are_rejected(host):
host.ap.workspace_service.get_execution_binding.return_value = host.binding.model_copy(
update={'placement_generation': 8}
)
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(host.binding))
assert exc.value.error.code == 'permission_denied'
incomplete = ActionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=7)
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(incomplete))
assert exc.value.error.code == 'invalid_authentication'
@pytest.mark.asyncio
@pytest.mark.parametrize('session', [None, {'workspace': 'workspace-b'}, {'plugin': 'other/plugin'}])
async def test_run_context_cannot_cross_workspace_or_plugin(host, monkeypatch, session):
actual = run_session(host)
if session is None:
actual = None
elif 'workspace' in session:
actual['authorization']['workspace_id'] = session['workspace']
else:
actual['plugin_identity'] = session['plugin']
stub_run(monkeypatch, actual)
business = AsyncMock(return_value=ActionResponse.success({'version': 'test'}))
install_action(host, 'get_langbot_version', business)
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(host.binding, 'run-a'))
assert exc.value.error.code == 'permission_denied'
business.assert_not_called()
@pytest.mark.asyncio
async def test_run_identity_is_injected_and_cannot_fall_back_to_plugin_privileges(host, monkeypatch):
stub_run(monkeypatch, run_session(host))
business = AsyncMock(return_value=ActionResponse.success({'version': 'test'}))
install_action(host, 'get_langbot_version', business)
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(host.binding, 'run-a'))
assert business.call_args.args[0] == {'run_id': 'run-a', 'caller_plugin_identity': 'author-a/plugin-a'}
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_bots', ExecutionAPIContext(host.binding, 'run-a'))
assert exc.value.error.code == 'permission_denied'
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_prompt', ExecutionAPIContext(host.binding))
assert exc.value.error.code == 'permission_denied'
@pytest.mark.asyncio
async def test_resource_denial_uses_real_secured_host_action(host, monkeypatch):
stub_run(monkeypatch, run_session(host))
host.ap.model_mgr = SimpleNamespace(get_model_by_uuid=AsyncMock())
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.invoke_llm',
ExecutionAPIContext(host.binding, 'run-a'),
{
'llm_model_uuid': 'ungranted-model',
'messages': [],
},
)
assert 'not authorized' in exc.value.error.message
host.ap.model_mgr.get_model_by_uuid.assert_not_called()
assert host.handler.current_action_context is None
@pytest.mark.asyncio
async def test_run_capability_denial_uses_real_secured_host_action(host, monkeypatch):
stub_run(monkeypatch, run_session(host))
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.state_get', ExecutionAPIContext(host.binding, 'run-a'), {'scope': 'conversation', 'key': 'x'}
)
assert 'not authorized' in exc.value.error.message
assert host.handler.current_action_context is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'payload',
[
{'llm_model_uuid': 1, 'messages': []},
{'llm_model_uuid': 'model', 'messages': 'invalid'},
{'llm_model_uuid': 'model', 'messages': [], 'extra_args': {'x': object()}},
],
)
async def test_input_type_validation_precedes_provider_calls(host, payload):
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.invoke_llm', ExecutionAPIContext(host.binding), payload)
assert exc.value.error.code == 'invalid_input'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'response',
[
ActionResponse.success({'tokens': 'wrong'}),
ActionResponse.success({}),
ActionResponse.success({'tokens': 1, 'unknown': object()}),
{'tokens': 1},
],
)
async def test_invalid_output_is_not_reported_as_success(host, response):
install_action(host, 'count_tokens', AsyncMock(return_value=response))
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.count_tokens', ExecutionAPIContext(host.binding), {'llm_model_uuid': 'model', 'messages': []}
)
assert exc.value.error.code == 'invalid_output'
@pytest.mark.asyncio
async def test_failure_and_cancellation_restore_outer_context(host):
outer = host.binding.model_copy(update={'workspace_uuid': 'outer'})
token = host.handler._current_action_context.set(outer)
try:
install_action(host, 'get_langbot_version', AsyncMock(side_effect=RuntimeError('private provider error')))
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(host.binding))
assert exc.value.error.message == 'Execution operation failed'
assert host.handler.current_action_context is outer
install_action(host, 'get_langbot_version', AsyncMock(side_effect=asyncio.CancelledError()))
with pytest.raises(asyncio.CancelledError):
await host.api.invoke('execution.get_langbot_version', ExecutionAPIContext(host.binding))
assert host.handler.current_action_context is outer
assert host.tenant.get() is None
finally:
host.handler._current_action_context.reset(token)
@pytest.mark.asyncio
async def test_stream_isolates_context_from_consumer_and_closes_on_early_exit(host):
closed = asyncio.Event()
async def provider(data):
try:
assert host.handler.current_action_context == host.binding
assert host.tenant.get() == 'workspace-a'
yield ActionResponse.success({'chunk': {'text': 'first'}})
await asyncio.Event().wait()
finally:
assert host.handler.current_action_context == host.binding
closed.set()
install_action(host, 'invoke_llm_stream', provider)
stream = host.api.stream(
'execution.invoke_llm_stream', ExecutionAPIContext(host.binding), {'llm_model_uuid': 'model', 'messages': []}
)
async with contextlib.aclosing(stream):
assert await anext(stream) == {'chunk': {'text': 'first'}}
assert host.handler.current_action_context is None
assert host.tenant.get() is None
assert closed.is_set()
assert host.handler.current_action_context is None
assert host.tenant.get() is None
@pytest.mark.asyncio
async def test_stream_reports_late_failure_and_cancels_provider(host):
closed = asyncio.Event()
async def provider(data):
try:
yield ActionResponse.success({'chunk': {'text': 'first'}})
yield ActionResponse.error('provider refused request')
finally:
closed.set()
install_action(host, 'invoke_llm_stream', provider)
async with contextlib.aclosing(
host.api.stream(
'execution.invoke_llm_stream',
ExecutionAPIContext(host.binding),
{'llm_model_uuid': 'model', 'messages': []},
)
) as stream:
assert await anext(stream) == {'chunk': {'text': 'first'}}
with pytest.raises(ApplicationAPIException, match='provider refused request'):
await anext(stream)
assert closed.is_set()
assert host.tenant.get() is None
@pytest.mark.asyncio
async def test_stream_cancellation_closes_provider(host):
started, closed = asyncio.Event(), asyncio.Event()
async def provider(data):
try:
started.set()
await asyncio.Event().wait()
yield ActionResponse.success({'chunk': {}})
finally:
closed.set()
install_action(host, 'invoke_llm_stream', provider)
stream = host.api.stream(
'execution.invoke_llm_stream', ExecutionAPIContext(host.binding), {'llm_model_uuid': 'model', 'messages': []}
)
consumer = asyncio.create_task(anext(stream))
await asyncio.wait_for(started.wait(), timeout=2)
consumer.cancel()
with pytest.raises(asyncio.CancelledError):
await consumer
assert closed.is_set()
assert host.handler.current_action_context is None
assert host.tenant.get() is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'operation,payload',
[
('call_tool', {'tool_name': 'ungranted-tool', 'parameters': {}}),
('get_tool_detail', {'tool_name': 'ungranted-tool'}),
('retrieve_knowledge', {'kb_id': 'ungranted-kb', 'query_text': 'test'}),
('invoke_embedding', {'embedding_model_uuid': 'ungranted-model', 'texts': ['test']}),
('invoke_rerank', {'rerank_model_uuid': 'ungranted-model', 'query': 'test', 'documents': ['test']}),
('get_binary_storage', {'owner_type': 'workspace', 'key': 'key'}),
],
)
async def test_resource_families_preserve_frozen_grants(host, monkeypatch, operation, payload):
stub_run(monkeypatch, run_session(host))
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(f'execution.{operation}', ExecutionAPIContext(host.binding, 'run-a'), payload)
assert 'not authorized' in exc.value.error.message
assert host.handler.current_action_context is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'operation,payload',
[
('history_page', {}),
('event_get', {'event_id': 'event-a'}),
('bind_box', {'box_id': 'box-a'}),
('export_box_files', {'paths': ['report.txt']}),
],
)
async def test_run_capability_families_cannot_fall_back_to_plugin_access(host, monkeypatch, operation, payload):
stub_run(monkeypatch, run_session(host))
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(f'execution.{operation}', ExecutionAPIContext(host.binding, 'run-a'), payload)
assert 'not authorized' in exc.value.error.message
@pytest.mark.asyncio
async def test_box_export_preserves_structured_failure(host, monkeypatch):
from langbot.pkg.box.export_errors import export_error
stub_run(monkeypatch, run_session(host))
error = export_error('file_bytes', limit=100, actual=101, path='report.txt')
install_action(
host,
'export_box_files',
AsyncMock(
return_value=ActionResponse.error(
str(error),
data={'error': error.error.model_dump(mode='json')},
)
),
)
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.export_box_files', ExecutionAPIContext(host.binding, 'run-a'), {'paths': ['report.txt']}
)
assert exc.value.error.details['error']['code'] == 'box.export.limit_exceeded'
assert exc.value.error.details['error']['details']['files_retained'] is True
@pytest.mark.asyncio
async def test_frozen_tool_source_cannot_be_replaced(host, monkeypatch):
session = run_session(host)
session['authorization']['authorized_ids'] = {'tool': {'tool-a'}}
session['authorization']['resources'] = {'tools': [{'tool_name': 'tool-a'}]}
stub_run(monkeypatch, session)
host.ap.tool_mgr = SimpleNamespace(execute_func_call=AsyncMock())
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.call_tool', ExecutionAPIContext(host.binding, 'run-a'), {'tool_name': 'tool-a', 'parameters': {}}
)
assert 'source' in exc.value.error.message.lower()
host.ap.tool_mgr.execute_func_call.assert_not_called()
@pytest.mark.asyncio
async def test_runner_admin_operations_do_not_grant_admin_implicitly(host):
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke('execution.run_stats', ExecutionAPIContext(host.binding))
assert 'not authorized' in exc.value.error.message
@pytest.mark.asyncio
async def test_query_identity_cannot_be_replaced_in_run_context(host, monkeypatch):
stub_run(monkeypatch, run_session(host))
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.invoke_llm',
ExecutionAPIContext(host.binding, 'run-a'),
{
'llm_model_uuid': 'model',
'messages': [],
'query_id': 'other-query',
},
)
assert exc.value.error.code == 'invalid_input'
@pytest.mark.asyncio
async def test_provider_cancellation_cannot_leave_stream_consumer_waiting(host):
async def provider(data):
raise asyncio.CancelledError()
yield
install_action(host, 'invoke_llm_stream', provider)
stream = host.api.stream(
'execution.invoke_llm_stream', ExecutionAPIContext(host.binding), {'llm_model_uuid': 'model', 'messages': []}
)
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(anext(stream), timeout=2)
assert host.tenant.get() is None
@pytest.mark.asyncio
async def test_concurrent_streams_keep_independent_installation_contexts(host):
other = host.binding.model_copy(
update={'workspace_uuid': 'workspace-b', 'installation_uuid': '00000000-0000-4000-8000-000000000002'}
)
host.handler.register_installation_binding(other, plugin_author='author-a', plugin_name='plugin-a')
host.ap.workspace_service.get_execution_binding.side_effect = (
lambda workspace, **kwargs: host.binding if workspace == 'workspace-a' else other
)
async def provider(data):
workspace = host.handler.current_action_context.workspace_uuid
for _ in range(2):
await asyncio.sleep(0)
assert host.handler.current_action_context.workspace_uuid == workspace
assert host.tenant.get() == workspace
yield ActionResponse.success({'chunk': {'workspace': workspace}})
install_action(host, 'invoke_llm_stream', provider)
async def consume(binding):
results = []
async with contextlib.aclosing(
host.api.stream(
'execution.invoke_llm_stream', ExecutionAPIContext(binding), {'llm_model_uuid': 'model', 'messages': []}
)
) as stream:
async for chunk in stream:
assert host.handler.current_action_context is None
assert host.tenant.get() is None
results.append(chunk['chunk']['workspace'])
return results
assert await asyncio.gather(consume(host.binding), consume(other)) == [['workspace-a'] * 2, ['workspace-b'] * 2]
@pytest.mark.asyncio
async def test_tool_parameters_reach_legacy_plugin_and_run_handlers(host, monkeypatch):
business = AsyncMock(return_value=ActionResponse.success({'result': 'ok'}))
install_action(host, 'call_tool', business)
await host.api.invoke(
'execution.call_tool', ExecutionAPIContext(host.binding), {'tool_name': 'tool', 'parameters': {'x': 1}}
)
assert business.call_args.args[0]['tool_parameters'] == {'x': 1}
assert 'parameters' not in business.call_args.args[0]
stub_run(monkeypatch, run_session(host))
await host.api.invoke('execution.call_tool', ExecutionAPIContext(host.binding, 'run-a'), {'tool_name': 'tool'})
assert business.call_args.args[0]['parameters'] == {}
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.call_tool',
ExecutionAPIContext(host.binding, 'run-a'),
{'tool_name': 'tool', 'tool_parameters': {}},
)
assert exc.value.error.code == 'invalid_input'
@pytest.mark.asyncio
async def test_platform_call_requires_bot_identity_outside_run(host):
with pytest.raises(ApplicationAPIException) as exc:
await host.api.invoke(
'execution.call_platform_api', ExecutionAPIContext(host.binding), {'action': 'send_message'}
)
assert exc.value.error.code == 'invalid_input'